diff --git a/src/Illuminate/Bus/UniqueLock.php b/src/Illuminate/Bus/UniqueLock.php index dea12303b719..9a2726e9d8a5 100644 --- a/src/Illuminate/Bus/UniqueLock.php +++ b/src/Illuminate/Bus/UniqueLock.php @@ -64,7 +64,7 @@ public function release($job) * @param mixed $job * @return string */ - protected function getKey($job) + public static function getKey($job) { $uniqueId = method_exists($job, 'uniqueId') ? $job->uniqueId() diff --git a/src/Illuminate/Foundation/Bus/PendingDispatch.php b/src/Illuminate/Foundation/Bus/PendingDispatch.php index 361454214d95..4addfe3e0ae3 100644 --- a/src/Illuminate/Foundation/Bus/PendingDispatch.php +++ b/src/Illuminate/Foundation/Bus/PendingDispatch.php @@ -7,9 +7,12 @@ use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Foundation\Queue\InteractsWithUniqueJobs; class PendingDispatch { + use InteractsWithUniqueJobs; + /** * The job. * @@ -207,12 +210,18 @@ public function __call($method, $parameters) */ public function __destruct() { + $this->addUniqueJobInformationToContext($this->job); + if (! $this->shouldDispatch()) { + $this->removeUniqueJobInformationFromContext($this->job); + return; } elseif ($this->afterResponse) { app(Dispatcher::class)->dispatchAfterResponse($this->job); } else { app(Dispatcher::class)->dispatch($this->job); } + + $this->removeUniqueJobInformationFromContext($this->job); } } diff --git a/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php b/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php new file mode 100644 index 000000000000..064c3f094233 --- /dev/null +++ b/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php @@ -0,0 +1,55 @@ + $this->getUniqueJobCacheStore($job), + 'laravel_unique_job_key' => UniqueLock::getKey($job), + ]); + } + } + + /** + * Remove the unique job information from the context. + * + * @param mixed $job + * @return void + */ + public function removeUniqueJobInformationFromContext($job): void + { + if ($job instanceof ShouldBeUnique) { + Context::forgetHidden([ + 'laravel_unique_job_cache_store', + 'laravel_unique_job_key', + ]); + } + } + + /** + * Determine the cache store used by the unique job to acquire locks. + * + * @param mixed $job + * @return string|null + */ + protected function getUniqueJobCacheStore($job): ?string + { + return method_exists($job, 'uniqueVia') + ? $job->uniqueVia()->getName() + : config('cache.default'); + } +} diff --git a/src/Illuminate/Queue/CallQueuedHandler.php b/src/Illuminate/Queue/CallQueuedHandler.php index 4f2e9e9ce9ef..4bd5aa30feb2 100644 --- a/src/Illuminate/Queue/CallQueuedHandler.php +++ b/src/Illuminate/Queue/CallQueuedHandler.php @@ -6,6 +6,7 @@ use Illuminate\Bus\Batchable; use Illuminate\Bus\UniqueLock; use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Cache\Factory as CacheFactory; use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Encryption\Encrypter; @@ -13,6 +14,7 @@ use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Log\Context\Repository as ContextRepository; use Illuminate\Pipeline\Pipeline; use Illuminate\Queue\Attributes\DeleteWhenMissingModels; use ReflectionClass; @@ -227,6 +229,8 @@ protected function handleModelNotFound(Job $job, $e) $shouldDelete = false; } + $this->ensureUniqueJobLockIsReleasedViaContext(); + if ($shouldDelete) { return $job->delete(); } @@ -234,6 +238,35 @@ protected function handleModelNotFound(Job $job, $e) return $job->fail($e); } + /** + * Ensure the lock for a unique job is released via context. + * + * This is required when we can't unserialize the job due to missing models. + * + * @return void + */ + protected function ensureUniqueJobLockIsReleasedViaContext() + { + if (! $this->container->bound(ContextRepository::class) || + ! $this->container->bound(CacheFactory::class)) { + return; + } + + $context = $this->container->make(ContextRepository::class); + + [$store, $key] = [ + $context->getHidden('laravel_unique_job_cache_store'), + $context->getHidden('laravel_unique_job_key'), + ]; + + if ($store && $key) { + $this->container->make(CacheFactory::class) + ->store($store) + ->lock($key) + ->forceRelease(); + } + } + /** * Call the failed method on the job instance. * diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 36eb9aeb5cb7..82e567282a6f 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -8,10 +8,14 @@ use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Foundation\Auth\User; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Bus; use Orchestra\Testbench\Attributes\WithMigration; +use Orchestra\Testbench\Factories\UserFactory; #[WithMigration] #[WithMigration('cache')] @@ -130,6 +134,28 @@ public function testLockCanBeReleasedBeforeProcessing() $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } + public function testLockIsReleasedOnModelNotFoundException() + { + UniqueTestSerializesModelsJob::$handled = false; + + /** @var \Illuminate\Foundation\Auth\User */ + $user = UserFactory::new()->create(); + $job = new UniqueTestSerializesModelsJob($user); + + $this->expectException(ModelNotFoundException::class); + + try { + $user->delete(); + dispatch($job); + $this->runQueueWorkerCommand(['--once' => true]); + unserialize(serialize($job)); + } finally { + $this->assertFalse($job::$handled); + $this->assertModelMissing($user); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + } + protected function getLockKey($job) { return 'laravel_unique_job:'.(is_string($job) ? $job : get_class($job)).':'; @@ -185,3 +211,14 @@ class UniqueUntilStartTestJob extends UniqueTestJob implements ShouldBeUniqueUnt { public $tries = 2; } + +class UniqueTestSerializesModelsJob extends UniqueTestJob +{ + use SerializesModels; + + public $deleteWhenMissingModels = true; + + public function __construct(public User $user) + { + } +}