R3.03 · Reliable Queues
Stalled recovery
Guarantee at-least-once message delivery using LMOVE to atomically transfer messages to a processing list, enabling recovery if consumers crash before completing work.
A reliable queue moves a job into an in-flight list under a lock so a crash leaves it parked, not
lost. But a parked job is only half the guarantee — something must come back for it. A recovery sweep finds
the in-flight job whose worker died and returns it to wait for another worker. This module
is that sweep: how it detects that a worker died, why it marks a job before it recovers it, and why the
detect-and-move must be one indivisible step.
Recovery from Failures
The source pattern runs a separate monitor process — sometimes called a reaper — that
periodically scans the processing queues for stalled messages. For each message the reaper applies a timeout
rule: check each processing queue; if a message has been there longer than a timeout (for example, ten minutes),
the worker is assumed dead; move the message back to the main queue with
LMOVE processing:worker1 work_queue RIGHT RIGHT. This guarantees at-least-once delivery
— a message may be processed more than once, but it is never lost.
EchoMQ keeps the shape of the reaper and sharpens its test. The reaper's test is has this message been
in-flight longer than a fixed timeout? — a coarse proxy for a dead worker that misfires whenever a job
legitimately runs long. EchoMQ's test is more precise: does this in-flight job still have a live
worker? The answer is a lock lease. Each in-flight job holds a lock with a
PX expiry, and a healthy worker renews that lease on a heartbeat. When the worker dies,
the heartbeat stops, the lease expires, and the lock is gone. Lock gone means the worker died means the
job stalled. The timeout becomes a lease, and the lease is exact: it is tied to the worker, not the
clock.
The sweep below runs over a fixed in-flight set. Toggle a worker dead to drop its lease, then run the sweep: the
jobs whose lock is gone move back to wait, the jobs whose worker is still renewing stay
put. The readout reports which jobs are stalled and where each lands.
The recovery sweep · three in-flight jobs — a missing lock is a dead worker
The source's reaper reclaims a message older than a timeout; EchoMQ reclaims a job whose lock lease is gone. The timeout is a proxy; the lease is tied to the worker exactly.
The lease as the death signal
A lock that expired is the signal a worker died. The lease (EchoMQ.Keys.lock/2 →
emq:{queue}:<id>:lock) is a key with a short PX TTL set
when a worker takes a job. A live worker renews it on a heartbeat — SET … PX, a
lease renewal — before it can lapse. The renewals run in batch from one timer in
EchoMQ.LockManager, so a pool of workers extends a pool of leases without one timer per
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
When the worker crashes, the heartbeat stops. No renewal arrives, the TTL runs to zero, and Redis evicts the
lock key. The job is now in active with no live worker. The recovery sweep tests exactly
this — for each job in active, is the lock key still present? — and a missing
lock is a stalled job. The first dive builds this heartbeat-and-lease picture, and shows why a paused worker that
keeps renewing is correctly left alone while a dead one is reclaimed.
A lock with a PX TTL, renewed on a heartbeat, is the liveness signal: the
lock is present exactly while a worker is alive, so a missing lock is a stalled job.
Mark, then recover
A brief pause is not a dead worker. A worker that misses one heartbeat — a long GC pause, a slow disk — may renew on the next tick and finish the job. If the sweep reclaimed a job the instant its lock lapsed, every momentary stall would be redelivered, doubling the work.
The fix is a two-phase sweep. The first sweep that finds a job stalled marks it: it records the
job in a stalled set rather than reclaiming it on the spot. Only the second sweep that
still finds the job stalled recovers it. A momentary stall survives one grace pass; a
genuinely dead worker is reclaimed on the next sweep. When a job is recovered, its attempt count is incremented
and it goes back to wait — unless it has now exceeded its maximum attempts, in
which case it goes to failed instead of being retried forever. The second dive walks the
mark → recover → wait-or-failed decision.
Mark a stalled job once, recover it only on the next sweep that still finds it stalled: a flicker gets one grace pass, a dead worker is reclaimed, and a chronically stalling job is failed at its attempt ceiling.
Atomic vs non-atomic recovery
The recovery sweep must itself be careful. The detect step tests is this job stalled? and the recover
step does remove it from active, push it to wait. If
those are two separate round trips, two sweeps running at once can both pass the detect step for the same job and
both run the recover step — the job lands in wait twice, and its attempt counter is
mis-incremented. This is the double-recovery window.
The atomic form closes it. moveStalledJobsToWait-8.lua runs the whole detect-and-move
as one EVALSHA: the read, the decision, and the writes happen inside Redis's single thread, so
no second sweep can interleave. Each stalled job is reclaimed once per sweep, and the attempt count is
incremented in the script. The Go port's StalledChecker does the same work app-side
— LRANGE active, then per job EXISTS lock, then
LREM/LPUSH — across separate round trips, and increments
the attempt count client-side. It is correct until two checkers overlap; then the window opens. The third dive is
the centrepiece: it runs the two overlapping checkers and counts the recoveries.
Joined as one EVALSHA, the detect-and-move is exact; joined as separate round trips, a second sweep slips between detect and recover and reclaims the same job twice.
When to Use
A recovery sweep belongs to any reliable queue whose workers can crash mid-job. The choices it makes are how it detects a dead worker, whether it grants a grace pass, and how the detect-and-move is joined.
| Concern | Choice |
|---|---|
| Detect a dead worker | a lock lease (SET … PX + heartbeat), not a fixed timeout |
| Tolerate a brief pause | two-phase: mark in a stalled set, recover on the next sweep |
| Bound the retries | increment the attempt count; past the ceiling, move to failed |
| Avoid double recovery | run detect-and-move as one Lua script (one EVALSHA) |
| The reclaim move | LREM active + LPUSH wait, in-script |
A reliable queue needs a sweep that detects on a lease, forgives a flicker, caps the retries, and moves in one step — the four choices this module's three dives make precise.
The three dives
Each dive takes one part of the sweep, in order: how it detects a dead worker, how it marks before it recovers, and why the detect-and-move must be one indivisible step. Read them in order — the lease comes first, the two-phase decision second, the atomic contrast third.
Lock-expiry detection
The lease as a death signal: a PX lock renewed on a heartbeat, the
EXISTS check the sweep runs, and why a paused worker is left alone but a dead one is
reclaimed.
Two-phase mark/recover
Mark a job in the stalled set on the first sweep, recover it on the second;
the grace pass for a flicker; the attempt count → wait or
failed branch.
Atomic vs non-atomic
The centrepiece: moveStalledJobsToWait-8.lua as one EVALSHA vs the Go
port's multi-round-trip loop; the double-recovery window and the client-side attempt-count race.
A separate sweep reclaims a job whose worker died, and the detect-and-move must be one step or two overlapping sweeps redeliver it. The signal a worker died is a lock lease that lapsed.
move_stalled_jobs_to_wait/4 runs moveStalledJobsToWait-8.lua
— eight keys, one EVALSHA — over the lease EchoMQ.Keys.lock/2 renewed by
EchoMQ.LockManager. The Go port's separate-round-trip
StalledChecker is the cautionary contrast.
The pattern says reclaim a crashed job in one atomic step; EchoMQ's atomic sweep does, and the Go port's round-trip loop shows what the window costs.
A door, not a depth
This module cites one excerpt of EchoMQ's protocol — the eight-key
moveStalledJobsToWait sweep and the lease it tests — as proof the recovery ships.
The heartbeat manager that renews the lease across a whole worker pool, the
stalled-check coordination (E6 · the lifecycle), and the
worker fetch loop (E2 · the engine) are the subject of the dedicated
EchoMQ course, built next with this toolkit. This module teaches the recovery pattern; that
course teaches the engine that runs it.
R3.03 closes the Reliable Queues chapter's recovery story: after the processing list and at-least-once delivery, the sweep that reclaims a crashed job is the last guarantee. Return to the Reliable Queues chapter to continue.
References
The Redis mechanisms behind the recovery sweep — the lease, the atomic script, the removal, and the move — and where the engine that runs them is taught.
Sources
- Redis — SET — the
PXoption behind the lock lease and the heartbeat renewal. - Redis — EVALSHA — the cached-script call that runs the atomic detect-and-move as one step.
- Redis — EVAL / scripting — why a Lua script is one indivisible step, so the sweep cannot interleave.
- Redis — LREM — the removal of the stalled job from the in-flight list.
- Redis — LMOVE — the move back to wait, the modern form of the source's
LMOVE processing work RIGHT RIGHT. - BullMQ — the queue protocol — the stalled-check worker path EchoMQ ports.
Related in this course
- R3.03.1 · Lock-expiry detection — the lease as the death signal.
- R3.03.2 · Two-phase mark/recover — mark once, recover on the next sweep.
- R3.03.3 · Atomic vs non-atomic — one EVALSHA vs the multi-round-trip loop.
- R3 · The reliable queue — the family in one place: the three guarantees.
- R2.01 · Atomic updates — the one-step atomic move the sweep needs.
- R3 · Reliable Queues — the chapter.
- E6 · The job lifecycle — the dedicated EchoMQ course: the heartbeat manager and the pool-wide stalled-check.