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..fb6ceffdfe 100644 --- a/extensions/package-manager/src/Job/ComposerCommandJob.php +++ b/extensions/package-manager/src/Job/ComposerCommandJob.php @@ -25,6 +25,22 @@ 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; + + /** + * 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 @@ -67,7 +83,14 @@ 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). dontRelease fails a + // duplicate dispatch cleanly instead of tight-looping it + // against the held lock (default releaseAfter=0). + (new WithoutOverlapping()) + ->expireAfter(600) + ->dontRelease(), ]; } } 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/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[] */ 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]; }