jonnify·redis /redis-patterns/queues/atomic-state-machine

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.

One transition — the id moves between emq:{q}: locations. Select a transition to light its path and read the script that runs it.
pendingZSET · mint-order activeZSET · lease score scheduleZSET · run-at deadZSET · morgue job rowhash · state, attempts claim complete retry past max
readout · Select a transition. Each is one inline EchoMQ.Script.new/2, dispatched by EchoMQ.Connector.eval/5 as a single EVALSHA — the read, the decision, and every write in one indivisible step.

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.

§1

How it works

atomic-updates.md.txt · jobs.ex

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 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.

§2

Redis commands used

script.ex · connector.ex

The 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.

§3

When to use — and when to avoid

atomic-updates.md.txt · When

Use 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 application · EchoMQ's leased state machine

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:

echo/apps/echo_mq/lib/echo_mq/jobs.ex · @complete, the token fencefrozen
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
§4

The three dives

locations → one EVALSHA → the SHA

Each 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.

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.

its EchoMQ application

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.

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.

§5

References

sources · related

Sources

  • 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; NOSCRIPT is the engine's own uncached reply.

Related in this course