R3.04.2 · dive 2 · the centrepiece
Read-decide-write in one EVALSHA
The finish is a read-modify-write. Split into round trips, a recovery sweep slips between read and write. Run as one EVALSHA, it is indivisible.
Finishing a job reads the lock, decides completed or failed, and writes the move across many keys. How those steps are joined determines whether the finish is correct under concurrency. Pick separate round trips or one EVALSHA below, with a recovery sweep interleaving: split apart, the sweep reclaims the job mid-finish and it is handled twice; as one EVALSHA, the whole read-decide-write runs inside Redis's single thread, indivisible.
A finish and a recovery sweep · one job — how many times handled
The transition is a read-modify-write
Finishing a job is not one write. It is a read, a decision, and several writes:
- Read — is the lock still held, and is this the right token? A worker that lost its lease must not finish a job a recovery sweep has already reclaimed.
- Decide — completed or failed; and past the attempt ceiling, failed instead of retried.
- Write — clear the lock, record the result on the job hash, move the id from
activetocompletedorfailed, bump the metrics, and fetch the next job.
That is a read-modify-write across many keys. Done as separate commands, the gaps between them are open. A
recovery sweep reads the same job still in active, reclaims it, and writes it
back to wait — between the finishing worker's read and its write. The job lands in
both completed and wait, or its lock is cleared by one process
while the other still treats it as held. At-least-once becomes a mess by accident.
A finish reads the lock, decides where the job goes, and writes across many keys — three steps a concurrent sweep can slip between.
One EVALSHA closes every gap
A Lua script runs to completion before Redis serves the next command. So folding the whole read-decide-write
into one script and dispatching it with a single EVALSHA removes every gap. The read,
the decision, and the writes are one indivisible step; no recovery sweep and no second finish can interleave.
-- moveToFinished-14.lua — the read-decide-write as one EVALSHA (real, Elixir) -- read: is the lock held under this token? what is the attempt count? -- decide: target is `completed` or `failed`; past max attempts → failed -- write: clear the lock · record the result on the job hash -- move jobId active → completed|failed · bump metrics -- (fetch_next=1) pop the next job wait → active -- nothing runs between the first line and the last — one indivisible step
The script is the lock. Because the read, the decision, and the writes are one step, there is no window between
them for a sweep to act in. EchoMQ.Scripts.move_to_finished/7 dispatches it with
execute(conn, :move_to_finished, keys, args), a named-script call that runs
EVALSHA.
One EVALSHA runs the read, the decision, and the writes inside Redis's single
thread — no second process interleaves, so the job is handled once.
The fourteen keys
moveToFinished-14.lua touches the whole queue shape — fourteen keys, named in
this exact order. EchoMQ.Scripts.move_to_finished/7 builds the list in this order and
passes it to the one EVALSHA:
- 1 emq:{queue}:wait LIST
- 2 emq:{queue}:active LIST
- 3 emq:{queue}:prioritized ZSET
- 4 emq:{queue}:events STREAM
- 5 emq:{queue}:stalled SET
- 6 emq:{queue}:limiter
- 7 emq:{queue}:delayed ZSET
- 8 emq:{queue}:paused LIST
- 9 emq:{queue}:meta
- 10 emq:{queue}:pc priority counter
- 11 emq:{queue}:completed | :failed ZSET · the target
- 12 emq:{queue}:<id> job hash
- 13 emq:{queue}:metrics:<target>
- 14 emq:{queue}:marker
All fourteen reach Redis as one command. The completed-or-failed key in slot 11 is the target the
target argument selects (:completed or
:failed); the wrappers move_to_completed/6 and
move_to_failed/6 both delegate to move_to_finished/7 with that
target set.
Fourteen keys — every list, sorted set, hash, stream and marker the transition touches —
reach Redis as one EVALSHA.
The fetch_next payoff
The centrepiece is one ARGV flag. fetch_next defaults to 1, so
the same moveToFinished call that finishes the current job also pops the next id from
wait into active — under the same atomic step.
Finish-then-fetch, the two moves a worker makes back to back, is one round trip rather than two. The lifecycle
transition and the next fetch are indivisible: there is no instant between the finish and the next pickup at
which the queue sits half-moved.
A worker loop that finished a job and then made a second call to fetch the next would pay two round trips and open a window between them. EchoMQ's loop pays one. The slider below contrasts the round-trip count directly.
Finish, then start the next · two calls or one
A multi-key read-modify-write must be one server-side script, or concurrent clients interleave. Fold the read, the decision, and every write — and the next pickup — into one call.
move_to_finished/7 dispatches moveToFinished-14.lua as one
EVALSHA over fourteen keys, with fetch_next defaulting to 1,
so the finish and the next fetch are one indivisible transition.
The finish and the next pickup are one atomic EVALSHA — one round trip,
no window for a sweep or a second finish to slip into.
moveToFinished on every job, are 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 is one step; that course teaches the engine that runs it.
References
The cached-script call that runs the transition as one step, why a script is indivisible, the target structures the move writes into, and the queue protocol EchoMQ ports.
Sources
- Redis — EVALSHA — the cached-script call that runs the fourteen-key transition as one step.
- Redis — EVAL / scripting — why a Lua script is one atomic step, so no second process interleaves the read-decide-write.
- Redis — Scripting with Lua — the model for a script running to completion in the single command thread.
- Redis — Sorted sets — the
completed/failed/delayed/prioritizedtarget locations the move writes into. - BullMQ — the queue protocol — the
moveToFinishedlifecycle and its fetch-next payoff EchoMQ ports.
Related in this course
- R3.04 · Atomic state machine — the module: the transition as one EVALSHA.
- R3.04.1 · States as locations — the prior dive: the locations the move travels between.
- R3.04.3 · EVALSHA and NOSCRIPT — the next dive: how the cached script gets to the server.
- R3.03 · Stalled recovery — the sweep that must not interleave this finish.
- R2.01 · Atomic updates — the one-script read-modify-write pattern this applies.
- E2 · The engine — the dedicated EchoMQ course: the worker fetch loop that calls
moveToFinished.