Skyline

The Locks & Limits screen

Held unique locks, the dispatches they skipped, and the jobs rate limiters and throttles held back.

The Locks & Limits screen, at /horizon/locks, shows the parts of a Laravel queue that hold jobs back without failing them: the ShouldBeUnique locks currently held, the dispatches those locks skipped, and the jobs that rate-limiting and overlap middleware released or dropped. None of these appear anywhere in Horizon, because a skipped dispatch never reaches the queue and a middleware release looks like any other release.

It is on by default and needs no setup for unique locks. Rate limiters and throttles report once you import Skyline's drop-in middleware.

The summary row#

Figure What it counts
Unique Locks Held Locks in the index, with the number flagged beside it.
Dispatches Skipped (24h) Dispatches discarded because the job's unique lock was held.
Jobs Held Back (24h) Jobs a rate limiter, throttle or overlap lock released or dropped without running.
Throttled Exceptions (24h) Exceptions caught by ThrottlesExceptions.

Unique locks#

Each row is a unique lock a dispatch took on the default cache store: the job and its uniqueId, how long the lock has been held, when it expires (Never for a lock with no uniqueFor), a link to the job it was taken for, and how many dispatches it has skipped. Flagged locks sort first, and the Only flagged switch hides the rest.

Flag Meaning
Stranded The job has completed or failed and the lock is still held. For a ShouldBeUniqueUntilProcessing job, the job has already started. Also set when a job refused on pickup left its lock behind.
Job gone The job that took the lock is no longer stored by Horizon, and the lock is still held.
No job No queued job was seen for the lock after 10 minutes. It may have been dispatched to a connection Horizon doesn't manage, rolled back with its transaction, or taken before lock tracking started.
Taken elsewhere The lock is held, but not by the owner Horizon recorded, so something outside Horizon's view took it.

A lock that could not be checked against its cache store is marked Unverified. While a lock is stranded, every dispatch of that job is skipped until the lock expires, or indefinitely if it has no TTL.

Releasing a lock#

The Release button asks for confirmation, then frees the lock so the next dispatch of the job is queued. The release is owner-checked: it only frees the lock if it is still held by the owner the page showed. If another dispatch has taken the lock since the page loaded, the request fails with 409 and the lock is left alone. A lock that was already free is reported as released already.

Releasing a lock while a copy of the job is still waiting lets a second copy be queued. For a ShouldBeUniqueUntilProcessing job, the waiting copy releases the lock again when it starts, even one a newer dispatch has taken. The confirmation says so for those jobs. Each release is logged as unique_lock.released_from_dashboard.

Skipped dispatches#

A dispatch discarded over a held lock is a silent no-op in Laravel. This table counts them per job for the past hour and the past day, with the last uniqueId and when it was last skipped. Show recent lists the 50 most recent discards with their timestamps.

A job with a steady skip count is usually working as intended. A job whose count climbs while nothing of it runs usually has a stranded lock, and its row in the table above will say so.

Rate limits and throttles#

One row per rate limiter, per job class using ThrottlesExceptions, and per job class using WithoutOverlapping. Each row shows past-hour and past-day counts of jobs released without running, jobs dropped without running, and exceptions the throttle caught. It also shows the configuration (the limit and its window, and any releaseAfter() or expireAfter()), the last exception a throttle caught, and the live count on the last limiter key a job was stopped by.

Counts are grouped by limiter name or job class, never by the per-user or per-tenant key a limiter was hit on, so a limiter keyed on user id is still one row.

Drop-in middleware#

Laravel's RateLimited and ThrottlesExceptions release a job with a plain release(), which Horizon cannot tell apart from a job releasing itself. ThrottlesExceptions also catches the job's exception, so no exception event fires and the error is lost. Skyline ships subclasses that record what happened. Swap the import and nothing else changes:

use Laravel\Horizon\Middleware\RateLimited;
// was: use Illuminate\Queue\Middleware\RateLimited;

