A loyalty balance goes wrong. Two point-adjustment jobs for the same customer landed on the queue seconds apart, two workers picked them up at the same moment, both read a balance of 500, and both wrote their own result. One adjustment silently disappeared.
Nothing failed. No exception, no retry, no row in failed_jobs. The queue did exactly what you asked: it ran two jobs in parallel.
Lock on the record, not on the job
Laravel ships middleware for this, and the important part is that it takes a key:
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [new WithoutOverlapping($this->customerId)];
}
Jobs for customer 42 now run one at a time. Jobs for customer 43 are unaffected and keep running in parallel, which is the whole point: you are serialising per record, not throttling the queue.
Under the hood it takes a cache lock named after the job class and your key, runs the job, and releases the lock in a finally. If it cannot get the lock, the job is released back to the queue to try again shortly.
The default that deadlocks you
This is the setting to change on day one. The lock's expiry defaults to zero, which means it never expires.
That is fine while jobs finish normally, because the finally releases the lock. It is not fine when a worker is killed part-way through: a timeout, an out-of-memory kill, or a deploy that does not wait. A killed process never runs finally, the lock is never released, and because it has no expiry every future job for that customer is blocked forever.
Always give it a ceiling:
return [
(new WithoutOverlapping($this->customerId))
->expireAfter(180),
];
Set it comfortably above your slowest run. The lock is a safety net, and a safety net with no timeout is a trap.
The default that busy-loops
releaseAfter defaults to 0, so a job that cannot get the lock is released immediately and comes straight back. On a queue with several jobs for the same customer, that is a tight loop of pop-and-release that burns worker cycles and, worse, burns attempts.
Give it a pause:
(new WithoutOverlapping($this->customerId))
->releaseAfter(10)
->expireAfter(180)
dontRelease() throws the work away
Worth being very clear about, because the name does not suggest it. If you call dontRelease() and the lock is unavailable, the job is not retried and not failed. It simply ends, having done nothing:
(new WithoutOverlapping($this->customerId))->dontRelease()
That is correct for genuinely disposable work, such as a cache warm that another job is already doing. It is quietly destructive for anything else. If the work matters, let it release.
Locking across different job classes
By default the lock name includes the job class, so AdjustPoints and RecalculateTier for the same customer do not block each other, even with the same key. When they touch the same data, that is not what you want:
(new WithoutOverlapping($this->customerId))->shared()->expireAfter(180)
shared() drops the class name from the lock, so every job using that key competes for one lock.
It gives you exclusion, not order
Here is the limitation to be honest about, because it is the one people assume away.
This middleware guarantees that two jobs for a key never run at the same time. It does not guarantee they run in the order they were dispatched. A released job goes to the back of the queue, so under contention a later job can easily run first.
If you need strict ordering per entity, exclusion alone will not get you there. The practical options are a chain, so each job dispatches the next, or a dedicated queue per entity with a single worker consuming it. Both trade throughput for ordering, which is the trade you are making either way.
For the loyalty example, ordering does not matter as long as each adjustment reads a fresh balance, which exclusion gives you. Know which one your problem actually needs.
How it differs from the neighbours
Three similar-sounding things worth keeping straight:
| Tool | What it does |
|---|---|
WithoutOverlapping |
stops jobs for the same key running concurrently |
ShouldBeUnique |
stops a duplicate ever being queued |
withoutOverlapping() on the scheduler |
stops a scheduled task starting while the last run is going |
And none of them replace making the job safe to run twice. A lock is a cache entry: it can expire under you, and it is gone if the cache is flushed. Keep a database-level guard underneath anything that matters.
Wrapping up
When the bug is two workers on the same record rather than too much work overall, lock on the record with WithoutOverlapping and a per-entity key. Always set expireAfter(), because the default of no expiry deadlocks that key when a worker is killed. Give releaseAfter() a few seconds so contention does not busy-loop, use shared() when several job classes touch the same data, avoid dontRelease() unless losing the work is genuinely fine, and remember it buys exclusion, not ordering.
All comments ()
No comments yet
Be the first to leave a comment on this post.