R3.03.3 · dive 3 · the centrepiece
Atomic vs non-atomic
Detect, then move. Joined as one EVALSHA the sweep is exact; joined as two round trips a second sweep slips between them and reclaims the same job twice.
The recovery sweep is two moves: is this job stalled? and remove it from
active, push it to wait. How they are joined determines
whether the sweep is correct under concurrency. Pick atomic or non-atomic below and run two overlapping checkers
over one stalled job: in atomic mode the move is one indivisible step; in non-atomic mode the second checker
slips between detect and recover, and the job is recovered twice.
Two overlapping checkers · one stalled job — how many recoveries
The detect-and-move as one step
The atomic form runs detect-and-move as a single Lua script. moveStalledJobsToWait-8.lua
is invoked with one EVALSHA: its body reads the in-flight list, tests each job's lock,
and moves the stalled ones to wait — all inside Redis's single command thread. No
other command runs between the script's first line and its last. A second sweep that fires during the script does
not interleave with it; it waits its turn and then finds the work already done. Each stalled job is reclaimed
once per sweep, and the attempt count is incremented in the script, on the value the script itself read.
-- moveStalledJobsToWait-8.lua — atomic detect-and-move, one EVALSHA (real, Elixir) -- KEYS: stalled(SET) wait(LIST) active(LIST) failed(ZSET) stalled-check meta paused marker -- for each job in `active` whose lock has expired: -- mark it on the first pass; on the next pass move it to `wait` (or `failed` past max attempts) -- and increment attemptsMade — all in one indivisible step, nothing interleaves
The script is the lock. Because the read, the decision, and the writes are one step, there is no window between the check and the move for a second sweep to act in.
One EVALSHA runs the read, the decision, and the writes inside Redis's single
thread — no second sweep interleaves, so each stalled job is reclaimed once.
The two separate round trips
The non-atomic form is the Go port's StalledChecker, the cautionary contrast — the
Go runtime's path, not EchoMQ's recommended one. It does the same work app-side, in separate commands:
LRANGE active 0 -1 lists the in-flight jobs; then for each job
EXISTS lock is the stalled test (isJobStalled); then
recoverStalledJob runs HGETALL job, reads and increments the
atm (attemptsMade) field in Go, runs LREM active, branches to
failed past max attempts, and runs LPUSH wait with a pipelined
HSET atm. Each is a round trip, and the gaps between them are open.
// stalled.go — the non-atomic Go path (the cautionary contrast, NOT "EchoMQ") activeJobs, _ := redis.LRange(ctx, kb.Active(), 0, -1).Result() // list in-flight jobs for _, jobID := range activeJobs { if exists, _ := redis.Exists(ctx, kb.Lock(jobID)).Result(); exists == 0 { // stalled? // ...a second checker can BOTH reach here for the same job — double-recovery window redis.LRem(ctx, kb.Active(), 1, jobID) // separate round trip redis.LPush(ctx, kb.Wait(), jobID) // separate round trip // atm (attemptsMade) read + incremented client-side, then HSet — a lost-update race } }
Two gaps in this loop are the bugs the atomic script does not have.
The double-recovery window. Between EXISTS lock (the job is stalled) and
LREM/LPUSH (reclaim it), a second checker can run the same
EXISTS lock for the same job, also find it stalled, and also run
LREM/LPUSH. The job lands in wait twice.
One stalled job is redelivered to two workers — at-least-once becomes at-least-twice, by accident.
The client-side attempt-count race. The Go path reads atm with
HGETALL, increments it in Go, and writes it back with HSET. Two
checkers that read the same atm before either writes both compute the same next value
and both write it — a lost update on the attempt counter, so a job that stalled twice records one attempt.
The atomic Lua increments the field in the script, on the value the script itself read, with nothing in between.
- double-recovery window
- The gap between
EXISTS lockandLREM/LPUSH; a second checker that runsEXISTSin it reclaims the same job again. - lost-update race
- Two checkers read the same
atm, both increment in Go, both write back the same value — one increment is lost.
The Go loop is correct until two checkers overlap: the gap between detect and recover redelivers the
job, and the client-side atm increment loses a count.
In EchoMQ — the atomic form, the Go port as contrast
EchoMQ uses the atomic form. EchoMQ.Scripts.move_stalled_jobs_to_wait/4 wraps
moveStalledJobsToWait-8.lua, an eight-key script over stalled,
wait, active, failed,
stalled-check, meta, paused, and
marker. One EVALSHA reclaims each stalled job once per sweep and
increments its attempt count in-script — both gaps closed by construction.
The Go port apps/echomq-go/pkg/echomq/stalled.go keeps the same job model but does the
recovery in separate round trips, and so carries both windows. It is shown here not as EchoMQ but as the contrast
that makes the atomic form's value concrete: the multi-round-trip loop is correct until two sweeps overlap, and a
recovery sweep, by its nature, runs concurrently. The slider below contrasts the two attempt-count increments
directly.
The attempt counter under two checkers · in-script vs client-side
The detect-and-move must be one step, or two overlapping sweeps reclaim the same job twice and miscount its attempts. The script is the lock: read, decide, and write with nothing in between.
moveStalledJobsToWait-8.lua is one EVALSHA — reclaim
once, increment in-script. The Go port's separate-round-trip StalledChecker is the
cautionary contrast: correct until two sweeps overlap.
The atomic Lua closes the double-recovery window and the lost-update race by construction; the Go port's round-trip loop shows exactly what those windows cost when two sweeps run at once.
EVALSHA cache, the
stalled-check throttle, and the pool-wide coordination that keeps two sweeps from racing in
the first place — is the dedicated EchoMQ course. From here, this dive opens onto
E2 · the engine — the worker fetch loop and the script dispatch. This dive
teaches why the move must be one step; that course teaches the engine that runs it.
References
The cached-script call that runs the move as one step, why a script is indivisible, the in-flight commands both forms run, and the queue protocol both runtimes port.
Sources
- Redis — EVALSHA — the cached-script call that runs the detect-and-move as one EVALSHA.
- Redis — EVAL / scripting — why a Lua script is one indivisible step, so no second sweep interleaves.
- Redis — LREM — the in-flight removal both forms run; in the Go path a separate round trip.
- Redis — LMOVE — the atomic move back to wait, the one-step alternative to the Go loop's separate
LREM/LPUSH. - BullMQ — the queue protocol — the stalled-check worker path both runtimes port.
Related in this course
- R3.03 · Stalled recovery — the module: the recovery sweep in full.
- R3.03.1 · Lock-expiry detection — the lease the sweep tests.
- R3.03.2 · Two-phase mark/recover — the prior dive: mark once, recover on the next sweep.
- R2.01 · Atomic updates — the one-step atomic move this sweep needs.
- R3 · The reliable queue — the family in one place: the three guarantees.
- E2 · The lifecycle, components & runtimes — the dedicated EchoMQ course: the worker fetch loop and the script dispatch.