jonnifyredis

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

checker 1 checker 2 EXISTS lock LREM / LPUSH EXISTS lock LREM / LPUSH atomic · the move is one step → checker 2 finds it already done → 1 recovery

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 lock and LREM/LPUSH; a second checker that runs EXISTS in 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

atm starts at 4 · two recoveries 4 → 5 → 6 in-script: both increments land · atm = 6, correct
the pattern

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.

in EchoMQ

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.

Door · not depth The script dispatch in full — the 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

Related in this course