From ededf2665b823786b61fe0f4ec0fed0352297d83 Mon Sep 17 00:00:00 2001 From: Joe Haines Date: Tue, 29 Nov 2022 10:08:06 +0000 Subject: [PATCH 1/2] Add basic tests for queued jobs on Laravel 9 In order to run the queue worker and HTTP server in the same docker container, I've enabled the 'init' option. This runs an init process as PID 1, which then spawns the queue worker & HTTP server See https://docs.docker.com/config/containers/multi-service_container/ and https://docs.docker.com/compose/compose-file/compose-file-v3/#init We _could_ do this with multiple containers, but that adds a lot more complexity for no real gain since we'd need 2 additional containers to accomplish the same thing (a database and a queue worker) This reduces the number of moving parts, making less likely to flake --- features/fixtures/docker-compose.yml | 4 +- features/fixtures/laravel9/.env.example | 9 +--- features/fixtures/laravel9/Dockerfile | 6 ++- .../fixtures/laravel9/app/Jobs/HandledJob.php | 27 ++++++++++++ .../laravel9/app/Jobs/UnhandledJob.php | 26 ++++++++++++ .../2022_11_29_094559_create_jobs_table.php | 36 ++++++++++++++++ features/fixtures/laravel9/routes/web.php | 8 ++++ features/fixtures/laravel9/run-fixture | 10 +++++ features/queues.feature | 42 +++++++++++++++++++ features/steps/laravel_steps.rb | 18 ++++++++ features/support/env.rb | 9 ++++ 11 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 features/fixtures/laravel9/app/Jobs/HandledJob.php create mode 100644 features/fixtures/laravel9/app/Jobs/UnhandledJob.php create mode 100644 features/fixtures/laravel9/database/migrations/2022_11_29_094559_create_jobs_table.php create mode 100755 features/fixtures/laravel9/run-fixture create mode 100644 features/queues.feature diff --git a/features/fixtures/docker-compose.yml b/features/fixtures/docker-compose.yml index ede198be..110ed101 100644 --- a/features/fixtures/docker-compose.yml +++ b/features/fixtures/docker-compose.yml @@ -1,4 +1,5 @@ -version: '3.4' +version: '3.8' + services: laravel51: build: @@ -96,6 +97,7 @@ services: published: 61280 laravel9: + init: true build: context: laravel9 args: diff --git a/features/fixtures/laravel9/.env.example b/features/fixtures/laravel9/.env.example index 8510237c..1e8a92cf 100644 --- a/features/fixtures/laravel9/.env.example +++ b/features/fixtures/laravel9/.env.example @@ -8,17 +8,12 @@ LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=mysql -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=laravel -DB_USERNAME=root -DB_PASSWORD= +DB_CONNECTION=sqlite BROADCAST_DRIVER=log CACHE_DRIVER=file FILESYSTEM_DISK=local -QUEUE_CONNECTION=sync +QUEUE_CONNECTION=database SESSION_DRIVER=file SESSION_LIFETIME=120 diff --git a/features/fixtures/laravel9/Dockerfile b/features/fixtures/laravel9/Dockerfile index cea033c5..fcd9bf65 100644 --- a/features/fixtures/laravel9/Dockerfile +++ b/features/fixtures/laravel9/Dockerfile @@ -17,4 +17,8 @@ RUN cp .env.example .env RUN composer install RUN php artisan key:generate -CMD php -S 0.0.0.0:8000 -t public +# apply database migrations +# --force is required to create the database +RUN php artisan migrate --force --no-interaction + +CMD ./run-fixture diff --git a/features/fixtures/laravel9/app/Jobs/HandledJob.php b/features/fixtures/laravel9/app/Jobs/HandledJob.php new file mode 100644 index 00000000..587677d3 --- /dev/null +++ b/features/fixtures/laravel9/app/Jobs/HandledJob.php @@ -0,0 +1,27 @@ +bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('jobs'); + } +}; diff --git a/features/fixtures/laravel9/routes/web.php b/features/fixtures/laravel9/routes/web.php index e7926f13..2ebe2765 100644 --- a/features/fixtures/laravel9/routes/web.php +++ b/features/fixtures/laravel9/routes/web.php @@ -52,6 +52,14 @@ Route::view('/handled_view_exception', 'handledexception'); Route::view('/handled_view_error', 'handlederror'); +Route::get('/queue/unhandled', function () { + \App\Jobs\UnhandledJob::dispatch(); +}); + +Route::get('/queue/handled', function () { + \App\Jobs\HandledJob::dispatch(); +}); + /** * Return some diagnostics if an OOM did not happen when it should have. * diff --git a/features/fixtures/laravel9/run-fixture b/features/fixtures/laravel9/run-fixture new file mode 100755 index 00000000..ea45de3d --- /dev/null +++ b/features/fixtures/laravel9/run-fixture @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +# run the PHP webserver +php -S 0.0.0.0:8000 -t public & + +# wait for any process to exit +wait -n + +# exit with the status of the first process to exit +exit $? diff --git a/features/queues.feature b/features/queues.feature new file mode 100644 index 00000000..c3eb67f4 --- /dev/null +++ b/features/queues.feature @@ -0,0 +1,42 @@ +Feature: Queue support + +@not-laravel-latest @not-laravel51 @not-laravel56 @not-laravel58 @not-laravel66 @not-laravel8 @not-lumen8 +Scenario: Unhandled exceptions are delivered from queues + Given I start the laravel fixture + And I start the laravel queue worker + When I navigate to the route "/queue/unhandled" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Bugsnag Laravel" notifier + And the exception "errorClass" equals "RuntimeException" + And the exception "message" equals "uh oh :o" + And the event "metaData.job.name" equals "Illuminate\Queue\CallQueuedHandler@call" + And the event "metaData.job.queue" equals "default" + And the event "metaData.job.attempts" equals 1 + And the event "metaData.job.connection" equals "database" + And the event "metaData.job.resolved" equals "App\Jobs\UnhandledJob" + And the event "app.type" equals "Queue" + And the event "context" equals "App\Jobs\UnhandledJob" + And the event "severity" equals "error" + And the event "unhandled" is true + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Laravel" + +@not-laravel-latest @not-laravel51 @not-laravel56 @not-laravel58 @not-laravel66 @not-laravel8 @not-lumen8 +Scenario: Handled exceptions are delivered from queues + Given I start the laravel fixture + And I start the laravel queue worker + When I navigate to the route "/queue/handled" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Bugsnag Laravel" notifier + And the exception "errorClass" equals "Exception" + And the exception "message" equals "Handled :)" + And the event "metaData.job.name" equals "Illuminate\Queue\CallQueuedHandler@call" + And the event "metaData.job.queue" equals "default" + And the event "metaData.job.attempts" equals 1 + And the event "metaData.job.connection" equals "database" + And the event "metaData.job.resolved" equals "App\Jobs\HandledJob" + And the event "app.type" equals "Queue" + And the event "context" equals "App\Jobs\HandledJob" + And the event "severity" equals "warning" + And the event "unhandled" is false + And the event "severityReason.type" equals "handledException" diff --git a/features/steps/laravel_steps.rb b/features/steps/laravel_steps.rb index 41bf94bd..7777928e 100644 --- a/features/steps/laravel_steps.rb +++ b/features/steps/laravel_steps.rb @@ -14,6 +14,24 @@ } end +# TODO: contribute this back to Maze Runner +# https://github.com/bugsnag/maze-runner/pull/425 +module Maze + class Docker + class << self + def exec(service, command, detach: false) + flags = detach ? "--detach" : "" + + run_docker_compose_command("exec #{flags} #{service} #{command}") + end + end + end +end + +When("I start the laravel queue worker") do + Maze::Docker.exec(Laravel.fixture, "php artisan queue:work", detach: true) +end + When("I navigate to the route {string}") do |route| Laravel.navigate_to(route) end diff --git a/features/support/env.rb b/features/support/env.rb index ad165533..4a969640 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -47,3 +47,12 @@ Before("@requires-sessions") do skip_this_scenario unless Laravel.supports_sessions? end + +# add a '@not-X' tag for each fixture +fixtures = Dir.each_child(File.realpath("#{PROJECT_ROOT}/features/fixtures")) do |name| + next unless name.match?(/^(laravel|lumen)/) + + Before("@not-#{name}") do + skip_this_scenario if Laravel.fixture == name + end +end From ab8918fe0509905258ddfa449335d03472ceea68 Mon Sep 17 00:00:00 2001 From: Joe Haines Date: Tue, 29 Nov 2022 14:51:07 +0000 Subject: [PATCH 2/2] Add a test with multiple attempts This shows the 'job.attempts' gets incremented for each attempt --- .../laravel9/app/Jobs/UnhandledJob.php | 7 +++ features/fixtures/laravel9/routes/web.php | 9 +-- features/queues.feature | 57 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/features/fixtures/laravel9/app/Jobs/UnhandledJob.php b/features/fixtures/laravel9/app/Jobs/UnhandledJob.php index f865c3cc..eb6c9b84 100644 --- a/features/fixtures/laravel9/app/Jobs/UnhandledJob.php +++ b/features/fixtures/laravel9/app/Jobs/UnhandledJob.php @@ -14,6 +14,13 @@ class UnhandledJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public int $tries; + + public function __construct(int $tries) + { + $this->tries = $tries; + } + /** * Execute the job. * diff --git a/features/fixtures/laravel9/routes/web.php b/features/fixtures/laravel9/routes/web.php index 2ebe2765..b7204196 100644 --- a/features/fixtures/laravel9/routes/web.php +++ b/features/fixtures/laravel9/routes/web.php @@ -1,5 +1,6 @@ query('tries', '1')); }); -Route::get('/queue/handled', function () { - \App\Jobs\HandledJob::dispatch(); +Route::get('/queue/handled', function (Request $request) { + \App\Jobs\HandledJob::dispatch((int) $request->query('tries', '1')); }); /** diff --git a/features/queues.feature b/features/queues.feature index c3eb67f4..de2af04b 100644 --- a/features/queues.feature +++ b/features/queues.feature @@ -21,6 +21,63 @@ Scenario: Unhandled exceptions are delivered from queues And the event "severityReason.type" equals "unhandledExceptionMiddleware" And the event "severityReason.attributes.framework" equals "Laravel" +@not-laravel-latest @not-laravel51 @not-laravel56 @not-laravel58 @not-laravel66 @not-laravel8 @not-lumen8 +Scenario: Unhandled exceptions are delivered from queued jobs with multiple attmpts + Given I start the laravel fixture + And I start the laravel queue worker + When I navigate to the route "/queue/unhandled?tries=3" + And I wait to receive 3 errors + + # attempt 1 + Then the error is valid for the error reporting API version "4.0" for the "Bugsnag Laravel" notifier + And the exception "errorClass" equals "RuntimeException" + And the exception "message" equals "uh oh :o" + And the event "metaData.job.name" equals "Illuminate\Queue\CallQueuedHandler@call" + And the event "metaData.job.queue" equals "default" + And the event "metaData.job.attempts" equals 1 + And the event "metaData.job.connection" equals "database" + And the event "metaData.job.resolved" equals "App\Jobs\UnhandledJob" + And the event "app.type" equals "Queue" + And the event "context" equals "App\Jobs\UnhandledJob" + And the event "severity" equals "error" + And the event "unhandled" is true + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Laravel" + + # attempt 2 + When I discard the oldest error + Then the error is valid for the error reporting API version "4.0" for the "Bugsnag Laravel" notifier + And the exception "errorClass" equals "RuntimeException" + And the exception "message" equals "uh oh :o" + And the event "metaData.job.name" equals "Illuminate\Queue\CallQueuedHandler@call" + And the event "metaData.job.queue" equals "default" + And the event "metaData.job.attempts" equals 2 + And the event "metaData.job.connection" equals "database" + And the event "metaData.job.resolved" equals "App\Jobs\UnhandledJob" + And the event "app.type" equals "Queue" + And the event "context" equals "App\Jobs\UnhandledJob" + And the event "severity" equals "error" + And the event "unhandled" is true + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Laravel" + + # attempt 3 + When I discard the oldest error + Then the error is valid for the error reporting API version "4.0" for the "Bugsnag Laravel" notifier + And the exception "errorClass" equals "RuntimeException" + And the exception "message" equals "uh oh :o" + And the event "metaData.job.name" equals "Illuminate\Queue\CallQueuedHandler@call" + And the event "metaData.job.queue" equals "default" + And the event "metaData.job.attempts" equals 3 + And the event "metaData.job.connection" equals "database" + And the event "metaData.job.resolved" equals "App\Jobs\UnhandledJob" + And the event "app.type" equals "Queue" + And the event "context" equals "App\Jobs\UnhandledJob" + And the event "severity" equals "error" + And the event "unhandled" is true + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Laravel" + @not-laravel-latest @not-laravel51 @not-laravel56 @not-laravel58 @not-laravel66 @not-laravel8 @not-lumen8 Scenario: Handled exceptions are delivered from queues Given I start the laravel fixture