R3.03.2 · dive 2
Two-phase mark/recover
A brief pause is not a dead worker. Mark a stalled job once, and recover it only on the next sweep that still finds it stalled.
A lock that lapsed is a strong signal a worker died, but not a certain one. A worker can miss one heartbeat — a long GC pause, a slow disk — and renew on the next tick. Reclaiming on the spot would redelivery every flicker. The two-phase sweep gives a job one grace pass: the first sweep marks it, the second recovers it. Toggle whether the worker recovers between sweeps to watch the mark cleared or the job reclaimed.
The two-sweep machine · mark, then recover only if still stalled
Mark on the first sweep
The first sweep that finds a job stalled does not reclaim it. It marks it: it records the job in
a stalled set and moves on. The mark is a memory across sweeps — a note that this
job was found without a live worker once. A job that was merely paused, whose worker renews its lock before the
next sweep, has its mark cleared and is never touched. A job whose worker is genuinely dead stays stalled, and
its mark survives.
The grace pass costs one sweep interval of delay before a dead worker's job is reclaimed, in exchange for not redelivering every job that briefly pauses. The trade is deliberate: a momentary stall is common and harmless, while a double-run is expensive, so the sweep spends one interval to avoid the second.
# sweep 1: mark the stalled job (no reclaim yet) SADD emq:{queue}:stalled <id> # record it; a paused worker clears this before sweep 2
- stalled set
- EchoMQ's
emq:{queue}:stalled, a Redis set that records a job was found stalled on the previous sweep. - grace pass
- The one sweep interval between mark and recover; a job whose worker comes back in that window is never reclaimed.
The first sweep marks a stalled job into the stalled set rather than
reclaiming it: a momentary stall whose worker renews has its mark cleared and is left alone.
Recover on the second sweep
The next sweep checks the marked jobs. A job still in the stalled set, still without a
live worker, is recovered: its attempt count is incremented, and it is moved out of
active. A job whose mark was cleared in between — its worker came back and renewed
the lock — is left alone.
Where a recovered job goes depends on its attempt count. A job back from a stall has used one of its attempts. If
it has attempts left, it returns to wait to be picked up again. If the recovery has
pushed it past its maximum attempts, it goes to failed instead — a job that
stalls every time it runs would otherwise loop forever, so the attempt ceiling caps the retries. Slide the
attempts used below up to the ceiling to watch the recovered job switch from wait to
failed.
# sweep 2: recover only if still marked and still stalled if SISMEMBER emq:{queue}:stalled <id> and EXISTS emq:{queue}:<id>:lock == 0: attempts = HINCRBY emq:{queue}:<id> atm 1 LREM emq:{queue}:active 1 <id> if attempts >= max_attempts: ZADD emq:{queue}:failed <ts> <id> # past the ceiling: fail, do not retry else: LPUSH emq:{queue}:wait <id> # attempts left: back to wait for another worker
The recovered job's branch · back to wait, or to failed past the ceiling
The second sweep recovers only a job still marked and still stalled, increments its attempts, and
sends it to wait — or to failed once it passes its
ceiling.
In EchoMQ — the two phases in one script
In EchoMQ the two phases live inside one script. moveStalledJobsToWait-8.lua operates
over a stalled set (EchoMQ.Keys.stalled/1 →
emq:{queue}:stalled) alongside wait,
active, and failed. On a sweep it marks a newly stalled job
into the stalled set; a job already in the set that is still stalled is the one it
recovers, moving it to wait or — past its attempt ceiling — to
failed, incrementing the attempt count as it goes.
-- moveStalledJobsToWait-8.lua — the mark + recover, in one script (real, Elixir) -- KEYS: stalled(SET) wait(LIST) active(LIST) failed(ZSET) stalled-check meta paused marker -- ARGV[2] = max_stalled_count — how many stalls a job may survive before it is failed
The max_stalled_count argument to
EchoMQ.Scripts.move_stalled_jobs_to_wait/4 bounds how many times a job may be recovered
from a stall before it is failed outright. Because the mark and the recover live in the same script, the
two-phase decision runs without a separate process holding intermediate state.
Mark a stalled job once, recover it on the next sweep that still finds it stalled. A flicker gets a grace pass;
a recovered job goes to wait until it passes its attempt ceiling, then to
failed.
moveStalledJobsToWait-8.lua marks into emq:{queue}:stalled
on one pass and recovers on the next; max_stalled_count caps the retries before a job
is moved to failed — mark and recover in one script.
EchoMQ's stalled set is the mark, and one script does mark-then-recover with
a max_stalled_count ceiling — a flicker forgiven, a dead worker reclaimed, a
chronic staller failed.
stalled-check key
throttles concurrent sweeps, and the pool-wide coordination — is the dedicated EchoMQ course.
From here, this dive opens onto E6 · the job lifecycle — the sweep
schedule that drives the two phases. This dive teaches the mark-then-recover decision; that course teaches the
schedule that runs it.
References
The set that marks a job once, the attempt-count increment, the scripting that keeps mark and recover in one step, and the queue protocol whose two-phase path EchoMQ ports.
Sources
- Redis — Sets — the
stalledset that marks a job once across sweeps. - Redis — SADD — the mark: record a newly stalled job for the next sweep.
- Redis — HINCRBY — the attempt-count increment on a recovered job.
- Redis — EVAL / scripting — why the mark and the recover run in one indivisible script.
- BullMQ — the queue protocol — the two-phase stalled-check worker path EchoMQ ports.
Related in this course
- R3.03 · Stalled recovery — the module: the recovery sweep in full.
- R3.03.1 · Lock-expiry detection — the prior dive: the lease as the death signal.
- R3.03.3 · Atomic vs non-atomic — the next dive: why the recover step must be one step.
- R3 · The reliable queue — the family in one place: the three guarantees.
- E6 · The job lifecycle — the dedicated EchoMQ course: the sweep schedule that drives the two phases.