From e7e319a5b80084fd2c3946f02f85a2b69331e198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sun, 5 Jul 2026 11:14:01 +0200 Subject: [PATCH] fix(queue): raise retry_after above the longest job timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The database queue connection used retry_after=90s while several jobs declare timeouts far above it (up to 600s). When a job outlives its reservation window the queue hands it to a second worker; a tries=1 job then fails with MaxAttemptsExceededException and re-runs its side effects (double model billing, reset progress) — exactly what CategorizeUncategorizedTransactionsJob's tries=1 was meant to prevent. Raise the default to 900s (env-overridable via DB_QUEUE_RETRY_AFTER) so it exceeds the longest job timeout, and add a guard test asserting every app/Jobs $timeout stays below retry_after so a new long job cannot silently reintroduce the bug. Fixes PHP-LARAVEL-2D --- config/queue.php | 7 +++++- tests/Feature/QueueConfigTest.php | 37 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/QueueConfigTest.php diff --git a/config/queue.php b/config/queue.php index 4fe599aa..cfa5fbc2 100644 --- a/config/queue.php +++ b/config/queue.php @@ -40,7 +40,12 @@ return [ 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + // retry_after must exceed the longest job $timeout on this connection + // (currently 600s). Otherwise a long job outlives its reservation, the + // queue hands it to a second worker, and a tries=1 job dies with + // MaxAttemptsExceededException (and re-runs its side effects). The + // worker --timeout must sit between the longest timeout and this value. + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 900), 'after_commit' => true, ], diff --git a/tests/Feature/QueueConfigTest.php b/tests/Feature/QueueConfigTest.php new file mode 100644 index 00000000..e510cc45 --- /dev/null +++ b/tests/Feature/QueueConfigTest.php @@ -0,0 +1,37 @@ +map(function ($file): string { + $relative = Str::of($file->getPathname()) + ->after(app_path().DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR, '\\') + ->replaceLast('.php', ''); + + return 'App\\'.$relative; + }) + ->filter(fn (string $class): bool => class_exists($class)) + ->mapWithKeys(function (string $class): array { + $timeout = (new ReflectionClass($class))->getDefaultProperties()['timeout'] ?? null; + + return [$class => $timeout]; + }) + ->filter(fn ($timeout): bool => is_int($timeout) && $timeout >= $retryAfter); + + expect($offenders->all())->toBe( + [], + 'These jobs have a $timeout >= retry_after ('.$retryAfter.'s); raise DB_QUEUE_RETRY_AFTER above the longest job timeout.' + ); +});