From dc491aeb63acd3366f0f23b1672b27622c8634d5 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Mon, 20 Apr 2026 15:19:08 +1000 Subject: [PATCH 1/4] Add default retry policy to queued jobs Set $tries=3 and exponential $backoff=[30,60,120] on AbstractJob (the base class extended by most queued jobs) and on SendReplyNotification (the only queued job that extends Queueable directly). With queue:work --tries=0 (the default on some hosting platforms, including Vapor prior to vapor-core 2.4.1), jobs inheriting no retry config retry indefinitely on transient failures. Bounded attempts with exponential backoff give DB/SMTP/filesystem blips time to recover, then fail cleanly. Subclasses that want a different policy can override either property. retryUntil() is not used by any current job, so the count-based policy is effective (Laravel's Worker silently ignores $tries when retryUntil() is set; see Worker.php:612 on 13.x, framework PR #35214). --- extensions/subscriptions/src/Job/SendReplyNotification.php | 6 ++++++ framework/core/src/Queue/AbstractJob.php | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/extensions/subscriptions/src/Job/SendReplyNotification.php b/extensions/subscriptions/src/Job/SendReplyNotification.php index a598210d3a..35a4306552 100644 --- a/extensions/subscriptions/src/Job/SendReplyNotification.php +++ b/extensions/subscriptions/src/Job/SendReplyNotification.php @@ -24,6 +24,12 @@ class SendReplyNotification implements ShouldQueue use Queueable; use SerializesModels; + /** The maximum number of times the job may be attempted. */ + public int $tries = 3; + + /** Delay in seconds between retries. */ + public array $backoff = [30, 60, 120]; + public function __construct( protected Post $post, protected ?int $lastPostNumber diff --git a/framework/core/src/Queue/AbstractJob.php b/framework/core/src/Queue/AbstractJob.php index 6ec8235a8c..f16abdb465 100644 --- a/framework/core/src/Queue/AbstractJob.php +++ b/framework/core/src/Queue/AbstractJob.php @@ -19,4 +19,10 @@ class AbstractJob implements ShouldQueue use InteractsWithQueue; use Queueable; use SerializesModels; + + /** The maximum number of times the job may be attempted. */ + public int $tries = 3; + + /** Delay in seconds between retries. */ + public array $backoff = [30, 60, 120]; } From 0555c5bfb1df92fba4d15527820aefd93334d27f Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Mon, 20 Apr 2026 15:55:40 +1000 Subject: [PATCH 2/4] Opt three non-idempotent jobs out of retry defaults - SendNotificationsJob: Notification::notify() uses a raw bulk insert (framework/core/src/Notification/Notification.php), so retries would create duplicate notification rows. - ExportJob (gdpr): writes an export file and dispatches Exporting/ Exported events; retries would duplicate both. - ComposerCommandJob (package-manager): composer install is not idempotent, and an OOM bypasses the handle() try/catch that normally swallows exceptions, so a retry could run against a partially-written vendor/. Each job sets $tries = 1 to override the AbstractJob default. --- extensions/gdpr/src/Jobs/ExportJob.php | 6 ++++++ extensions/package-manager/src/Job/ComposerCommandJob.php | 6 ++++++ .../core/src/Notification/Job/SendNotificationsJob.php | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/extensions/gdpr/src/Jobs/ExportJob.php b/extensions/gdpr/src/Jobs/ExportJob.php index 6fa7137f75..e3a10113ad 100644 --- a/extensions/gdpr/src/Jobs/ExportJob.php +++ b/extensions/gdpr/src/Jobs/ExportJob.php @@ -20,6 +20,12 @@ class ExportJob extends GdprJob { + /** + * Each run writes an export file and dispatches Exporting/Exported events; + * retries would duplicate the file and fire listeners more than once. + */ + public int $tries = 1; + public function __construct(private User $user, private User $actor) { } diff --git a/extensions/package-manager/src/Job/ComposerCommandJob.php b/extensions/package-manager/src/Job/ComposerCommandJob.php index b195a4d633..45e0eb28f1 100644 --- a/extensions/package-manager/src/Job/ComposerCommandJob.php +++ b/extensions/package-manager/src/Job/ComposerCommandJob.php @@ -25,6 +25,12 @@ class ComposerCommandJob extends AbstractJob implements ShouldBeUnique */ public int $timeout = 60 * 3; + /** + * Composer commands are not idempotent; a retry after OOM (which bypasses + * the handle() try/catch) could run against a partially-modified vendor/. + */ + public int $tries = 1; + public function __construct( protected AbstractActionCommand $command, protected string $phpVersion diff --git a/framework/core/src/Notification/Job/SendNotificationsJob.php b/framework/core/src/Notification/Job/SendNotificationsJob.php index 72a9eb3ed2..e3c1a34720 100644 --- a/framework/core/src/Notification/Job/SendNotificationsJob.php +++ b/framework/core/src/Notification/Job/SendNotificationsJob.php @@ -17,6 +17,12 @@ class SendNotificationsJob extends AbstractJob { + /** + * Notification::notify() uses a raw bulk insert (not upsert), so retrying + * would create duplicate notification rows in recipients' feeds. + */ + public int $tries = 1; + public function __construct( private readonly BlueprintInterface&AlertableInterface $blueprint, /** @var User[] */ From e1a6148aaf576d38b262f437d4368815778d56d6 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Mon, 20 Apr 2026 20:35:26 +1000 Subject: [PATCH 3/4] Bound ComposerCommandJob lock lifetimes ShouldBeUnique defaults $uniqueFor to 0, which means the lock never expires. WithoutOverlapping without expireAfter has the same pattern. If the worker crashes mid-composer-install, either lock can otherwise block future dispatches until cache TTL runs down. Both now bounded at 600 seconds (longer than $timeout's 180s so they can't expire mid-run, short enough to auto-clear after a crash). --- .../src/Job/ComposerCommandJob.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/extensions/package-manager/src/Job/ComposerCommandJob.php b/extensions/package-manager/src/Job/ComposerCommandJob.php index 45e0eb28f1..537c22f9ea 100644 --- a/extensions/package-manager/src/Job/ComposerCommandJob.php +++ b/extensions/package-manager/src/Job/ComposerCommandJob.php @@ -31,6 +31,16 @@ class ComposerCommandJob extends AbstractJob implements ShouldBeUnique */ public int $tries = 1; + /** + * How long (seconds) the ShouldBeUnique lock is held. + * + * Without an explicit value the default is 0, meaning the lock never + * expires. If the worker crashes mid-install, the lock can otherwise + * block future dispatches until the cache TTL runs down. 600s is + * longer than $timeout above so it won't expire mid-run. + */ + public int $uniqueFor = 600; + public function __construct( protected AbstractActionCommand $command, protected string $phpVersion @@ -73,7 +83,10 @@ public function failed(Throwable $exception): void public function middleware(): array { return [ - new WithoutOverlapping(), + // expireAfter matches $uniqueFor above so a crashed worker + // can't leave the overlap lock permanently held (default 0 + // would mean the lock never expires). + (new WithoutOverlapping())->expireAfter(600), ]; } } From 6c6d8df779fe8c6fa422e6849aaeb2a0536d175b Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Mon, 20 Apr 2026 20:42:12 +1000 Subject: [PATCH 4/4] Drop duplicate ComposerCommandJob dispatches instead of tight-looping WithoutOverlapping defaults releaseAfter to 0, which re-queues a duplicate dispatch immediately. If one somehow slips past the ShouldBeUnique guard (stale $uniqueFor, dispatch race), a second worker would tight-loop against the held lock. ->dontRelease() fails the duplicate cleanly instead, so the admin sees an explicit 'already running' rather than a stealth second composer install after the first completes. --- extensions/package-manager/src/Job/ComposerCommandJob.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/package-manager/src/Job/ComposerCommandJob.php b/extensions/package-manager/src/Job/ComposerCommandJob.php index 537c22f9ea..fb6ceffdfe 100644 --- a/extensions/package-manager/src/Job/ComposerCommandJob.php +++ b/extensions/package-manager/src/Job/ComposerCommandJob.php @@ -85,8 +85,12 @@ public function middleware(): array return [ // expireAfter matches $uniqueFor above so a crashed worker // can't leave the overlap lock permanently held (default 0 - // would mean the lock never expires). - (new WithoutOverlapping())->expireAfter(600), + // would mean the lock never expires). dontRelease fails a + // duplicate dispatch cleanly instead of tight-looping it + // against the held lock (default releaseAfter=0). + (new WithoutOverlapping()) + ->expireAfter(600) + ->dontRelease(), ]; } }