Unique job locks: why ShouldBeUnique stops working
Why a ShouldBeUnique lock gets stuck or skipped, and each case Skyline releases, takes or flags.
A ShouldBeUnique job that stops dispatching almost always has a lock still held under its key. Laravel
discards every later dispatch while that lock exists. It throws nothing, records no failed job and shows nothing in
Horizon, and with the default uniqueFor of 0 the lock has no expiry to end it. Skyline
closes the ways a lock gets left behind and takes the lock on the push paths that skip it. Whatever is still held
shows on the Locks & Limits screen.
This page covers what Skyline changes. For how the framework's unique locks behave on their own, and how they
interact with WithoutOverlapping, read
ShouldBeUnique vs ShouldBeUniqueUntilProcessing
and WithoutOverlapping.
Where a unique lock goes wrong#
| What happens | Laravel on its own | With Skyline |
|---|---|---|
A unique job is deleted from Redis, a queue is emptied, or horizon:clear runs |
The job is gone and its lock stays held | Lock released with each job removed |
A ShouldBeUniqueUntilProcessing job is failed on pickup, before handle() |
The failure path skips the release, so the lock stays held | Lock released if this job still owns it, and logged either way |
A unique job is queued by a chain, a batch, Queue::push(), Queue::later() or a retry |
No lock is taken, so a later dispatch() queues a second copy |
Unchanged — but the held lock and its skip count are visible, so duplicates are findable |
| A dispatch is skipped because the lock is held | Nothing is logged or stored | Logged, and counted per job on the Locks & Limits screen |
| A lock is held with no job left to release it | Only visible with redis-cli |
Flagged, with an owner-checked Release button |
Deleting a job or emptying a queue#
When you dispatch a ShouldBeUnique job, Laravel takes a cache lock keyed on the job class and its
uniqueId(). The lock is released when the job finishes, when it fails for the last time, or, for
ShouldBeUniqueUntilProcessing, when it starts. Deleting the payload straight out of Redis bypasses all of
those paths. The job is gone, the lock is not, and every future dispatch of that job is discarded.
Skyline releases the lock on each path that removes a job from the queue out-of-band:
- Deleting a single job from the dashboard.
- Emptying a queue from the dashboard.
- Running
php artisan horizon:clear.
It reads the payloads before removing them, rebuilds each queued command, and releases the lock of every one that
implements ShouldBeUnique through the framework's own UniqueLock::release(). A job whose
uniqueVia() points at another cache store is released on that store. Emptying a large queue sweeps the
ready list and the delayed set in batches. There is nothing to configure.
A ShouldBeUniqueUntilProcessing lock left by a failed pickup#
Laravel releases a ShouldBeUniqueUntilProcessing lock just before handle() runs, and its
failure path deliberately skips the release on the assumption that it already happened. A job the worker refuses on
pickup never got that far. The two common cases:
-
Its
retryUntil()deadline passed while it waited.retryUntil()is fixed at dispatch, so a delayed job or a backlog can carry a job past it before any worker reaches it. -
Its tries ran out on releases that never reached
handle(), such as aWithoutOverlappingorRateLimitedmiddleware releasing it each time.
The job fails, the lock stays, and with uniqueFor at 0 no later dispatch of that job is
ever queued. This is still how the framework behaves on Laravel 13.
Skyline records which lock owner token each job's dispatch took, inside the job's payload. When such a job fails on
pickup, Skyline releases the lock with an atomic compare-and-delete against that owner. A lock that has since
expired, been released by an earlier attempt, or been taken by a newer dispatch belongs to someone else and is left
alone. The release is logged as unique_lock.released_after_failure.
// config/horizon.php
'release_stranded_unique_locks' => env('HORIZON_RELEASE_STRANDED_UNIQUE_LOCKS', true),
Set it to false to keep the framework's behaviour. A lock this job still holds is then logged as
unique_lock.stranded and not released.
Skyline also fixes the failure message. The worker reports a job picked up after its retryUntil() as
MaxAttemptsExceededException: … has been attempted too many times, which reads as if the job ran and
kept failing. On Horizon's failure path, the one the dashboard and the job.failed log line read,
Skyline substitutes Laravel\Horizon\Exceptions\RetryWindowExpired:
App\Jobs\SyncCustomer never ran: its retryUntil() allowed 60s from dispatch, but a worker only picked it up 600s after dispatch.
It extends MaxAttemptsExceededException, so existing instanceof checks still match, and
Laravel's own failed_jobs record keeps the original message. When the delay alone puts a job past its
deadline, Skyline warns at dispatch time too, as job.retry_until_before_available.
If a job refused on pickup can't be unserialized, usually because a SerializesModels model was
deleted while it waited, the command can't be rebuilt, and Skyline can't work out which store the lock lives in.
It logs unique_lock.stranded with the exception message instead. A lock on the default cache store
still appears on the Locks & Limits screen, where you can release it.
Chains, batches, Queue::push and retries#
Laravel takes a unique lock on the dispatch() path only. These paths queue a ShouldBeUnique
job without taking one:
- the first job of a chain (
Bus::chain()); - jobs added to a batch (
Bus::batch(), pushed through the queue'sbulk()); - direct
Queue::push()andQueue::later()calls; - retries, from
php artisan queue:retryor the dashboard's Retry button.
A job queued that way doesn't stop a later dispatch(), so a second copy lands on the queue beside it.
This is the most reported uniqueness problem on the framework's tracker, and it is unchanged through Laravel 13.
Skyline does not change this. A lock taken on the dispatch() path is still the only lock there is, and a
chain head, a batched job or a direct push queues alongside it. What Skyline does is make the consequences visible:
every lock a dispatch holds appears on the
Locks & Limits screen with the dispatches it has
skipped, so a job running in duplicate shows up as a lock whose skip count is not moving while copies keep running.
If duplicates through these paths are a real problem, take your own lock around the push rather than relying on
ShouldBeUnique: Cache::lock($key, $seconds)->get() before queueing, released when the
job finishes. Deduplicating the collection before Bus::batch() handles the batch case without a lock
at all.
There is a second gap behind the first. When a copy queued without the lock starts processing, Laravel force-releases the lock under its key even though that lock belongs to another job — so a duplicate can free the original's lock on its way past. That is framework behaviour and Skyline cannot prevent it from outside; the flagged Taken elsewhere and No job states on the Locks & Limits screen are how it surfaces.
Seeing held locks and skipped dispatches#
The Locks & Limits screen lists the unique locks dispatches have taken on the default cache store, each with its job, how long it has been held, when it expires, and how many dispatches it has skipped. A lock whose job has completed or failed, or whose job is gone, is flagged, and a Release button frees it after a confirmation. Below it, the Skipped Dispatches table counts discarded dispatches per job for the past hour and the past day.
The same discards reach your logs once log_channel points at a channel that accepts
warning:
[unique-job:9f2c…] dispatch skipped — a ShouldBeUnique lock is already held; the job was not queued.
Laravel 13.25 added a UniqueJobSkipped event for a skipped dispatch, but it still logs nothing and
records nothing, and earlier versions fire no event at all. Since 1.5 Skyline listens for that event where it exists,
which also covers a lock kept on another store through uniqueVia(), and falls back to its own cache
wrapper on older versions and for the paths the event misses (unique queued listeners and scheduled unique jobs). The full event list is on Job lifecycle
logging.
Limits#
- Redis queues only. Releasing locks on removal needs the pending and delayed payloads, which only Redis exposes. On other drivers it does nothing, and the lock's TTL, if you set one, is the only backstop.
- Unserializable payloads are skipped. A payload naming a class that no longer exists, or a model that was deleted, can't be rebuilt, so its lock can't be released automatically. It is logged, and you can release it from the Locks & Limits screen.
- Removal releases through the framework. On Laravel 13.24 and later the framework's release checks the owner token a dispatch stored on the job. Before 13.24, and for a job queued without its own lock, it force-releases the key. A worker that pops a duplicate between the read and the wipe could then run beside another copy. Pause the queue first if that matters.
- The owner is only known for jobs pushed after upgrading. Jobs already on the queue carry no recorded owner, so a stranded lock left by one of them is logged but not released.
-
Lock tracking covers the default cache store. A job whose
uniqueVia()uses another store, or a lock taken through theCachefacade, doesn't appear on the Locks & Limits screen or in discard logging.
A lock with no TTL has ways to get stuck that no package can see, like a worker killed mid-release or a lost Redis
failover. Give uniqueFor a value longer than the job's whole lifetime, queue wait and retries
included. The rest of the ways a unique lock misbehaves, including a missing uniqueId(), are in
ShouldBeUnique gotchas under real
traffic.