public function middleware(): array
{
    return [new RateLimited('backups')];
}
Import from Laravel\Horizon\Middleware What it adds
RateLimited, RateLimitedWithRedis Releases are attributed as rate_limited and name the limiter and the limit that was hit. A dontRelease() drop is logged as job.rate_limited_dropped instead of passing for a completed job.
ThrottlesExceptions, ThrottlesExceptionsWithRedis A release while the throttle is open is exceptions_throttled. A release after the job threw is throttled_exception, logged at warning with the exception's class, message and location, and shown under Previous Attempts. A job deleted through deleteWhen() is logged as job.throttled_exception_deleted.
WithoutOverlapping Releases are attributed as without_overlapping and counted here. A dontRelease() drop is logged as job.overlap_dropped.

Each subclass calls the framework's own handle() and reads the outcome, rather than copying its logic, so framework changes to how these middleware decide still apply. The framework's versions keep working unchanged; they don't report to this screen, and their releases keep the generic released reason. The release reasons and log lines are listed on Job lifecycle logging.

Storage and cost#

Everything is stored on Horizon's own Redis connection. Each unique dispatch and each lock release costs one write.

  • The lock index is written when a lock is acquired and cleared when it is released. It is checked against the cache store whenever the screen loads, and in batches of 100 a minute by the master supervisor, so locks removed behind Horizon's back by cache:clear or eviction drop out. It holds at most 1,000 locks, dropping the soonest to expire first.
  • Counts go into 10-minute buckets that expire after 25 hours. Only the 50 latest skipped dispatches are kept individually.
  • Writes never break a dispatch. If a write to Redis fails, Skyline stops writing for 30 seconds and the job carries on as normal.
// config/horizon.php
'lock_insights' => env('HORIZON_LOCK_INSIGHTS', true),

Set HORIZON_LOCK_INSIGHTS=false to store nothing. The screen then shows a banner, while the log lines and release reasons keep working.

Limits#

  • The default cache store only. A job whose uniqueVia() keeps its lock on another store, a lock taken through the Cache facade, and a custom cache repository subclass are not tracked. The same limit applies to discard logging.
  • Only locks a dispatch takes are linked to a job. A lock taken outside the dispatch path shows as No job after 10 minutes, or Taken elsewhere if it replaced one Horizon recorded.
  • Only Skyline's middleware report. Jobs using the framework's RateLimited, ThrottlesExceptions or WithoutOverlapping don't appear under Rate limits and throttles, and neither do Skip or your own middleware.

For why a unique lock ends up stranded in the first place, and what Skyline releases automatically, see Unique job locks. The API behind this screen is on the HTTP API page.

Common questions

How can I see which Laravel unique job locks are held?

Horizon doesn't show cache locks. Skyline's Locks & Limits screen, at /horizon/locks, lists the unique locks dispatches took on the default cache store, with each lock's job, how long it has been held, its expiry and how many dispatches it skipped. Locks are flagged Stranded, Job gone, No job or Taken elsewhere when something is wrong.

Why was my job released by RateLimited or ThrottlesExceptions without running?

Its limiter was exhausted, or the throttle was open after too many exceptions. The framework releases the job with a plain release(), so Horizon shows a generic release. Import Skyline's drop-in RateLimited, RateLimitedWithRedis, ThrottlesExceptions or ThrottlesExceptionsWithRedis from Laravel\Horizon\Middleware and each release carries a reason (rate_limited, exceptions_throttled or throttled_exception), is counted per limiter on Locks & Limits, and a caught exception is shown under Previous Attempts.

Does lock tracking slow down dispatching?

Each unique dispatch and lock release costs one write to Horizon's Redis connection. A failed write never breaks the dispatch: Skyline pauses tracking writes for 30 seconds and carries on. Set HORIZON_LOCK_INSIGHTS=false to store nothing, while keeping the log lines and release reasons.

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.