artisanplaybook .dev
ARTICLES / QUEUES

ShouldBeUnique and the lock that outlives the job

ShouldBeUnique is a cache lock with a default lifetime you probably did not choose. When a worker dies holding one, dispatches stop silently and cache:clear does not necessarily get it back.

ON THIS PAGE

Warehouse 7 has not synced since Tuesday

Every other warehouse is current. failed_jobs is empty. queue:failed prints nothing. Horizon shows healthy throughput. The scheduler fires SyncInventory::dispatch(7) every ten minutes and has done so 600 times since Tuesday, and not one of those dispatches reached the queue.

The job looks like this, and the job is not the problem:

class SyncInventory implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public int $warehouseId) {}

    public function uniqueId(): string
    {
        return (string) $this->warehouseId;
    }
}

ShouldBeUnique is doing exactly what it says. That is the problem. Somewhere in your cache there is a key holding warehouse 7's slot, the process that was meant to release it died on Tuesday, and the key has no expiry.

This is not the timeout-versus-retry_after mismatch that causes a job to run twice — that failure mode is about one dispatched instance being picked up twice, and there is a separate piece on this site about it. It is not silent failure into failed_jobs either. Nothing failed. The dispatch was discarded, on purpose, by the uniqueness check.

The lock is a cache key, and you can go and look at it

When a ShouldBeUnique job is dispatched, Illuminate\Bus\UniqueLock tries to take an atomic lock before the job is pushed. The key is built by hand:

return 'laravel_unique_job:'.$jobName.':'.$uniqueId;

For an ordinary job class that is the fully qualified class name and your uniqueId(), and then the cache store's prefix goes on the front. On this application, using the database cache store, that comes out as:

artisanplaybook-cache-laravel_unique_job:App\Jobs\SyncInventory:7

Which means the diagnosis is a query, not a guess:

php artisan tinker --execute 'print_r(DB::table("cache_locks")->where("key", "like", "%laravel_unique_job%")->get()->all());'

On Redis, the same thing with KEYS '*laravel_unique_job*' — or SCAN, on a busy production instance. If a row or key comes back for warehouse 7, that is your answer, and the expiration column is about to tell you the rest of the story.

Two things about the key are worth noting before you rely on it. It contains uniqueId(), so if uniqueId() returns something that varies between dispatches — a timestamp, Str::uuid(), a model's updated_at — the job is not unique at all and never was. And uniqueness needs a cache driver that supports atomic locks: memcached, redis, dynamodb, database, file and array. Ship ShouldBeUnique with a cache driver that cannot lock and you get no uniqueness and no error.

The default lifetime is zero, and zero does not mean "briefly"

Nobody sets uniqueFor on the first version of a job. Here is what the framework uses when you do not:

$uniqueFor = method_exists($job, 'uniqueFor')
    ? $job->uniqueFor()
    : ($this->getAttributeValue($job, UniqueFor::class, 'uniqueFor') ?? 0);

$lock = $cache->lock(self::getKey($job), $uniqueFor);

Zero seconds. And zero is not a short expiry — it is no expiry, interpreted differently by each lock driver:

  • Redis — RedisLock::acquire() branches on $this->seconds > 0. Above zero it writes the key with EX. At zero it calls setnx, with no TTL at all. The key survives until something deletes it.
  • Database — DatabaseLock::expiresAt() falls back to $defaultTimeoutInSeconds, which is 86400. Acquiring a default unique lock on this application's database store and reading the row back gives an expiration exactly 24.0 hours out.
  • Array — the in-memory store used in tests stores expiresAt as null when seconds is zero. Travel thirty days forward in a test and the lock is still held.

So the blast radius of a killed worker depends on a config value you probably chose for other reasons. On Redis, warehouse 7 is stuck until a human intervenes. On the database driver, it un-sticks itself after a day, which is worse in one specific way: it looks intermittent, and it will be blamed on the upstream API.

Who releases it, and the paths where nobody does

CallQueuedHandler releases the lock in two places. After the job is handled:

if (! $job->isReleased() && ! $this->commandShouldBeUniqueUntilProcessing($command)) {
    $this->ensureUniqueJobLockIsReleased($command);
}

