The lifecycle · dive 03
Completion & recovery
A claimed job leaves the active set four ways: three under the worker's token, one when a client sweeps.
Success retires the job (@complete); a failure parks it for a delayed
retry or, at max attempts, dead-letters it (@retry); a lapsed lease returns it to pending
on the server clock when a client calls the sweep (@reap); and a dead job can be
re-entered (reprocess_job/3). The thread through all of it is the fencing
token: only the holder of the current attempts value may retire, retry or
extend the job. Pick an exit on the right, then read the two beats, the recovery transitions and their native
twins.
How a claimed job leaves active · pick an exit
row state (row deleted) · metric metrics:completed +1
the attempts token matches: ZREM active, DEL the row, then the host DELs the row's :logs — a completed job leaves no row; a stale token is refused (EMQSTALE)
native twin: EMQ.COMPLETE — flat jobs only, and it refuses a holder whose id is no longer active
Complete, in two beats
Beat one — the handle. EchoMQ.Jobs @complete, run by
complete/5. For a flat job it sends two keys, the active set and the row, and three
arguments: the id, the attempts token the worker holds, and the queue base. A flow child
appends its parent's keys (the flows module). On success the host retires the row's
:logs subkey in a second, best-effort command.
# echo_mq — EchoMQ.Jobs # complete/5 is the handle. KEYS = [active, row]; ARGV = [id, token, base]. # Only the current token holder may retire the job. def complete(conn, queue, job_id, token, result \\ nil) do keys = [Keyspace.queue_key(queue, "active"), Keyspace.job_key(queue, job_id)] argv = [job_id, Integer.to_string(token), Keyspace.queue_key(queue, "")] # a flat job has no parent, so it passes the two keys above unchanged {keys, argv} = case parent_of(conn, queue, job_id) do nil -> {keys, argv} # … a flow child appends its parent's keys (the flows module) end case Connector.eval(conn, @complete, keys, argv) do {:ok, 1} -> # the row is gone; its :logs subkey is retired by a second, best-effort # command whose reply is not consulted _ = Connector.command(conn, ["DEL", Keyspace.job_key(queue, job_id) <> ":logs"]) :ok # the row was already gone {:ok, 0} -> {:error, :gone} # a non-current token holder {:error, {:server, "EMQSTALE" <> _}} -> {:error, :stale} other -> other end end
Beat two — the script body (the token fence).
@complete reads attempts, refuses a token mismatch, removes the
active member, retires the row and bumps the metric. The token check is what makes a redelivered or stale
completion a no-op. One line matters for the native comparison below: when the holder's id is no longer in active
— its lease lapsed and a sweep returned it to pending before anyone re-claimed it — the script still completes
it, and removes it from pending.
-- the @complete script — KEYS[1] = active, KEYS[2] = the row; ARGV[1] = id, ARGV[2] = token, ARGV[3] = base -- Read the row's attempts. If the row is gone, return 0 (the handle's :gone). local att = redis.call('HGET', KEYS[2], 'attempts') if not att then return 0 end -- The fence: the caller must present the CURRENT attempts token. A stale holder -- (an old claim whose job was swept back and re-claimed) presents an older -- number and is refused — its completion changes nothing. if att ~= ARGV[2] then return redis.error_reply('EMQSTALE complete token mismatch') end -- the queue base, "emq:{q}:" local p = ARGV[3] local g = redis.call('HGET', KEYS[2], 'group') -- Remove the active member; was_active is 1 if it was present (the normal case). local was_active = redis.call('ZREM', KEYS[1], ARGV[1]) if g then -- … a lane job: the lane accounting (the fair-lanes dive) else -- a flat job no longer active (swept back to pending) leaves pending too if was_active == 0 then redis.call('ZREM', p .. 'pending', ARGV[1]) end end -- … the cross-queue emit and the same-queue fan-in (the flows module) -- Retire the row and count the completion. The row is deleted — a completed job -- leaves no row behind; the metric is the durable trace. redis.call('DEL', KEYS[2]) redis.call('HINCRBY', p .. 'metrics:completed', 'count', 1) return 1
Completion is not "delete the job." It is: prove you hold the current token, then retire the active member and the row in one atomic step — so a stale worker waking up late cannot un-retire a job that was already recovered and re-run.
The recovery transitions
@retry parks or dead-letters, token-fenced.
EchoMQ.Jobs @retry, run by retry/7: KEYS
= active, schedule, dead and the row; ARGV = the id, the token,
delay_ms, max_attempts, the error and the base. At or past the
cap it dead-letters with last_error; below it, it parks the job on the schedule set at
now + delay_ms, with the delay the caller passes. The shipped
EchoMQ.Consumer passes its fixed retry_delay_ms (default
1_000); EchoMQ.Backoff paces only degraded beats (the Consumer's and the Metronome's),
not a job's retry.
-- the @retry script — KEYS[1] = active, KEYS[2] = schedule, KEYS[3] = dead, KEYS[4] = the row; -- ARGV[1] = id, ARGV[2] = token, ARGV[3] = delay_ms, ARGV[4] = max_attempts, ARGV[5] = error, ARGV[6] = base 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] local g = redis.call('HGET', KEYS[4], 'group') local was_active = redis.call('ZREM', KEYS[1], ARGV[1]) -- … the lane accounting and the flat not-active cleanup, as in @complete redis.call('HSET', KEYS[4], 'last_error', ARGV[5]) -- the cap compares attempts, the claim count, with max_attempts if tonumber(att) >= tonumber(ARGV[4]) then redis.call('HSET', KEYS[4], 'state', 'dead') -- into the morgue, at score 0 redis.call('ZADD', KEYS[3], 0, ARGV[1]) redis.call('HINCRBY', p .. 'metrics:failed', 'count', 1) -- … a flow child signals its parent here (the flows module) return 'dead' end -- below the cap: park on the schedule set at now + delay_ms, on the server clock 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'
@reap returns lapsed leases, on the server
clock. EchoMQ.Jobs @reap, run by reap/2 with
KEYS = active, pending and ARGV = the base. It range-queries
the active set for members whose lease score is at or before now — up to 100 a pass — and returns each to
pending, with no heartbeat and no worker liveness check. It runs only when a client process calls
reap/2: the Consumer's recovery step, whose default is :reap,
and the Metronome.
-- the @reap script — KEYS[1] = active, KEYS[2] = pending; ARGV[1] = base -- the queue base, "emq:{q}:" local p = ARGV[1] local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) -- Any active member scored at/below now has a LAPSED lease (the claimant is gone -- or stuck). The active-set score IS the deadline, so this is a range query. local exp = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, 100) for _, id in ipairs(exp) do redis.call('ZREM', KEYS[1], id) local jk = p .. 'job:' .. id local g = redis.call('HGET', jk, 'group') if g then -- … a lane job returns to its lane (the fair-lanes dive) else -- a flat job goes back to pending at score 0 redis.call('ZADD', KEYS[2], 0, id) end redis.call('HSET', jk, 'state', 'pending') end return #exp
With the :stalled recovery option the Consumer runs
EchoMQ.Stalled.check/3 instead: a counted sweep that dead-letters a job once its stall
count reaches max_stalled, taught in
Cancellation & checkpoints.
reprocess_job/3 re-enters a dead job — and refuses one that is not
dead. EchoMQ.Jobs @reprocess2 removes the id from dead (the
removal is the membership test), clears last_error, sets state
to pending, and adds the id to pending, or to its lane for a lane job.
attempts is kept, so the next claim's token continues from it.
# echo_mq — EchoMQ.Jobs # reprocess_job/3 moves a dead job back to pending. KEYS = the row, dead, # pending and the queue base the lane keys derive from; ARGV = [id]. def reprocess_job(conn, queue, job_id) do keys = [ Keyspace.job_key(queue, job_id), Keyspace.queue_key(queue, "dead"), Keyspace.queue_key(queue, "pending"), Keyspace.queue_key(queue, "") ] case Connector.eval(conn, @reprocess2, keys, [job_id]) do {:ok, 1} -> :ok # no row {:ok, -1} -> {:error, :gone} # EMQSTATE not dead: the id was not in the morgue, and nothing changed {:error, {:server, "EMQSTATE" <> _}} -> {:error, :not_dead} other -> other end end
The four exits share one structure: the active-set member is removed, the row is rewritten or deleted, and the id moves to the set its new state names. Recovery is not special-cased — it is the same set arithmetic, driven by a sweep a client calls instead of by the worker's own call.
The native twins
On a node that loads the trafikey module (wire 6), four of these transitions are also
native commands, with the scripted call's keys and arguments in the same order. Over the default wire 4,
complete/5, retry/7, reprocess_job/3
and Stalled.check/3 evaluate their scripts; over a wire: 6
connector they send these four commands, as a raw client can. Every check runs before the first write,
so a refusal writes nothing.
Complete.
EVALSHA <sha> 2 emq:{q}:active emq:{q}:job:<id> <id> <token> emq:{q}:
becomes EMQ.COMPLETE 2 emq:{q}:active emq:{q}:job:<id> <id> <token> emq:{q}:,
replying 1, 0 for a missing row, or
EMQSTALE complete token mismatch. It completes flat jobs only: a lane job answers
EMQSTATE complete lane not native, and a flow child or a flow layout
EMQSTATE complete flow not native. A holder whose id is no longer in active answers
EMQSTATE complete not active, where the script completes it. The
:logs subkey is deleted inside the command.
Fail.
EMQ.FAIL 4 emq:{q}:active emq:{q}:schedule emq:{q}:dead emq:{q}:job:<id> <id> <token> <delay_ms> <max_attempts> <error> emq:{q}:
takes @retry's keys and arguments and replies scheduled or
dead, with the script's two EMQSTALE refusals. Its cap counts a
row field of its own, failures, which only a failure increments; the script compares
attempts, the claim count, so a job re-claimed after a stall gets more retries
natively. It refuses a holder no longer in active (EMQSTATE fail not active) and lane and
flow jobs, and answers dead with no write for a job already dead.
Reprocess.
EMQ.REPROCESS 4 emq:{q}:job:<id> emq:{q}:dead emq:{q}:pending emq:{q}: <id>
takes @reprocess2's keys and replies 1,
-1 for a missing row, or EMQSTATE not dead. For a flat job the
replies and writes match the script, and attempts, failures and
stalled are kept; a lane job answers
EMQSTATE reprocess lane not native.
The stall sweep.
EMQ.STALLED.CHECK 3 emq:{q}:active emq:{q}:pending emq:{q}:dead emq:{q}: <max_stalled> <limit>
is the twin of Stalled.check/3, replying
[[recovered ids], [dead ids]]. A lease with no lease_epoch, or
one stamped by an earlier server run, is recovered without counting a stall, and a ghost is only removed from
active.
The reaper. reap/2 has no native twin: @reap
runs only as a script, and a wire: 6 connector refuses it with
{:wire6_unsupported, :reap}. The fence, the check order and the restart exemption are taught in
One transition, two executors.
Every transition here but the reaper has a native twin, and each twin refuses a lane job;
EMQ.COMPLETE and EMQ.FAIL also refuse a flow child and a holder no
longer active.
The token fence
The job's current attempts is 2 (it was claimed twice — its first
lease lapsed, a sweep returned it, and it was re-claimed). Present a token and read the verdict: the matching
token completes; any other is refused with {:error, :stale} (the
EMQSTALE wire class). The last choice keeps the current token but takes the id out of
active — a second lapse, swept back to pending before anyone re-claimed it — and there the two executors part.
This is the same check @complete, @retry, and the
lease-extension verb all share. The model is the real fence comparison over a fixed row — pure, no network.
Present a token · the row's attempts is 2
@complete → ZREM active, DEL row → :ok
the current holder retires the job; EMQ.COMPLETE replies 1 the same way
A stale token is exactly the scenario the lease creates: a worker's lease lapsed, a sweep returned
its job to pending, another worker re-claimed it and bumped attempts, and then the first
worker wakes up and tries to complete. Its token is now behind the row, so the fence refuses it — the job that was
recovered and re-run is not double-finished.
Pattern & implementation
A reliable queue confirms work atomically and recovers a crashed worker's job from a visibility timeout, so nothing is lost or double-run. The reliable queue and States as locations teach the move.
@complete token-fences the terminal; @retry parks or
dead-letters with last_error; @reap returns lapsed leases to
pending on the server clock when a client sweeps; reprocess_job/3 re-enters a dead
job. EMQ.COMPLETE, EMQ.FAIL,
EMQ.REPROCESS and EMQ.STALLED.CHECK are the native twins.
The pattern says: confirm atomically and recover from a timeout. The implementation makes the
attempts token the confirmation and the active-set score the timeout — so completion and
recovery are the same arithmetic seen from two clocks.
The durable floor
A completed row is deleted and a dead-lettered job keeps its last_error in the morgue,
in memory. Retained history elsewhere in EchoMQ does not have to stay resident: when an
EchoMQ.Stream declared to the archive driver is trimmed,
EchoStore.StreamArchive folds its trimmed segments into the durable
EchoStore.Graft floor — CubDB's append-only B-tree, and Tigris object storage when a
remote is configured — deep history without resident memory, readable beside the live tail. That floor is
taught in full in Echo Persistence.
Recap — four exits, one fence
A claimed job leaves the active set by @complete (token-fenced retirement),
@retry (schedule with the caller's delay, or dead at max attempts with
last_error), or @reap (a lapsed lease returns to pending on the
server clock when a client sweeps); a dead job re-enters via reprocess_job/3. All but the
reaper have native twins, which refuse a lane job; EMQ.COMPLETE and
EMQ.FAIL also refuse a flow child and a holder no longer active. The fencing token —
attempts — is what keeps a stale worker from double-finishing a recovered job. That
closes the lifecycle: enqueue, claim, complete, and recover, all four sets and one row.
Next in the Queue: Jobs, lanes & the consumer — the producer, fair claiming across identities, and the supervised loop that parks instead of polling.
References
Where the token-fenced terminal and the recovery transitions are grounded, and the commands the bodies use.
Sources
- Valkey — ZRANGEBYSCORE — the expired-lease range the reaper reads.
- Valkey — ZREM — the active-set removal every exit performs.
- Valkey — ZADD — the schedule / morgue / pending insertions the transitions write.
- Redis — EVALSHA — the load-once dispatch each transition runs by SHA.
Related in this course
- The lifecycle — the module this dive belongs to.
- Claim & the lease — the lease and the token this fence checks.
- The four sets — the sets a job moves between.
- One transition, two executors — the native fence, check order and restart exemption.
- Cancellation & checkpoints — the counted stall sweep, Stalled.check/3.
- redis-patterns · The reliable queue — confirm-and-recover, the pattern this implements.
- Echo Persistence — the durable floor a trimmed stream segment folds into.