@@ -549,4 +549,92 @@ public function extend(ReservedJob $job, int $seconds): bool
549549
550550 $ this ->assertStringContainsString ('Could not renew job lease: redis timeout ' , $ this ->output );
551551 }
552+
553+ // ------------------------------------------------------------------
554+ // Deferred cleanup
555+ // ------------------------------------------------------------------
556+
557+ /**
558+ * Install a container that implements the framework's defer scope API.
559+ *
560+ * @return object{log: array<int, string>}
561+ */
562+ private function installDeferAwareContainer (): object
563+ {
564+ $ container = new class extends MockContainer {
565+ public array $ log = [];
566+
567+ public int $ scopes = 0 ;
568+
569+ private array $ stack = [];
570+
571+ public function defer (callable $ callback ): static
572+ {
573+ if ($ this ->stack === []) {
574+ throw new \LogicException ('defer() outside a scope ' );
575+ }
576+
577+ $ this ->stack [array_key_last ($ this ->stack )][] = $ callback ;
578+
579+ return $ this ;
580+ }
581+
582+ public function runInDeferScope (callable $ callback ): mixed
583+ {
584+ $ this ->scopes ++;
585+ $ this ->stack [] = [];
586+
587+ try {
588+ return $ callback ();
589+ } finally {
590+ foreach (array_reverse (array_pop ($ this ->stack )) as $ cleanup ) {
591+ $ cleanup ();
592+ }
593+ }
594+ }
595+ };
596+
597+ Container::setInstance ($ container );
598+ $ container ->singleton (QueueManager::class, fn () => $ this ->manager );
599+ $ container ->alias (QueueManager::class, 'queue.worker ' );
600+
601+ return $ container ;
602+ }
603+
604+ public function testEachJobRunsInItsOwnDeferScope (): void
605+ {
606+ $ container = $ this ->installDeferAwareContainer ();
607+
608+ RecordingJob::$ during = function (RecordingJob $ job ) use ($ container ) {
609+ $ label = $ job ->label ;
610+ $ container ->defer (function () use ($ container , $ label ) { $ container ->log [] = "cleanup- {$ label }" ; });
611+ $ container ->log [] = "run- {$ label }" ;
612+ };
613+
614+ $ this ->manager ->push (new RecordingJob ('a ' ));
615+ $ this ->manager ->push (new RecordingJob ('b ' ));
616+
617+ $ this ->runOne ();
618+ $ this ->runOne ();
619+
620+ $ this ->assertSame (2 , $ container ->scopes );
621+ $ this ->assertSame (['run-a ' , 'cleanup-a ' , 'run-b ' , 'cleanup-b ' ], $ container ->log );
622+ }
623+
624+ public function testDeferredCallbacksRunWhenAJobFailsBeforeItIsReleased (): void
625+ {
626+ $ container = $ this ->installDeferAwareContainer ();
627+
628+ RecordingJob::$ during = function () use ($ container ) {
629+ $ container ->defer (function () use ($ container ) { $ container ->log [] = 'cleanup ' ; });
630+
631+ throw new \RuntimeException ('job failed ' );
632+ };
633+
634+ $ this ->manager ->push (new RecordingJob ('a ' ));
635+ $ this ->runOne ();
636+
637+ $ this ->assertSame (['cleanup ' ], $ container ->log );
638+ $ this ->assertSame (1 , $ this ->driver ()->stats ('default ' )['delayed ' ] + $ this ->driver ()->countFailed ());
639+ }
552640}
0 commit comments