jonnifyredis

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

sweep 1 MARK → stalled set between sweeps no renewal still stalled sweep 2 RECOVER active → wait worker stayed dead · marked on sweep 1, reclaimed on sweep 2 one grace pass spent, then the dead worker's job is recovered

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

1
3
recover: atm 1 → 2 (max 3) → wait attempts left · retry → failed past the ceiling · stop atm 2 < max 3 → the recovered job returns to wait

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.

the pattern

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.

in EchoMQ

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.

Door · not depth The sweep cadence in full — how often the script runs, how the 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

Related in this course