jonnifyechomq

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

claim +90s deadline 30s now

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

one poison job stalled = 0 · claimed, not yet swept recover while stalled < max_stalled; dead-letter at or above

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

the pattern (Redis Patterns Applied)

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 implementation (echo_mq)

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

Related in this course