The record hash · dive 02 · the machinery
The state and attempts
One field says where the job is. One field fences the work.
Two of the record's fields carry the job's machinery. state is the
lifecycle position — one field holds where the job is, and every transition rewrites it,
on either executor, with the same values. attempts is the fencing
token: the claim increments it and hands the new value to the worker, and every later call the worker
makes is refused once its token no longer matches. The retry budget is a separate count: the scripts cap on the
token, the native module on a field of its own, failures. Pick a transition on the
right to read the state it writes.
The state field · pick a transition
state — the lifecycle, in one field
The state field holds where the job is, and nothing else holds it. There is no separate
status table, no set membership that has to agree with a flag: the one field is the answer, and every transition
rewrites it in the same atomic step that moves the job between sets — on either executor, with the same
values.
@enqueue / EMQ.ENQUEUE write "pending";
@schedule / EMQ.SCHEDULE write "scheduled";
@claim2 / EMQ.CLAIM write "active"; a
failure (@retry / EMQ.FAIL) writes
"scheduled" below the cap and "dead" at it;
@delay / EMQ.DELAY write "scheduled";
@promote / EMQ.PROMOTE return a due job to
"pending"; lease recovery returns an expired lease to "pending"
— @reap always, the stall sweep and EMQ.STALLED.CHECK below
max_stalled, dead-lettering at it; the holder's own release,
EMQ.REQUEUE, which only the native module has, returns a held job to
"pending"; @reprocess2 / EMQ.REPROCESS
return a "dead" job to "pending". Completion writes no state: it
deletes the row.
One field is the lifecycle position. Each transition rewrites it atomically on either executor, so the field and the job's real place never drift.
attempts — the fencing token
The attempts field is the worker's fencing token. Admission writes
"0"; the claim mints each new value. Beat one is the named handle:
EchoMQ.Jobs.claim/3 reads the queue's pause flag first —
paused?/2, a separate HGET emq:{q}:meta paused, not atomic with
the script — then runs @claim2 with KEYS = emq:{q}:pending,
emq:{q}:active, emq:{q}: (the base) and ARGV =
[lease_ms], and maps the reply [id, payload, att] to
{:ok, {id, payload, att}}.
# echo_mq — EchoMQ.Jobs: the live claim. It runs @claim2. def claim(conn, queue, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do # The pause flag is a separate HGET emq:{q}:meta paused round-trip, # read before the script and not atomic with it. if paused?(conn, queue) do :empty else # KEYS: pending, active, and the queue base emq:{q}: (a declared root). keys = [ Keyspace.queue_key(queue, "pending"), Keyspace.queue_key(queue, "active"), Keyspace.queue_key(queue, "") ] # ARGV: the lease length only; the clock is the server's. argv = [Integer.to_string(lease_ms)] case Connector.eval(conn, @claim2, keys, argv) do {:ok, []} -> :empty # att is the new attempts value: the worker's fencing token. {:ok, [id, payload, att]} -> {:ok, {id, payload, att}} other -> other end end end
Beat two is the script body. @claim2 pops the oldest pending id,
derives the row key from the declared base (KEYS[3] .. 'job:' .. id, so the row shares
the queue's hashtag and slot), and answers empty for a ghost (a popped id with no row), consuming it without
minting a skeleton; otherwise it increments attempts — the returned value is the
token — writes
state = "active", leases the id on the active set at now +
lease_ms with now from TIME, and returns the id, the
payload and the token.
-- the @claim2 script: lease the oldest pending job and mint its token -- Pop the oldest pending id; an empty set ends the claim. local popped = redis.call('ZPOPMIN', KEYS[1]) if #popped == 0 then return {} end local id = popped[1] -- The row key derives from the declared base KEYS[3] (emq:{q}:), so the -- row carries the queue's {q} hashtag and lands on the same slot. local jk = KEYS[3] .. 'job:' .. id -- A ghost (a popped id with no row) stays consumed; no skeleton row is minted. if redis.call('EXISTS', jk) == 0 then return {} end -- HINCRBY mints the token: the new attempts value fences this lease. local att = redis.call('HINCRBY', jk, 'attempts', 1) -- The lifecycle field moves to 'active' in the same atomic step. redis.call('HSET', jk, 'state', 'active') -- The lease is not a field: it is the active-set score, now + lease_ms, -- on the SERVER clock. local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) redis.call('ZADD', KEYS[2], now + tonumber(ARGV[1]), id) -- The reply: the id, the payload, and the token. return {id, redis.call('HGET', jk, 'payload'), att}
EMQ.CLAIM runs the same transition with the same keys and the same reply. It folds the
pause check in, checks the row and the counter before it pops, reads the server's cached clock instead of
calling TIME, and writes lease_epoch in the same
HSET as state. Natively, only EMQ.CLAIM
increments attempts; it predicts the HINCRBY result before its
first write and verifies it after.
So the token is the lease's generation. If a worker stalls past its lease, recovery returns the job to pending;
the next claim bumps attempts again, minting a new token, and the first worker's token
is now stale.
attempts is the fencing token: the claim mints it with
HINCRBY, and a new claim invalidates the old token.
The fencing check
Every later call the worker makes passes the token back — complete,
retry, extend_lock, delay — and
the transition compares it with the row before it writes anything. The head of
@complete:
-- the @complete script, its first lines: the token fence -- KEYS[2] is the row; ARGV[2] is the token the worker holds. local att = redis.call('HGET', KEYS[2], 'attempts') -- No attempts field: the row is gone, and the reply is 0. if not att then return 0 end -- A different value is a stale holder: refused before any write. if att ~= ARGV[2] then return redis.error_reply('EMQSTALE complete token mismatch') end -- ... the body continues: the active-set ZREM, the lane and flow -- branches, then DEL KEYS[2], and the row is retired.
A worker whose lease expired and was recovered, its job claimed again with attempts
bumped, holds a stale token and cannot complete a job another worker now holds.
EMQ.COMPLETE makes the same byte compare and refuses with the same
EMQSTALE complete token mismatch. Run the check against the sample row, whose current
token is 2.
complete with a token · the row's attempts is 2
The token is the row's attempts. A current token completes; a stale one is
refused EMQSTALE on either executor.
The retry budget, two ways
The scripted @retry caps on the token itself. After the token fence and the lane
bookkeeping it writes last_error, then
tonumber(att) >= tonumber(ARGV[4]) dead-letters. Every claim therefore spends budget,
including the claim that follows a recovered lease or a delay.
-- the @retry script: the token fence, then the cap (KEYS[4] is the row) local att = redis.call('HGET', KEYS[4], 'attempts') if not att then return redis.error_reply('EMQSTALE job gone') end if att ~= ARGV[2] then return redis.error_reply('EMQSTALE retry token mismatch') end local p = ARGV[6] -- ... the active-set ZREM and the lane bookkeeping, then the latest -- failure, written on every failure: redis.call('HSET', KEYS[4], 'last_error', ARGV[5]) -- THE CAP is the token itself, against max_attempts (ARGV[4]). -- Every claim raised att, so every claim spent budget. if tonumber(att) >= tonumber(ARGV[4]) then redis.call('HSET', KEYS[4], 'state', 'dead') redis.call('ZADD', KEYS[3], 0, ARGV[1]) redis.call('HINCRBY', p .. 'metrics:failed', 'count', 1) -- ... a flow child tells its parent here (the ARGV[7] branches). return 'dead' end -- Below the cap: 'scheduled', due at now + the backoff delay (ARGV[3]). local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) redis.call('HSET', KEYS[4], 'state', 'scheduled') redis.call('ZADD', KEYS[2], now + tonumber(ARGV[3]), ARGV[1]) return 'scheduled'
The native EMQ.FAIL counts its own verdicts in a separate field,
failures, and caps on that. Its verdict is a pure function,
decide_fail: the token compare on attempts, the lane and flow
refusals, the already-dead and not-active checks, then next_attempt(failures) against
max_attempts.
// trafikey — EMQ.FAIL's verdict: a pure function of what the handler has read. fn decide_fail(token: &[u8], max_attempts: i64, seen: &FailSeen) -> Step<FailRead, Fail> { // The token fence on attempts, compared as bytes, as @retry compares it. let Some(attempts) = &seen.attempts else { return Step::Need(FailRead::Attempts); }; let Some(attempts) = attempts else { return Step::Done(Fail::Gone); }; if attempts.as_slice() != token { return Step::Done(Fail::Stale); } // Lane and flow rows are refused: the native verbs serve flat jobs. let Some(group) = seen.group else { return Step::Need(FailRead::Group); }; if group { return Step::Done(Fail::Lane); } let Some(parent) = seen.parent else { return Step::Need(FailRead::Parent); }; if parent { return Step::Done(Fail::Flow); } // A repeated verdict on a dead row answers dead and writes nothing. let Some(dead) = seen.dead else { return Step::Need(FailRead::Dead); }; if dead { return Step::Done(Fail::AlreadyDead); } // The holder must still be in the active set. let Some(active) = seen.active else { return Step::Need(FailRead::Active); }; if !active { return Step::Done(Fail::NotActive); } // THE CAP: failures + 1 (absent counts as 0) against max_attempts. // attempts is never the cap, so a recovered lease, a delay or a // requeue between claims spends no budget. let Some(failures) = &seen.failures else { return Step::Need(FailRead::Failures); }; Step::Done(match next_attempt(failures.as_deref()) { Err(reply) => Fail::Counter(reply), Ok(f) if f >= max_attempts => Fail::Dead(f), Ok(f) => Fail::Retry(f), }) }
So recovered leases, delays and requeues spend none of the retry budget natively, and
attempts stays the token and nothing else. With no recovery, delay or requeue between
claims, failures equals attempts at every failure and the two
executors agree. Run the ledger: four fixed scenarios, each executor's row after every step, and the verdict.
The first three run with max_attempts 2 and max_stalled 3; the
restart runs with max_stalled 1, the scripted sweep's default.
The budget ledger · pick a scenario
| step | the scripts’ row | the native module’s row |
|---|---|---|
| 1 · enqueue | pending · attempts 0 born | pending · attempts 0 · failures — · lease_epoch — born |
| 2 · claim | active · attempts 1 token 1 | active · attempts 1 · failures — · lease_epoch A token 1 |
| 3 · the lease expires; the stall sweep runs | pending · attempts 1 · stalled 1 stalled 1 of 3 | pending · attempts 1 · failures — · lease_epoch A · stalled 1 stalled 1 of 3 |
| 4 · claim | active · attempts 2 · stalled 1 token 2 | active · attempts 2 · failures — · lease_epoch A · stalled 1 token 2 |
| 5 · fail | dead · attempts 2 · stalled 1 cap: attempts 2 of 2 | scheduled · attempts 2 · failures 1 · lease_epoch A · stalled 1 cap: failures 1 of 2 |
The scripts spend budget per claim; the native module spends it per failure. The two agree until a claim follows a recovered lease or a delay.
The restart exemption
EMQ.CLAIM, EMQ.EXTEND and EMQ.EXTENDS
stamp lease_epoch with the server's run_id, which the module
reads once when it loads. When a lease expires, EMQ.STALLED.CHECK recovers it
without counting a stall if its lease_epoch is missing or differs from
the current run_id: the expiry came from a server restart, not a stalled worker. The
scripted stall sweep counts every expiry. The server runs no sweep on its own: the scripted sweep runs when
EchoMQ.Stalled calls it, once through check/2 or on the beat of
its opt-in timer process; EMQ.STALLED.CHECK runs when a client calls it, and the native
module runs no timers.
EMQ.STALLED.CHECK applies the pure rule to each expired lease before it reads
stalled:
// trafikey — the restart exemption. EMQ.STALLED.CHECK applies it to each // expired lease before it reads or counts stalled. // Pure: an expired lease is restart-caused, and exempt from the stall count, // when its lease_epoch is missing or differs from the current run_id. pub(crate) fn exempt(lease_epoch: Option<&[u8]>, current: &[u8]) -> bool { // current: this process's run_id, read once at module load. The lease // carries the run_id CLAIM, EXTEND or EXTENDS stamped when they wrote it. lease_epoch != Some(current) }
The ledger's restart scenario runs it: with max_stalled 1, the scripted sweep
dead-letters a job whose worker never stalled, and EMQ.STALLED.CHECK recovers it
uncounted.
Carry control state in the entity's own fields, and give each counter one meaning: a token that fences, a budget that caps. Data Modeling & Memory (R7) teaches the move in Redis Patterns Applied; Patterns become protocol is the near side.
state is the position every transition rewrites; attempts is
the token @claim2 and EMQ.CLAIM mint with
HINCRBY and every holder's call compares; the native module adds
failures as the budget and lease_epoch as the lease's
epoch.
A restart is not a stall. The lease carries the epoch it was written in, and the native sweep counts only the expiries of leases its own process wrote.
Recap
state holds the job's lifecycle position, rewritten atomically by each transition on
either executor. attempts is the fencing token: the claim mints it, every later call
compares it, and a stale token is refused EMQSTALE. The scripted retry caps on
attempts; the native EMQ.FAIL caps on
failures, so recovered leases, delays and requeues spend no budget.
lease_epoch exempts a restart's expiries from the stall count.
Next in this module: The payload — the caller's opaque cargo, stored and returned verbatim on either executor.
References
Where the writes behind the lifecycle field and the fencing token are documented.
Sources
- Valkey — HINCRBY — the atomic increment that mints the fencing token.
- Valkey — HSET — the write each transition uses to set
state. - Valkey — TIME — the server clock the scripted claim leases on.
- Valkey — INFO — the
run_idthatlease_epochrecords. - Valkey — Documentation — the substrate of record.
Related in this course
- The record hash — the module hub.
- The hash and its fields — the previous dive.
- The payload — the next dive.
- The lifecycle — the four sets the state field tracks, in the Queue pillar.
- The native module — the module whose
EMQ.*commands run the core job transitions inside the server. - The Protocol — the chapter landing.