And in failed(), when the job has exhausted its attempts. Both of those are PHP running inside the worker process. Neither runs if the process does not reach them:

  • The kernel OOM-killer takes the worker. No shutdown, no release.
  • SIGKILL from an impatient deploy script or a container runtime that ran out of patience during a graceful stop.
  • The worker's --timeout fires. The SIGALRM handler marks the job failed only when that attempt would exceed $tries, $maxExceptions or the job opts into FailOnTimeout — otherwise it kills the process with the lock still held, and it stays held until some later attempt finishes or fails. On a host without pcntl, supportsAsyncSignals() is false and no timeout handler is registered at all.
  • The machine goes away. Spot instance reclaimed, node drained, power event.
  • The job's payload can no longer be deserialized after a deploy that renamed a class or a property.

That last one has a rescue path in the current release: ensureUniqueJobLockIsReleasedViaContext() reads the store, key and owner out of hidden log context and releases the lock without needing to unserialize the command. It covers the deleted-model and incomplete-class cases. It does not cover a process that stopped existing.

Note what this means for the shape of your job. A long job holding a no-expiry unique lock is a single point of failure for that key: the longer handle() runs, the wider the window in which the process can die holding it. If what you actually want is "never process two of these at once" rather than "never queue two of these at once", WithoutOverlapping is the middleware for that job, and it takes an explicit expiry.

Choosing uniqueFor, and the trade-off you cannot avoid

Set it. The current release lets you do it with an attribute, which keeps it visible at the top of the class:

use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(900)]
class SyncInventory implements ShouldQueue, ShouldBeUnique
{
    // ...
}

A uniqueFor() method and a public $uniqueFor property both still work — ReadsClassAttributes falls back to the property, and a property set to something other than its declared default takes precedence over the attribute. A uniqueFor() method beats both.

Now the real decision, which no default can make for you. The value has to be longer than the worst-case total life of one dispatch — queue wait plus runtime plus every retry and every backoff delay. Set it shorter and the lock expires while the first job is still working, a second dispatch sails through, and you have two of them running: the exact thing you added the interface to prevent.

Set it longer and a dead worker blocks that key for that long.

There is no value that is safe in both directions, which is why the useful framing is: pick the shortest window that comfortably covers a healthy dispatch, and then make sure a stuck lock is visible. Fifteen minutes on a job the scheduler fires every ten is defensible. Twenty-four hours on the same job is a day of silence.

Two things narrow the window further. ShouldBeUniqueUntilProcessing releases the lock as processing begins instead of when it ends, so the lock only has to cover the queue wait — a good fit for "collapse a burst of dispatches into one run", and a bad fit for "never run two at once". And if you would rather the lock lived somewhere other than your default cache store, uniqueVia() returns the repository to use, which is how you keep unique locks out of a store that something else flushes.

Unsticking one, and the command that does not do it

Here is the part that costs people an afternoon. On the database cache store, php artisan cache:clear does not release unique job locks. DatabaseStore::flush() deletes from the cache table; locks live in cache_locks. Run it against a stuck lock and the next dispatch is still discarded — verified against this application:

after cache:clear            -> lock rows 1, next dispatch acquires? false
after cache:clear --locks    -> lock rows 0, next dispatch acquires? true

--locks is the flag you want, and it only works when the store has a separate lock store — a different lock_table for the database driver, a different lock_connection for Redis. Without one, flushLocks() throws and the command reports that the store does not support clearing locks. On Redis with the default single connection, cache:clear flushes the whole database and does take the lock with it, along with everything else you had cached.

Targeted is better than either:

Cache::lock('laravel_unique_job:App\Jobs\SyncInventory:7')->forceRelease();

forceRelease() ignores ownership, which is the point — the owner token died with the worker.

What to change

Grep for ShouldBeUnique and for ShouldBeUniqueUntilProcessing. For every class that comes back, check three things: that it declares a uniqueFor window, that the window covers retries and backoff rather than just the happy path, and that uniqueId() returns something stable across dispatches.

Then add the alarm, because this failure mode produces no failures to alert on. The signal is a dispatch that should have happened and did not, so measure the thing the job is supposed to keep fresh — max(synced_at) per warehouse, and page when it goes stale. A scheduled check for lock rows older than your longest uniqueFor gets you the same answer from the other direction:

php artisan tinker --execute 'echo DB::table("cache_locks")->where("key", "like", "%laravel_unique_job%")->where("expiration", ">", now()->addHours(2)->getTimestamp())->count();'

Finally, if your deploy sends SIGKILL to workers after a fixed grace period, that number is also the setting that decides how often you will read this article again. Give queue:restart long enough to let jobs finish, and let the process manager bring the workers back.

Related reading

ARCHIVE →