jonnifyechomq

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

active the claimed job done @complete · row DEL schedule @retry · delay dead @retry · max pending @reap · lapsed
@complete · active → done (row deleted)
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

row attempts (the fence) 2 emq:{q}:job:<id> presented token 2 id in active
present token 2 vs row attempts 2 → equal · id in active
@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

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 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

Related in this course