R3.04 · Reliable Queues — module hub
Atomic state machine.
Ensure data integrity with atomic read-modify-write operations: a job's transition between states runs as one server-side Lua script, so concurrent clients cannot interleave.
Grounding: echo/apps/echo_mq · EchoMQ.Jobs (enqueue → claim → complete / retry) · EchoMQ.Connector.eval/5 (EVALSHA-first) — the leased state machine, each transition one inline EchoMQ.Script.new/2, the row's attempts the fencing token. Engine: Valkey.
Static verdicts — claim → @claim ZPOPMIN pending,
HINCRBY attempts, ZADD active · complete → @complete checks the token, ZREM
active, retire the row · retry → @retry ZADD schedule on the server clock ·
dead → @retry past max attempts, ZADD dead.
How it works
atomic-updates.md.txt · jobs.exThe 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 a job's location. A worker
completing a job reads the row's fencing token, checks it still holds the lease, and writes the move out
of active. A lease-expiry sweep, running at the same instant, reads the same job still in
active past its deadline and returns it to pending for redelivery. Run as
separate commands, the two interleave: one process retires the job while the other re-queues it. The
concurrent writers are the completing worker and the reaper
(EchoMQ.Stalled, on the server clock), and the value they race on is the job's place in
the queue.
A Lua script executes atomically — no other command runs between its first line and its last.
So each transition folds the whole read-decide-write into one script: read the row's
attempts token, decide whether it still matches and whether the attempt is past
max, then write every move. No second client can interleave, because Valkey runs the
script to completion before it serves the next command. In EchoMQ the leased state machine is
EchoMQ.Jobs: enqueue/4 writes the row and the pending entry;
claim/3 pops the oldest member, HINCRBYs the row's attempts to
mint the lease token, and scores it into active; complete/5 checks the token
and retires the row; retry/7 checks the token and either reschedules or moves the row to
dead.
The job's state is a place in emq:{q}:, and the writers that race on it are
the worker completing and the reaper reclaiming — so the transition has to be one indivisible step.
Redis commands used
script.ex · connector.exThe client issues exactly one command per transition: the EVALSHA. Each script is
declared inline with EchoMQ.Script.new(name, lua), which precomputes the SHA1, and
dispatched by EchoMQ.Connector.eval/5, which runs EVALSHA first and falls
back to SCRIPT LOAD + a re-run EVALSHA on a NOSCRIPT reply:
EVALSHA <sha> 2 emq:{cm}:pending emq:{cm}:active emq:{cm}:job: 30000 # @claim — pop, HINCRBY attempts, ZADD active
EVALSHA <sha> 2 emq:{cm}:active emq:{cm}:job:JOB0KHTOWnGLuC ... # @complete — check token, ZREM active, retire row
SCRIPT LOAD "<lua source>" # => the SHA — run once per script on a NOSCRIPT reply
The script body runs the queue moves — ZPOPMIN over pending,
ZADD into active/schedule/dead, ZREM
from active, HINCRBY on the row's attempts, HSET on
its state — but those reach Valkey inside the one cached-script call. Every key the
script touches is declared in KEYS (the v2 law); the branded JOB id is gated
before the key is built (EchoMQ.Keyspace.job_key/2 raises on an ill-formed id).
Notes on ValkeyA script runs to completion in the single
command thread — no other command interleaves — and the engine returns NOSCRIPT
when a SHA is uncached, which is what the connector's load-and-retry path keys on
— valkey.io/commands/evalsha.
When to use — and when to avoid
atomic-updates.md.txt · WhenUse the atomic-script transition when 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 completion is exactly that: it reads the fencing token, branches on whether the token still
matches and whether the attempt is past max, and writes across the active set,
the row hash, and the metric. A lease-expiry sweep runs concurrently by design.
Avoid it when the update is a single key and a single command —
INCR, SET … GET, and GETDEL are already atomic on their own. And
keep the keys on one node: a Lua script may only touch keys in the same slot, so EchoMQ braces the
queue name — emq:{q}:pending, emq:{q}:active,
emq:{q}:job:<id> — so every key of one queue hash-tags into one slot and a
multi-key script can run at all (the R2.05 hash-tag treatment, built into the keyspace).
The transition is token-fenced, so it cannot run twice
The read-decide-write is one EVALSHA, and the row's attempts —
HINCRBY'd at claim/3 — is the fencing token: a worker whose lease was
reaped and re-claimed by another holds a stale token, so its complete/5 or
retry/7 is refused. The branch reads, in the real @complete body:
local att = redis.call('HGET', KEYS[2], 'attempts')
if not att then return 0 end ← row gone
if att ~= ARGV[2] then
return redis.error_reply('EMQSTALE complete token mismatch')
end
redis.call('ZREM', KEYS[1], ARGV[1]) ← release the lease, retire the row
The three dives
locations → one EVALSHA → the SHAEach dive takes one part of the transition, in order: a job's states are emq:{q}:
locations, the move between them is one EVALSHA per inline script, 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.
States as locations
A job's state is the emq:{q}: key its id lives in: pending,
active, schedule and dead are sorted sets; the row is a hash. A
transition moves the id between structures.
Read-decide-write in one EVALSHA
The centrepiece: each EchoMQ.Script.new/2 runs the read, the decision, and
the writes as one EVALSHA; the row's attempts token makes it idempotent
under redelivery.
EVALSHA and NOSCRIPT
EchoMQ.Script.new/2 precomputes the SHA; the connector calls
EVALSHA; a flushed cache returns NOSCRIPT and the connector runs
SCRIPT LOAD once, then re-runs the EVALSHA.
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.
EchoMQ.Jobs runs each lifecycle transition as one inline
EchoMQ.Script.new/2, dispatched EVALSHA-first by EchoMQ.Connector.eval/5;
the row's attempts fencing token, minted by claim/3, makes the script
idempotent so the worker and the reaper cannot both move one job.
The pattern says fold a multi-key read-modify-write into one script; EchoMQ's leased state
machine does, and the attempts token keeps the transition from running twice.
The worker fetch loop, in depth
The full claim loop and the rest of the script bundle — the leased state machine end to end — are the dedicated EchoMQ course's Queue pillar.
/echo-persistenceWhere a dead job lands
When retry/7 moves a row to dead, it reaches the durable floor: the page
tier (EchoStore.Graft.* over CubDB) and Tigris — the durability dial.
The keyspace and the jobs, in the manuscript
Part III builds the emq:{q}: keyspace and the branded JOB rows the
transition moves between.
R3.04 closes the chapter's lifecycle story: after the processing list, at-least-once delivery, and stalled recovery, the atomic state machine is how each transition stays indivisible. Return to the Reliable Queues chapter to continue, or open the orientation dive for the family framing.
References
sources · relatedSources
- Redis — EVALSHA — run a cached script by its SHA; the cheap call that drives every lifecycle transition.
- Redis — EVAL / scripting — why a Lua script is one atomic step, so the read-decide-write cannot interleave.
- Redis — SCRIPT LOAD — cache the body once and get back the SHA the client calls by.
- Valkey — EVALSHA — the cached-script call on the engine the connector is gated against;
NOSCRIPTis the engine's own uncached reply.
Related in this course
- R3.04.1 · States as locationsa job's state is the emq:{q}: key its id lives in
- R3.04.2 · Read-decide-write in one EVALSHAthe transition as one indivisible call
- R3.04.3 · EVALSHA and NOSCRIPTload once, run by SHA
- R3.03 · Stalled recoverythe reaper that must not interleave the completion
- R2.01 · Atomic updatesthe one-script read-modify-write pattern
- /echomq/queuethe dedicated EchoMQ course: the leased state machine