Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11.x] Fix unique job lock is not released on model not found exception, lock gets stuck. #54000

Open
wants to merge 11 commits into
base: 11.x
Choose a base branch
from
8 changes: 8 additions & 0 deletions src/Illuminate/Foundation/Bus/PendingDispatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
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 +209,18 @@ public function __call($method, $parameters)
*/
public function __destruct()
{
$this->rememberLockIfJobIsUnique($this->job);

if (! $this->shouldDispatch()) {
$this->forgetLockIfJobIsUnique($this->job);

return;
} elseif ($this->afterResponse) {
app(Dispatcher::class)->dispatchAfterResponse($this->job);
} else {
app(Dispatcher::class)->dispatch($this->job);
}

$this->forgetLockIfJobIsUnique($this->job);
}
}
81 changes: 81 additions & 0 deletions src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace Illuminate\Foundation\Queue;

use Illuminate\Cache\Repository;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Support\Arr;

trait InteractsWithUniqueJobs
{
/**
* Saves the used cache driver for the lock and
* the lock key for emergency forceRelease in
* case we can't instantiate a job instance.
*/
public function rememberLockIfJobIsUnique($job): void
{
if ($this->isUniqueJob($job)) {
context()->addHidden([
'laravel_unique_job_cache_driver' => $this->getCacheDriver($job),
'laravel_unique_job_key' => $this->getKey($job),
]);
}
}

/**
* forget the used lock.
*/
public function forgetLockIfJobIsUnique($job): void
{
if ($this->isUniqueJob($job)) {
context()->forgetHidden(['laravel_unique_job_cache_driver', 'laravel_unique_job_key']);
}
}

/**
* Determine if job has unique lock.
*/
private function isUniqueJob($job): bool
{
return $job instanceof ShouldBeUnique;
}

/**
* Get the used cache driver as string from the config,
* CacheManger will handle invalid drivers.
*/
private function getCacheDriver($job): ?string
{
/** @var \Illuminate\Cache\Repository */
$cache = method_exists($job, 'uniqueVia') ?
$job->uniqueVia() :
app()->make(Repository::class);

$store = collect(config('cache')['stores'])

->firstWhere(
function ($store) use ($cache) {
return $cache === rescue(fn () => cache()->driver($store['driver']));
}
);

return Arr::get($store, 'driver');
}

//NOTE: can I change visibility of the original method in src/Illuminate/Bus/UniqueLock.php ?
/**
* Generate the lock key for the given job.
*
* @param mixed $job
* @return string
*/
private function getKey($job)
{
$uniqueId = method_exists($job, 'uniqueId')
? $job->uniqueId()
: ($job->uniqueId ?? '');

return 'laravel_unique_job:'.get_class($job).':'.$uniqueId;
}
}
20 changes: 20 additions & 0 deletions src/Illuminate/Queue/CallQueuedHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,24 @@ protected function ensureUniqueJobLockIsReleased($command)
}
}

/**
* Ensure the lock for a unique job is released
* when can't unserialize the job instance.
*
* @return void
*/
protected function ensureUniqueJobLockIsReleasedWithoutInstance()
{
/** @var string */
$driver = context()->getHidden('laravel_unique_job_cache_driver');
/** @var string */
$key = context()->getHidden('laravel_unique_job_key');

if ($driver && $key) {
cache()->driver($driver)->lock($key)->forceRelease();
}
}

/**
* Handle a model not found exception.
*
Expand All @@ -227,6 +245,8 @@ protected function handleModelNotFound(Job $job, $e)
$shouldDelete = false;
}

$this->ensureUniqueJobLockIsReleasedWithoutInstance();

if ($shouldDelete) {
return $job->delete();
}
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)
{
}
}