The lifecycle · dive 03
Completion & recovery
A claimed job leaves the active set exactly four ways — and each is token-fenced.
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 (@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 or extend the job. Pick an exit
on the right, then read the two beats and the recovery transitions.
How a claimed job leaves active · pick an exit
Complete, in two beats
Beat one — the handle. complete/4 builds the active set and the row
key, passes the id and the token, and maps the reply. (The same script carries a fan-in hook for flows; a
non-flow completion runs the two-key call shown here.)
# echo_mq — EchoMQ.Jobs (non-flow completion shape) # complete/4 is the handle. KEYS = [active, row]; ARGV[1] is the id, ARGV[2] the # attempts token the worker holds, ARGV[3] the queue base. Only the current token # holder may retire the job — a stale token answers {:error, :stale}. keys = [Keyspace.queue_key(queue, "active"), Keyspace.job_key(queue, job_id)] argv = [job_id, Integer.to_string(token), Keyspace.queue_key(queue, "")] case Connector.eval(conn, @complete, keys, argv) do {:ok, 1} -> :ok {:ok, 0} -> {:error, :gone} # the row was already gone {:error, {:server, "EMQSTALE" <> _}} -> {:error, :stale} # a non-current token holder other -> other end
Beat two — the script body (the token fence). @complete reads
attempts, refuses a token mismatch, removes the active member, and — on the non-flow path
— retires the row and bumps the metric. The token check is what makes a redelivered or stale completion a no-op.
-- the @complete script — the token fence + the terminal (non-flow path) -- 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 that was reaped 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 local p = ARGV[3] -- the queue base, "emq:{q}:" -- 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]) -- 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. Below max attempts it
schedules with a literal delay (the backoff curve is computed above the wire); at max it
dead-letters with last_error.
-- the @retry script — record the error, then park or dead-letter (core path) -- att is the row's attempts (read and token-checked above); p is the queue base. local p = ARGV[6] redis.call('HSET', KEYS[4], 'last_error', ARGV[5]) if tonumber(att) >= tonumber(ARGV[4]) then -- at or past max attempts redis.call('HSET', KEYS[4], 'state', 'dead') redis.call('ZADD', KEYS[3], 0, ARGV[1]) -- into the morgue redis.call('HINCRBY', p .. 'metrics:failed', 'count', 1) return 'dead' end 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]) -- park with a literal delay return 'scheduled'
@reap is crash recovery on the server clock. It range-queries the
active set for members whose lease score is in the past and returns each to pending — no heartbeat, no worker
liveness check. The active-set score IS the deadline, so "which leases lapsed" is a range query.
-- the @reap script — return expired leases to pending (server-clock recovery) local p = ARGV[1] -- the queue base, "emq:{q}:" 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) -- out of active redis.call('ZADD', KEYS[2], 0, id) -- back to pending (non-grouped path) redis.call('HSET', p .. 'job:' .. id, 'state', 'pending') end return #exp
reprocess_job/3 re-enters a dead job — and refuses one that is not
dead. A dead job is the only finished-and-retained state, so this is the "retry a failed job" surface.
# echo_mq — EchoMQ.Jobs # reprocess_job/3 moves a dead job back to pending: clear last_error, set # state = pending, ZADD pending. The script refuses a job NOT in the morgue with # {:error, :not_dead}, and a missing job with {:error, :gone}. case Connector.eval(conn, @reprocess, keys, [job_id]) do {:ok, 1} -> :ok {:ok, -1} -> {:error, :gone} {:error, {:server, "EMQSTATE" <> _}} -> {:error, :not_dead} other -> other 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 the server clock instead of a worker call.
The token fence
The job's current attempts is 2 (it was claimed twice — reaped once,
re-claimed). Present a token and read the verdict: the matching token completes; any other is refused with
{:error, :stale} (the EMQSTALE wire class). 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
A stale token is exactly the scenario the lease creates: a worker was reaped (its job returned to
pending and re-claimed, bumping attempts), then 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; reprocess_job/3 re-enters a dead job.
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.
Recap — four exits, one fence
A claimed job leaves the active set by @complete (token-fenced retirement),
@retry (schedule with a literal delay, or dead at max attempts with
last_error), or @reap (a lapsed lease returns to pending on the
server clock); a dead job re-enters via reprocess_job/3. 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.
- The Lua layer — the scripts the transitions are.
- redis-patterns · The reliable queue — confirm-and-recover, the pattern this implements.