commit 52d95e9370a3d810f764e6453b24a23b8e91fa4d Author: Samuel Ć tancl Date: Tue Dec 31 06:35:15 2024 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eba6c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +src/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2459037 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Tenancy for Laravel queue testing suite + +In addition to the tests we can write using testbench, we have this repository which: +1. Creates a new Laravel application +2. Sets up Tenancy +3. Creates a sample job +4. Asserts that the queue worker is working as expected + +This is mostly due to some past bugs that were hard to catch in our test suite. + +With this repo, we can have a separate CI job validating queue behavior _in a real application_. + +## TODOs + +- Verify how `queue:restart` works in v4 diff --git a/cli/Dockerfile b/cli/Dockerfile new file mode 100644 index 0000000..e10e334 --- /dev/null +++ b/cli/Dockerfile @@ -0,0 +1,25 @@ +ARG PHP_VERSION=8.4 + +FROM php:${PHP_VERSION}-cli-bookworm + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" + +RUN apt update && apt install -y git unzip libzip-dev libicu-dev \ + && apt autoremove && apt clean + +RUN pecl install redis && docker-php-ext-enable redis +RUN docker-php-ext-install zip && docker-php-ext-enable zip +RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql +RUN docker-php-ext-install intl && docker-php-ext-enable intl + +WORKDIR /var/www/html + +RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && php composer-setup.php + +RUN mv composer.phar /usr/local/bin/composer + +RUN mkdir /var/www/.composer && chmod -R 777 /var/www/.composer + +RUN mkdir -p /var/www/.config/psysh && chmod -R 777 /var/www/.config/psysh + +USER www-data diff --git a/cli/build.sh b/cli/build.sh new file mode 100755 index 0000000..de72663 --- /dev/null +++ b/cli/build.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +tag="tenancy-queue-test-cli" +php="8.4" + +docker build --build-arg PHP_VERSION=$php -t $tag . diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..515392e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + redis: + image: redis:7-bookworm + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 5s + retries: 2 + start_period: 1s + + queue: + image: tenancy-queue-test-cli + working_dir: /var/www/html + user: www-data + command: php artisan queue:work -vvv + healthcheck: + test: ["CMD-SHELL", "pidof php"] + interval: 30s + timeout: 2s + retries: 2 + start_period: 2s + depends_on: + redis: + condition: service_healthy + volumes: + - ./src:/var/www/html diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..0e1cb83 --- /dev/null +++ b/setup.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +TENANCY_VERSION=${TENANCY_VERSION:-"dev-master"} + +set -e + +(cd cli && ./build.sh) + +rm -rf src + +docker run --rm -it -e TENANCY_VERSION="${TENANCY_VERSION}" -v .:/var/www/html tenancy-queue-test-cli bash setup/_tenancy_setup.sh diff --git a/setup/FooJob.php b/setup/FooJob.php new file mode 100644 index 0000000..a609bab --- /dev/null +++ b/setup/FooJob.php @@ -0,0 +1,19 @@ + Str::random(12), 'email' => Str::random(12), 'password' => Str::random(12)]); + } +} + diff --git a/setup/Tenant_base.php b/setup/Tenant_base.php new file mode 100644 index 0000000..535c6f9 --- /dev/null +++ b/setup/Tenant_base.php @@ -0,0 +1,13 @@ + \[.*$/'\''prefixed_connections'\'' => [ '\''cache'\'',/' config/tenancy.php +echo "REDIS_QUEUE_CONNECTION=queue" >> .env + +rm config/database.php +cp ../setup/database.php config/database.php + +cp database/migrations/*create_users*.php database/migrations/tenant + +mkdir app/Jobs +cp ../setup/FooJob.php app/Jobs/FooJob.php diff --git a/setup/database.php b/setup/database.php new file mode 100644 index 0000000..1700fed --- /dev/null +++ b/setup/database.php @@ -0,0 +1,182 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'queue' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + ], + +]; diff --git a/setup/providers.php b/setup/providers.php new file mode 100644 index 0000000..c6a4f12 --- /dev/null +++ b/setup/providers.php @@ -0,0 +1,6 @@ +run(function () { dispatch(new App\Jobs\FooJob); });" + sleep 5 +} + + +###################################### SETUP ###################################### + +rm -f src/database.sqlite +rm -f src/database/tenantfoo.sqlite + +docker compose start redis # in case it's not running - the below setup code needs Redis to be running + +docker compose run --rm queue php artisan migrate:fresh >/dev/null +docker compose run --rm queue php artisan tinker -v --execute "App\\Models\\Tenant::create(['id' => 'foo', 'tenancy_db_name' => 'tenantfoo.sqlite']);" + +docker compose down; docker compose up -d --wait +docker compose logs -f queue & + +# Kill any log watchers that may still be alive +trap "docker compose stop queue" EXIT + +echo "Setup complete, starting tests...\n" + +################### BASIC PHASE: Assert jobs use the right context ################### + +dispatch_tenant_job +assert_tenant_users 1 +assert_central_users 0 +echo "OK: User created in tenant\n" + +dispatch_central_job +assert_tenant_users 1 +assert_central_users 1 +echo "OK: User created in central\n" + +############# RESTART PHASE: Assert the worker always responds to signals ############# + +echo "Running queue:restart (after a central job)..." +docker compose exec -T queue php artisan queue:restart >/dev/null +sleep 3 +assert_queue_worker_exited +echo "OK: Queue worker has exited\n" + +echo "Starting queue worker again..." +docker compose restart queue +sleep 3 +docker compose logs -f queue & + +echo + +dispatch_tenant_job +# IMPORTANT: +# If the worker remains in the tenant context after running a job +# it not only fails the final assertion here by not responding to queue:restart. +# It ALSO prematurely restarts right here! See https://github.com/archtechx/tenancy/issues/1229#issuecomment-2566111616 +# However, we're not too interested in checking for an extra restart, so we skip +# queue assertions here and only check that the job executed correctly. +# Then, if the queue worker has shut down, we simply start it up again and continue +# with the tests. That said, if the warning has been printed, it should be pretty much +# guaranteed that the assertion about queue:restart post-tenant job will fail too. +without_queue_assertions assert_tenant_users 2 +without_queue_assertions assert_central_users 1 +echo "OK: User created in tenant\n" + +if docker compose ps -a --format '{{.Status}}' queue | grep -q "Exited"; then + echo "WARN: Queue worker restarted after running a tenant job post-restart (https://github.com/archtechx/tenancy/issues/1229#issuecomment-2566111616) following assertions will likely fail." + docker compose start queue # Start the worker back up + sleep 3 + docker compose logs -f queue & +else + echo "OK: No extra restart took place" +fi + +# Following the above, we also want to check if this only happens the first +# time a job is dispatched for a tenant (with central illuminate:queue:restart) filled +# and fills the TENANT's illuminate:queue:restart from then on, or if this happens on +# future jobs of that tenant as well. + +# This time, just to add more context, we can try to dispatch a central job first +# in case it changes anything. But odds are that in broken setups we'll see both warnings. +dispatch_central_job +without_queue_assertions assert_tenant_users 2 +without_queue_assertions assert_central_users 2 +echo "OK: User created in central\n" + +dispatch_tenant_job +without_queue_assertions assert_tenant_users 3 +without_queue_assertions assert_central_users 2 +echo "OK: User created in tenant\n" + +if docker compose ps -a --format '{{.Status}}' queue | grep -q "Exited"; then + echo "WARN: ANOTHER extra restart took place after running a tenant job" + docker compose start queue # Start the worker back up + sleep 3 + docker compose logs -f queue & +else + echo "OK: No second extra restart took place" +fi + +# We have to clear the central illuminate:queue:restart value here +# to make the last assertion work, because if the previous WARNs were +# triggered, that means the following *tenant job dispatch* will trigger +# a restart as well. +# -n 1 = DB number for cache connection, configured in setup/database.php +docker compose exec redis redis-cli -n 1 DEL laravel_database_illuminate:queue:restart >/dev/null + +# Also make the queue worker reload the value from cache +docker compose restart queue +docker compose logs -f queue & + +# Finally, we dispatch a tenant job *immediately* before a restart. +dispatch_tenant_job +assert_tenant_users 4 +assert_central_users 2 +echo "OK: User created in tenant\n" + +echo "Running queue:restart (after a tenant job)..." +docker compose exec -T queue php artisan queue:restart >/dev/null +sleep 3 +assert_queue_worker_exited +echo "OK: Queue worker has exited"