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:clearor 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 theCachefacade, 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,ThrottlesExceptionsorWithoutOverlappingdon't appear under Rate limits and throttles, and neither doSkipor 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.