Skip to content
2 changes: 1 addition & 1 deletion src/Illuminate/Bus/UniqueLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions src/Illuminate/Foundation/Bus/PendingDispatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
}
}
55 changes: 55 additions & 0 deletions src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Illuminate\Foundation\Queue;

use Illuminate\Bus\UniqueLock;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Support\Facades\Context;

trait InteractsWithUniqueJobs
{
/**
* Store unique job information in the context in case we can't resolve the job on the queue side.
*
* @param mixed $job
* @return void
*/
public function addUniqueJobInformationToContext($job): void
{
if ($job instanceof ShouldBeUnique) {
Context::addHidden([
'laravel_unique_job_cache_store' => $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');
}
}
33 changes: 33 additions & 0 deletions src/Illuminate/Queue/CallQueuedHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
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;
use Illuminate\Contracts\Queue\Job;
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;
Expand Down Expand Up @@ -227,13 +229,44 @@ protected function handleModelNotFound(Job $job, $e)
$shouldDelete = false;
}

$this->ensureUniqueJobLockIsReleasedViaContext();

if ($shouldDelete) {
return $job->delete();
}

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.
*
Expand Down
37 changes: 37 additions & 0 deletions tests/Integration/Queue/UniqueJobTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')]
Expand Down Expand Up @@ -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)).':';
Expand Down Expand Up @@ -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)
{
}
}