R3 · dive 2
States as locations
A job's state is not a field on a record. It is the Redis key its id lives in — and a transition moves the id to the next key.
A naive queue stores a status column and updates it as the job moves, so the location and the
status can disagree after a crash. EchoMQ holds no status field: emq:{queue}:wait and
:active are LISTs, :delayed, :prioritized,
:completed and :failed are ZSETs, and the state is the
key the id sits in. The whole transition runs as one atomic Lua move —
moveToFinished-14.lua across fourteen keys in one EVALSHA —
and a blocked worker wakes on a marker instead of polling. Fire a transition on the right and watch the id hop
from one key to the next.
States as locations · the job id hops from one key to the next
The state is the location
A naive queue stores a status column on the job record — "wait",
"active", "done" — and updates it as the job moves. Two
facts can then disagree: where the job actually sits and what its status claims. A crash between writing the
status and moving the job leaves the pair out of sync, and nothing reconciles them.
EchoMQ holds no status field. A job's state is the Redis key its id lives in. The wait and active states are LISTs; delayed, prioritized, completed and failed are sorted sets scored by time or priority:
# a job's state is the key its id lives in — no status column emq:{emails}:wait → LIST · ids waiting to run emq:{emails}:active → LIST · ids in flight under a lock emq:{emails}:delayed → ZSET · scored by when the job is due emq:{emails}:prioritized → ZSET · scored by priority emq:{emails}:completed → ZSET · finished ids, scored by finish time emq:{emails}:failed → ZSET · failed ids
EchoMQ.Keys builds each of these from the queue context —
wait/1, active/1, delayed/1,
prioritized/1, completed/1, failed/1.
A transition is moving the id from one key to the next. There is no separate state column to fall out of sync,
because the location is the state. The hero figure runs exactly this — fire a transition and the id hops
to its new key.
- wait / active
- LISTs. A job is pushed onto wait and moved into active to run; the active list is the in-flight set under a lock.
- delayed / prioritized
- ZSETs. The score is the due time (delayed) or the priority (prioritized), so the queue can pop the next-due or highest-priority id.
- completed / failed
- ZSETs of terminal ids, scored by finish time, so old entries can be trimmed by age or count.
A job's state is not a field that can lie about where the job is — the state is the key the id lives in, so the location can never disagree with itself.
The whole transition is one atomic Lua move
Moving a job from active to completed is not one write. The
transition reads the job, checks its lock, removes the id from active, writes the result
into completed, updates counters and metrics, and pushes the next job — touching
many keys. Done as separate commands, a crash partway leaves the job in two places, or in none.
moveToFinished-14.lua performs that whole transition across fourteen keys
in one EVALSHA. Its header documents the fourteen KEYS, and
EchoMQ.Scripts.move_to_finished/7 assembles them in order:
# moveToFinished-14.lua — the 14 keys of one lifecycle transition KEYS[1] wait KEYS[8] paused KEYS[2] active KEYS[9] meta KEYS[3] prioritized KEYS[10] pc (priority counter) KEYS[4] events KEYS[11] completed / failed KEYS[5] stalled KEYS[12] job hash KEYS[6] limiter KEYS[13] metrics KEYS[7] delayed KEYS[14] marker
Redis runs a Lua script to completion with no other client interleaving, so the move applies in full or not at all. There is no torn intermediate state to recover. This is the R2.01 atomic-update pattern at lifecycle scale: read, decide, write — in one move. The figure below contrasts that one script against the same lifecycle done as separate commands, and shows where a crash leaves the job torn.
One EVALSHA vs N round-trips · where a crash tears the lifecycle
A lifecycle move touches many keys, and a queue can survive a crash only if that move is all-or-nothing — one Lua script, not a sequence a crash can split.
Load once, execute by SHA
The script is the protocol, and it is cached by its SHA1. EchoMQ.Scripts.execute_raw/4
computes the script's sha1, then issues
EVALSHA sha numkeys keys… args…. On the first call for a script Redis has not yet seen,
it answers NOSCRIPT; execute_raw/4 matches that error, falls
back to EVAL with the full body (which also caches it), and every later call hits
EVALSHA:
# EchoMQ.Scripts.execute_raw/4 — run by SHA, fall back to EVAL on NOSCRIPT sha = sha1(script) case command(conn, ["EVALSHA", sha, num_keys | keys ++ args]) do {:ok, result} -> {:ok, result} {:error, %Redix.Error{message: "NOSCRIPT" <> _}} -> command(conn, ["EVAL", script, num_keys | keys ++ args]) # caches it too end
The full roughly thousand-line moveToFinished-14.lua body crosses the wire once. After
that the worker sends only its forty-character SHA and the keys and arguments. The script lives in Redis; the
caller references it by hash.
A heavy lifecycle script is sent in full exactly once and then run by its SHA, so the protocol lives in Redis and the wire carries only a hash.
Block on a marker, do not busy-poll
A worker that loops LRANGE over the wait list and sleeps burns CPU and adds latency
between a job arriving and a worker noticing it. EchoMQ wakes a blocked worker instead. Every enqueue touches
the emq:{queue}:marker ZSET (EchoMQ.Keys.marker/1), and an
idle worker parks on BZPOPMIN marker timeout on a dedicated blocking
connection:
# worker.ex do_wait_for_job/3 — sleep until a marker says a job arrived marker_key = Keys.marker(state.keys) Redix.command(blocking_conn, ["BZPOPMIN", marker_key, timeout_seconds]) # returns [key, member, score] when a job arrives, or nil on timeout
The worker sleeps until a job arrives; then Redis returns the popped marker and the worker fetches the job. No CPU is spent while the queue is empty, and pickup latency is near zero rather than half a poll interval. The Go port's polling ticker is the contrast — it wakes on a fixed interval whether or not a job is waiting.
A worker should not ask the queue over and over whether a job has arrived — it should sleep on a marker and let Redis wake it the instant one does.
In EchoMQ — the lifecycle the worker runs
The pattern and the application line up directly. A job's state is its Redis location, every transition is one
atomic Lua move, and an idle worker blocks on a marker rather than polling. In EchoMQ that is
moveToFinished-14.lua — fourteen keys, one EVALSHA via
EchoMQ.Scripts.execute_raw/4 — together with emq:{queue}:marker
and BZPOPMIN. Each piece is real code in echo/apps/echomq:
EchoMQ.Keys for the state keys, EchoMQ.Scripts for the move,
EchoMQ.Worker for the blocking pickup.
A job's state is its Redis location, the whole transition is one atomic Lua move that cannot tear, and a blocked worker is woken by a marker instead of a poll.
moveToFinished-14.lua moves a job across fourteen keys in one
EVALSHA (via EchoMQ.Scripts.execute_raw/4), and a worker
sleeps on emq:{queue}:marker with BZPOPMIN.
States are Redis locations, a transition is one atomic Lua move loaded once and run by SHA, and a worker waits on a marker — the reliable lifecycle EchoMQ's worker runs.
emq.4). This dive teaches the pattern; that course teaches the engine that runs
it.
References
Where the atomic lifecycle script is run by hash, the cached-script protocol it relies on, the blocking pop that wakes a worker, and the reliable-queue protocol whose Lua scripts are the protocol.
Sources
- Redis — Scripting with Lua — a script runs to completion with no interleaving, so
moveToFinished-14.luaapplies in full or not at all. - Redis — EVALSHA — run a cached script by its SHA1; a
NOSCRIPTreply falls back toEVAL, which caches the body. - Redis — BZPOPMIN — the blocking pop on the marker ZSET that wakes a worker instead of busy-polling.
- BullMQ — the reliable-queue protocol EchoMQ ports, where the Lua scripts are the protocol.
Related in this course
- R3 · Reliable Queues — the chapter: the reliable-queue family in one place.
- R3 · The reliable queue — dive 1: the in-flight list, at-least-once, and stalled reclaim.
- R2.01 · Atomic updates — the atomic move; this dive is that pattern at lifecycle scale.
- R0.2 · Redis under Portal — the EchoMQ bus these patterns ground in.