jonnifyechomq

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

active the claimed job done @complete · row DEL schedule @retry · delay dead @retry · max pending @reap · lapsed

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

row attempts (the fence) 2 presented token 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

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq)

@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

Related in this course