fix(queue): raise retry_after above the longest job timeout

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
This commit is contained in:
Víctor Falcón 2026-07-05 11:14:01 +02:00
parent 26875bbfff
commit e7e319a5b8
2 changed files with 43 additions and 1 deletions

View File

@ -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,
],

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* The database queue driver releases a job back to the queue once its
* reservation is older than retry_after. If a job runs longer than that, a
* second worker picks it up: a tries=1 job then dies with
* MaxAttemptsExceededException and re-runs its side effects. Guard the
* invariant so a new long-running job can never silently reintroduce it.
*/
test('every queued job timeout stays below the database queue retry_after', function () {
$retryAfter = (int) config('queue.connections.database.retry_after');
$offenders = collect(File::allFiles(app_path('Jobs')))
->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.'
);
});