Lifecycle controls · dive 02
Cancellation & checkpoints
The lease is a score; a checkpoint moves it; a cancel is a message a handler chooses to honour.
A claimed job is held by a worker for the length of its lease. Three controls act on that worker while it runs. Cancel it cooperatively — ask a long handler to stop its own work at a safe point, without a forced kill mid-transaction. Checkpoint its lease — re-score the active member to a fresh deadline so a long-but-alive handler is not reaped. Recover the ones that stalled — sweep for leases that lapsed without a checkpoint, recover them, and dead-letter the ones that keep stalling. Drag "now" on the right and checkpoint, then read the three below.
The lease timeline · drag now, then checkpoint
Cancellation is cooperative and worker-side
EchoMQ.Cancel is a host-side primitive with no wire identity. The token
is a plain make_ref/0; cancellation is a process message. new/0
mints a token. cancel/3 sends {:emq_cancel, token, reason} to a
pid's mailbox. check/1 is a non-blocking receive after 0 that
answers {:cancelled, reason} if a cancellation for this token is
waiting, else :ok. check!/1 raises
EchoMQ.Cancel.Cancelled for a checkpoint-style handler.
# echo_mq — EchoMQ.Cancel — a cooperative token, worker-side, no wire identity # new/0 mints a token: a unique reference that names one cancellation. def new, do: make_ref() # cancel/3 sends the cancel message to a pid's mailbox; reason is optional. # The handler picks it up at its next check/1 — nothing interrupts it here. def cancel(pid, token, reason \\ nil) when is_pid(pid) do send(pid, {:emq_cancel, token, reason}) :ok end # check/1 is O(1): a receive after 0. The ^token pin matches only THIS token, # so a handler catches its own cancellation and no one else's. def check(token) do receive do {:emq_cancel, ^token, reason} -> {:cancelled, reason} after 0 -> :ok end end # check!/1 raises EchoMQ.Cancel.Cancelled when cancelled, so a handler that # calls it at a safe point aborts cleanly there — else continues. def check!(token) do case check(token) do :ok -> :ok {:cancelled, reason} -> raise Cancelled, reason: reason end end
Cooperative means a handler that never checks completes normally — cancellation does not interrupt it. The
^token pin ensures a handler only catches its own cancellation. This is a local
primitive: a long handler calls check!/1 at safe points and aborts where it is asked to,
leaving no half-finished transaction. There is no key, no script, and no set involved — the cancel never touches
the wire.
A cooperative cancel is a message, not a kill. The handler decides where it is safe to stop; the token pin keeps the cancel its own.
extend_lock checkpoints the lease
The lease is the active-set score. extend_lock/5 runs
@extend_lock: read the row's attempts, fence the token (a stale
token answers EMQSTALE), read the server TIME, and
ZADD the active set at now + lease for this id. It re-scores the
member to a fresh deadline.
Beat one — the named handle. extend_lock/5 builds the active set and
the row, passes the id, token, and lease, and maps the return.
EchoMQ.Jobs @extend_lock — the handle
# echo_mq — EchoMQ.Jobs # extend_lock/5 re-scores the active member to a fresh deadline. KEYS = [the # active set, the job row]; ARGV = [id, the attempts token, the lease ms]. def extend_lock(conn, queue, job_id, token, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do keys = [Keyspace.queue_key(queue, "active"), Keyspace.job_key(queue, job_id)] argv = [job_id, Integer.to_string(token), Integer.to_string(lease_ms)] case Connector.eval(conn, @extend_lock, keys, argv) do {:ok, 1} -> :ok # the lease re-scored to now + lease {:ok, -1} -> {:error, :gone} # no such row {:error, {:server, "EMQSTALE" <> _}} -> {:error, :stale} # not the current holder other -> other end end
Beat two — the script body. @extend_lock is the token fence then the
server-clock re-score. KEYS[1] is the active set, KEYS[2] the
job row; ARGV[1] the id, ARGV[2] the token,
ARGV[3] the lease.
@extend_lock — the body
-- the @extend_lock script — re-score one lease, token-fenced, on the server clock -- The token fence: the row's attempts IS the fencing token. Read it first. local att = redis.call('HGET', KEYS[2], 'attempts') if not att then return -1 end -- no row: gone if att ~= ARGV[2] then return redis.error_reply('EMQSTALE extend token mismatch') -- a stale holder may not extend end -- The re-score: read the SERVER clock and set the active member to now + lease. -- The lease IS the active score, so there is no separate :lock string to refresh. local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) redis.call('ZADD', KEYS[1], now + tonumber(ARGV[3]), ARGV[1]) return 1
A long-but-alive handler calls extend_lock/5 periodically so its deadline always sits
ahead of now — so the reaper never sees it as expired. Because the lease is the active score, there is no separate
lock string to refresh; the checkpoint re-scores the one member that already represents the lease, and it is
token-fenced so only the current holder may extend. A worker that lost the lease (its token is stale) cannot
re-score it, so a redelivered job's old holder cannot keep a dead lease alive.
A checkpoint is a re-score, not a new lock. The lease is the active score; moving it ahead of now is all it takes to keep an alive handler from being reaped, and the token keeps it the holder's alone.
Stalled recovery is count-thresholded, on top of reap
The reaper (Jobs.reap/2) returns any expired-lease job from active to pending once, with
no count — crash recovery. EchoMQ.Stalled is the count-thresholded layer above it.
@sweep_stalled reads the expired members on the server clock, and for each one increments
a per-job stalled field. A job below the max_stalled threshold
(default 1) is recovered — back to pending, or to its lane if it is grouped. A job at or above the threshold is
dead-lettered.
Beat one — the named handle. check/3 runs one sweep and answers
{:ok, %{recovered: [id], dead: [id]}} — the ids returned to pending and the ids
dead-lettered this pass. job_stalled?/4 answers whether a job's
stalled field is present and positive.
EchoMQ.Stalled @sweep_stalled — the handle
# echo_mq — EchoMQ.Stalled — one sweep over the active set # KEYS = [active, pending, dead]; ARGV = [the queue base, max_stalled, limit]. def check(conn, queue, opts \\ []) do max_stalled = Keyword.get(opts, :max_stalled, 1) limit = Keyword.get(opts, :limit, 100) keys = [ Keyspace.queue_key(queue, "active"), Keyspace.queue_key(queue, "pending"), Keyspace.queue_key(queue, "dead") ] argv = [Keyspace.queue_key(queue, ""), Integer.to_string(max_stalled), Integer.to_string(limit)] case Connector.eval(conn, @sweep_stalled, keys, argv) do {:ok, [recovered, dead]} when is_list(recovered) and is_list(dead) -> {:ok, %{recovered: recovered, dead: dead}} other -> other end end
Beat two — the script body. @sweep_stalled reads the expired members,
and per id increments the stalled count, then forks on the threshold:
KEYS[1] active, KEYS[2] pending,
KEYS[3] dead. (The grouped-job branch — a stalled lane job rejoins its lane — is elided
for clarity; the flat path is shown.)
@sweep_stalled — the body (flat path)
-- the @sweep_stalled script — count stalls, recover or dead-letter local p = ARGV[1] -- the queue base root local maxst = tonumber(ARGV[2]) local lim = tonumber(ARGV[3]) local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) -- every active member whose lease has lapsed (score at or before now) local exp = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, lim) local recovered = {} local dead = {} for _, id in ipairs(exp) do redis.call('ZREM', KEYS[1], id) -- leave the active set local jk = p .. 'job:' .. id local st = redis.call('HINCRBY', jk, 'stalled', 1) -- count this stall if st >= maxst then -- at or above the threshold: dead-letter redis.call('HSET', jk, 'state', 'dead') redis.call('HSET', jk, 'last_error', 'stalled') redis.call('ZADD', KEYS[3], 0, id) redis.call('HINCRBY', p .. 'metrics:failed', 'count', 1) table.insert(dead, id) else -- below the threshold: recover to pending redis.call('ZADD', KEYS[2], 0, id) redis.call('HSET', jk, 'state', 'pending') table.insert(recovered, id) end end return {recovered, dead}
The stalled-sweep ladder · step the passes
So a worker that stalls once is recovered; a worker that keeps stalling on the same job — a poison job that hangs
every worker that picks it up — is not recovered forever. The count is the difference between a transient slow
handler and a job that should die. The sweep may run on its own opt-in periodic process, or be direct-driven by
check/3 for an operator's one-shot recovery.
Worked example. A worker claims a job with a 30-second lease, runs for 90 seconds, and
checkpoints with extend_lock/5 every 20 seconds. Its active deadline always sits ahead of
now, so neither the reaper nor the stalled sweep touches it. A second worker claims a different job and crashes
silently. Its lease lapses; the sweep increments its stalled field to 1, at the default
threshold, so it is dead-lettered with last_error = "stalled".
Recovery counts how many times a job has stalled before it gives up on it. One stall is a slow worker; repeated stalls on the same job is a poison job that earns the morgue.
Pattern & implementation
A reliable queue checkpoints a worker's lease and reclaims abandoned work on a recovery pass. Time, delay & priority and Reliable queues teach the lease-and-recover machinery over Redis.
The lease is the active score, extend_lock/5 re-scores it under a token fence, and
EchoMQ.Stalled is the count-thresholded sweep on top of the reaper — recover below a
threshold, dead-letter at it. The cancel is host-side, off the wire entirely.
The pattern says: checkpoint the lease, reclaim the abandoned, give up on the poison. The
implementation is extend_lock, the reaper, and the count-thresholded
Stalled sweep.
Recap — control over the worker in hand
The lease is a score; a checkpoint moves it (extend_lock/5, token-fenced on the server
clock); a cancel is a message a handler chooses to honour (EchoMQ.Cancel, off the wire);
recovery counts how many times a job has stalled before it dead-letters it (EchoMQ.Stalled,
on top of the reaper). Control over the worker in hand is three small, fenced moves. The next dive widens the lens
to the whole queue and one job's row — the operator's runbook.
Next in lifecycle controls: The operator plane —
EchoMQ.Admin pause / resume / drain / obliterate, and the per-job verbs on
EchoMQ.Jobs.
References
Where the lease checkpoint and the stalled sweep are grounded, and the commands their bodies use.
Sources
- Valkey — ZADD — the active-set re-score that is the lease checkpoint.
- Valkey — HINCRBY — the per-job
stalledcount the sweep increments. - Valkey — ZRANGEBYSCORE — the expired-lease read the sweep runs on the server clock.
- Redis — EVALSHA — the load-once dispatch the checkpoint and sweep run by SHA.
- Valkey — Documentation — the substrate of record EchoMQ is backed by.
Related in this course
- Lifecycle controls — the module this dive belongs to.
- The Queue — the state machine, the active set whose score is the lease.
- The Lua layer — how a checkpoint is one atomic, fenced script.
- The Protocol — the keyspace and the branded id the fence reads.
- redis-patterns · Time, delay & priority — the lease/recover family.