jonnifyredis
Redis Patterns / R3 · Reliable Queues / Atomic state machine

R3.04 · Reliable Queues

Atomic state machine

Ensure data integrity with atomic read-modify-write operations: a job's transition runs as one server-side Lua script, so concurrent clients cannot interleave.

A queue's job has a lifecycle — wait, active, completed, failed, delayed. Each transition reads where the job sits, decides where it goes next, and writes the move. Done as separate commands, a recovery sweep or a second worker slips between the read and the write, and the job is handled twice or left in two places at once. Done as one EVALSHA, the whole read-decide-write runs inside Redis's single command thread, indivisible. This module is that discipline applied to the queue: the states are Redis locations, the transition is moveToFinished-14.lua, and the script is the lock.

The problem: interleaved read-modify-write

The atomic-updates pattern starts from a race. A read-modify-write — read a value, compute a new one, write it back — is three steps, and any other client can act in the gaps. The source's worked example is a balance: read account:123:balance, subtract 100, write the result. Two clients that both read the old balance before either writes both compute from the same number, and one update is lost.

Applied to the queue, the value being updated is not a balance but a job's location. A worker finishing a job reads the lock on the job in active, branches to completed, and writes the move. A recovery sweep, running at the same instant, reads the same job still in active and reclaims it to wait. Run as separate commands, the two interleave: the job lands in completed and wait both, or its lock is cleared by one process while the other still treats it as held. The concurrent writers are the finishing worker and the recovery sweep (or a second worker), and the value they race on is the job's place in the queue.

The figure below runs the finish over a fixed set of jobs. Pick a transition and watch the job id move from one location to the next; with fetch_next on, the same call also pulls the next waiting job into active. The readout names the move, the structures it touches, and that it is one EVALSHA.

One transition · the job id moves, and fetch_next pulls the next — one EVALSHA

emq:{queue}:wait (LIST) 51 · 52 emq:{queue}:active (LIST) job 50 + lock emq:{queue}:completed (ZSET) — emq:{queue}:failed (ZSET) — emq:{queue}:delayed (ZSET) — finish (complete) · active → completed, lock cleared, next fetched · 1 EVALSHA

The value the lifecycle updates is the job's location, and the writers that race on it are the worker finishing and the sweep reclaiming — so the transition has to be one indivisible step.

The solution: one Lua script, one EVALSHA

A Lua script executes atomically — no other command runs between the script's first line and its last. So the fix is to fold the whole read-decide-write into one script and run it with a single call. The read (is the lock still held? is this the right token?), the decision (completed or failed; past max attempts?), and every write (clear the lock, record the result, move the id, bump metrics, fetch the next) happen as one indivisible step. No second client can interleave, because Redis runs the script to completion before it serves the next command.

In EchoMQ this script is moveToFinished-14.lua, dispatched by EchoMQ.Scripts.move_to_finished/7. It touches fourteen keys — the whole queue shape — and it does the finish AND the next fetch in one round trip: fetch_next defaults to 1, so the same call that finishes the current job pops the next one from wait into active. The lifecycle transition and the next fetch are one atomic move.

# moveToFinished-14.lua — one EVALSHA, the whole lifecycle transition (real, Elixir)
# KEYS  1 wait      2 active   3 prioritized  4 event-stream  5 stalled
# KEYS  6 limiter   7 delayed  8 paused       9 meta         10 pc
# KEYS 11 completed|failed  12 jobId  13 metrics  14 marker
EVALSHA <sha> 14 wait active prioritized events stalled limiter \
        delayed paused meta pc completed jobId metrics marker \
        jobId ts returnvalue result completed 1 ...     # ARGV[6] fetch_next = 1
# clear the lock · write the result on the job hash · move jobId active → completed
# bump metrics · pop the next job wait → active — all in one indivisible step

Folded into one Lua script and dispatched with a single EVALSHA, the read-decide-write runs to completion before any other command — the script is the lock.

Redis commands used

The client issues exactly one command per transition: the EVALSHA. The script body runs the queue moves — RPOPLPUSH over the wait/ active lists, ZADD over the completed/failed sorted sets, HSET on the job hash — but those reach Redis inside the one cached-script call.

CommandRole in the transition
EVALSHA sha numkeys key… arg…Run the cached script by its SHA — cheap, no script body on the wire. The one command per finish.
SCRIPT LOAD scriptCache the body once and get back the SHA the client calls by.
EVAL script numkeys key… arg…Run the full body — the fallback when the SHA is not cached (NOSCRIPT).

A multi-key, multi-structure transition reaches Redis as one indivisible call — the client issues one EVALSHA, and the script runs every move inside it.

When to use

Use the atomic-script transition whenever a job's move reads state, branches on it, and writes across more than one key — and a second process can run the same move at the same time. A queue's finish is exactly that: it reads the lock and the attempt count, branches to completed or failed, and writes across the lists, the sorted sets, the job hash, and the metrics. A recovery sweep runs concurrently by design. The transition must be one step.

Use the script whenWhy
The move spans more than one keySeparate commands leave a window where the id is in two places or neither.
The move carries a branchcompleted vs failed, retry vs fail past max attempts — the decision belongs inside the atomic step.
A second process can run the same moveA recovery sweep finishing the same job interleaves unless the transition is indivisible.
Finish and next fetch should be one round tripfetch_next=1 folds the next pickup into the finish, with no window between.

When the move spans many keys, carries a branch, and runs against a concurrent sweep, fold it into one EVALSHA.

When to avoid

A single-key, single-command update needs no script — INCR, SET … GET, and GETDEL are already atomic on their own. Reach for the script only when the move spans multiple keys or carries a branch. And keep the keys on one node: a Lua script may only touch keys in the same slot, so a clustered queue co-locates its keys with a hash tag (the R2.05 treatment) before a fourteen-key script can run at all.

If the update is one key and one command, it is already atomic; the script earns its place only when the move is multi-key, branched, and concurrent — and all its keys sit in one slot.

The three dives

Each dive takes one part of the transition, in order: a job's states are Redis locations, the move between them is one EVALSHA over fourteen keys, and the cached script reaches the server by SHA with a NOSCRIPT fallback. Read them in order — the locations first, the atomic move second, the SHA mechanics third.

the pattern

A read-modify-write across multiple keys must be one server-side script, or concurrent clients interleave. Fold the read, the decision, and every write into one Lua call.

in EchoMQ

move_to_finished/7 wraps moveToFinished-14.lua — fourteen keys, one EVALSHA — finishing the job and, with fetch_next defaulting to 1, fetching the next as one indivisible transition.

The pattern says fold a multi-key read-modify-write into one script; EchoMQ's fourteen-key moveToFinished does, and finishes-and-fetches in the same atomic step.

A door, not a depth

This module cites one excerpt of EchoMQ's protocol — the fourteen-key moveToFinished and the EVALSHA/NOSCRIPT fallback in execute_raw/4 — as proof the transition ships. The worker fetch loop that calls it (E2 · the engine) and the full lifecycle transitions with their fourteen-key include graph (E6 · the lifecycle) are the subject of the dedicated EchoMQ course, built next with this toolkit. This module teaches the atomic-transition pattern; that course teaches the engine that runs it.

R3.04 closes the Reliable Queues chapter's lifecycle story: after the processing list, at-least-once delivery, and the recovery sweep, the atomic state machine is how each transition stays indivisible. Return to the Reliable Queues chapter to continue.

References

The scripting commands behind the transition — the cached call, the load, the full-body fallback — and where the engine that runs them is taught.

Sources

Related in this course