Skyline

Queue control

Unique job locks

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

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.

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.

Sign up for early access