R3.03.1 · dive 1
Lock-expiry detection
A lock that expired is the signal a worker died. The lease is the heartbeat of the job: renewed, a worker lives; lapsed, the worker is gone.
One question must be resolved before the recovery sweep acts: which in-flight job no longer has a
live worker? The source pattern resolves it with a timeout — a message in-flight longer than ten minutes is
assumed stalled. EchoMQ resolves it with a lease tied to the worker directly: a lock with a
PX TTL, renewed on a heartbeat. Drag the timeline below to a tick and toggle the
heartbeat off to watch the lease run to zero — when the lock is gone, the worker died.
The lease over time · a 30s lock, a heartbeat every 10s, until the worker dies
The timeout, re-cast as a lease
The source's reaper scans the processing queue and, for each message older than a timeout, treats the worker as dead and moves the message back. The timeout is a proxy. It misfires in both directions: a job that legitimately runs longer than the timeout is reclaimed while its worker is still working, and a worker that dies one second into a ten-minute timeout holds a job hostage for the rest of it.
The lease removes the proxy. When a worker takes a job, it sets a lock key with a short
PX expiry. A live worker renews that lease on a heartbeat — a periodic
SET … PX that pushes the expiry forward — well before it can lapse. The lock is
present exactly while a worker is alive and renewing. The reclaim question becomes a key lookup: for each job in
active, is its lock key still present? A present lock is a live worker; a missing lock
is a stalled job.
# the lease: set when a worker takes the job SET emq:{queue}:<id>:lock <token> PX 30000 # a 30s lease # the heartbeat: a live worker renews before the lease lapses SET emq:{queue}:<id>:lock <token> PX 30000 # pushes the expiry forward, every ~half-lease # the death signal: no renewal -> the TTL runs to 0 -> Redis evicts the key EXISTS emq:{queue}:<id>:lock # 0 means the worker died -> stalled
The lease length and the heartbeat interval are a pair. The heartbeat must run several times within one lease so a single missed tick does not lapse it, and the lease must be short enough that a dead worker is detected promptly. A lease of thirty seconds renewed every ten is the usual shape: two missed renewals still leave the lock alive, three lapse it.
- lease
- A lock key with a short
PXTTL, set when a worker takes a job — EchoMQ'semq:{queue}:<id>:lock. Present exactly while a worker is alive and renewing. - heartbeat
- A periodic
SET … PXthat pushes the lease's expiry forward, run by a live worker several times within one lease. - death signal
- The lock is gone. No heartbeat arrived, the TTL ran to zero, Redis evicted the key —
EXISTSreturns 0, so the job is stalled.
The timeout is a proxy for a dead worker; the lease is the worker's own heartbeat. A lock with a
PX TTL is present while a worker renews it, so a missing lock is exactly a stalled job.
Paused is not dead
The lease distinguishes a worker that paused from a worker that died — a distinction a timeout cannot make. A paused worker that is still running its event loop keeps renewing the lease, so its lock stays present and the sweep leaves its job alone, even if the job has run for an hour. A dead worker stops renewing, its lock lapses within one lease, and the sweep reclaims its job. The test is the lock, not the clock: a long-running job under a live heartbeat is safe; a short job under a dead worker is reclaimed.
This is why the lease is more precise than the timeout it replaces. The timeout's test is how long has this job been running?, and it reclaims on a number. The lease's test is is a worker still renewing this lock?, and it reclaims on the worker. A job that runs longer than any fixed timeout but holds a live heartbeat is exactly the case the timeout gets wrong and the lease gets right. Toggle the two columns below to compare a slow-but-alive worker against a fast-but-dead one under both rules.
Slow-but-alive vs fast-but-dead · the timeout vs the lease
A paused worker renews its lock and is left alone; a dead worker's lock lapses and its job is reclaimed. The lease reclaims on the worker, where a timeout reclaims on the clock and gets both edge cases wrong.
In EchoMQ — the lease and its heartbeat
In EchoMQ the lease is EchoMQ.Keys.lock/2, which builds
emq:{queue}:<id>:lock for a job id ("#{job(ctx, job_id)}:lock").
It is set with a PX expiry when the worker takes the job and renewed on the worker's
heartbeat. The renewals do not run one timer per job: EchoMQ.LockManager keeps a single
timer that extends all tracked leases in one batch, so a pool of workers renews a pool of leases without a timer
storm.
# EchoMQ.Keys.lock/2 builds the lease key per job id (real, Elixir) def lock(ctx, job_id), do: "#{job(ctx, job_id)}:lock" # emq:{queue}:<id>:lock # EchoMQ.LockManager renews all tracked leases from ONE timer, in batch — # not one timer per job
The recovery sweep — moveStalledJobsToWait-8.lua — tests the lock for each
job in emq:{queue}:active; a job whose lock has lapsed is the one with no live worker.
The lease is the input to the whole sweep: the next dive marks those jobs, and the third runs the move
atomically.
The signal a worker died is a lock lease that lapsed. Each in-flight job holds a lock with a TTL, a live worker renews it on a heartbeat, and a missing lock marks a stalled job — the worker, not a fixed timeout.
EchoMQ.Keys.lock/2 builds emq:{queue}:<id>:lock, a
PX lease; EchoMQ.LockManager renews all leases from one timer
batch. A lock that is gone is a worker that died.
EchoMQ's lease is a PX lock per job, renewed in batch by one timer; the sweep
reads it, and a missing lock is a stalled job — the input every later step acts on.
References
Where the lock lease is set and expired, the lookup the sweep runs, and the queue protocol whose lock-renewal path EchoMQ ports.
Sources
- Redis — SET — the
PXexpiry behind the lock lease and theSET … PXheartbeat renewal. - Redis — EXPIRE — TTL semantics: how a key with a
PXexpiry is evicted when its time runs out. - Redis — EXISTS — the lock lookup the sweep runs: a missing lock is a stalled job.
- BullMQ — the queue protocol — the lock-renewal worker path EchoMQ ports.
Related in this course
- R3.03 · Stalled recovery — the module: the recovery sweep in full.
- R3.03.2 · Two-phase mark/recover — the next dive: mark once, recover on the next sweep.
- R3.03.3 · Atomic vs non-atomic — the atomic sweep that acts on this lock check.
- R3 · The reliable queue — the family in one place: the three guarantees.
- E6 · The job lifecycle — the dedicated EchoMQ course: the heartbeat manager that renews the lease.