Skyline

Unique job locks

Why deleting a ShouldBeUnique job used to wedge its lock, and how Skyline clears it.

Deleting a ShouldBeUnique job straight out of a Redis queue leaves its cache lock held, and because uniqueFor defaults to no expiry, every future dispatch of that job is then silently discarded — permanently. Skyline releases the lock whenever a job leaves the queue out-of-band, so the job keeps dispatching.

This page documents a fix rather than a feature. If you use ShouldBeUnique jobs and you delete jobs from the dashboard or run horizon:clear, it explains a failure mode you may have hit without ever diagnosing it.

The problem#

When you dispatch a job implementing ShouldBeUnique, Laravel acquires a cache lock keyed on the job's unique id. Any further dispatch that finds the lock held is silently dropped — that is the point. The lock is released when the job finishes, when it fails terminally, or (for ShouldBeUniqueUntilProcessing) when it starts.

Deleting the job's payload straight out of Redis bypasses every one of those release paths. The job is gone; its lock is not. And because uniqueFor defaults to 0 — no expiry — the lock does not time out.

The job vanishes, the lock remains, and every future dispatch of that job is silently discarded. Forever.

Silently is the operative word. Nothing throws, nothing is logged by the framework, and the dashboard shows an empty queue. The symptom is a job that "just stopped running" days after somebody cleaned up a queue.

What Skyline does#

Skyline releases the unique lock whenever a job leaves the queue out-of-band. That covers all three paths:

  • Deleting a single job from the dashboard.
  • Emptying a queue from the dashboard.
  • Running php artisan horizon:clear.

In each case Skyline reads the payloads before removing them, reconstructs each queued command, checks whether it implements ShouldBeUnique, and releases its lock through the framework's own unique-lock machinery. A job that overrides uniqueVia() to lock on a different cache store is honoured, because the release consults uniqueVia() per job exactly as the framework does. Emptying a large queue sweeps both the ready list and the delayed set in batches.

No configuration is required, and there is nothing to turn on.

Limits#

  • Redis queues only. Only Redis exposes the pending and delayed payloads needed to find the locks. On other drivers this is a no-op, and the lock's TTL — if you set one — remains the only backstop.
  • Unserializable payloads are skipped. If a payload references a class that no longer exists in the codebase, the command cannot be reconstructed and its lock cannot be released. This is rare, and again the TTL is the backstop.
  • The release is ownerless. Skyline force-releases the lock rather than proving ownership. If a worker pops a duplicate in the narrow window between reading and wiping a queue, it could run concurrently with another copy. This is inherent to clearing a queue while workers are live; pause the queue first if that matters.
Set a uniqueFor anyway

Skyline closes the dashboard-shaped hole, but an unbounded lock has other ways to get stuck — a killed worker, a lost Redis failover. Giving uniqueFor a value comfortably longer than the job's worst-case runtime remains good practice.

Seeing the discards#

Related, and useful when you suspect a stuck lock: Skyline can log every dispatch that is discarded because a unique lock is held. The framework emits no event for this, so it is otherwise invisible.

[unique-job:9f2c…] dispatch skipped — a ShouldBeUnique lock is already held; the job was not queued.

Enable it by pointing log_channel at a channel that accepts warning — see Job lifecycle logging, which also documents the caveat that discard logging covers the application's default cache store only.

Common questions

Why did my ShouldBeUnique job stop dispatching?

Most likely its unique lock is still held. Deleting a job's payload straight out of Redis — from a dashboard, or with horizon:clear — bypasses every path that releases the lock, and because uniqueFor defaults to 0 (no expiry) the lock never times out. Every future dispatch of that job is then silently discarded. Nothing throws and nothing is logged, so the symptom is a job that "just stopped running" days after somebody cleaned up a queue.

How does Skyline fix the stuck unique lock?

Skyline reads the payloads before removing them, reconstructs each queued command, checks whether it implements ShouldBeUnique, and releases the lock through the framework's own unique-lock machinery. It covers all three out-of-band paths: deleting a single job, emptying a queue, and horizon:clear. No configuration is required.

Should I still set uniqueFor on my jobs?

Yes. Skyline closes the dashboard-shaped hole, but an unbounded lock has other ways to get stuck — a killed worker, a lost Redis failover. A uniqueFor comfortably longer than the job's worst-case runtime remains the backstop.

Queue control, not just queue monitoring.

Skyline is a drop-in replacement for Laravel Horizon that lets you act on what you see — pause a queue, jump a job to the front, drain a backlog. $99 once, every app you run it on.

Buy Skyline — $99 once

Secure checkout by Anystack. 30 days to change your mind.