jonnifyechomq

Batches · dive 03

Batch lease extension

Re-score the many under one clock read. The lease is the active score, so checkpointing many is one script — and it returns the ones it could not extend.

A worker holding several jobs at once checkpoints all their leases together, on one clock. extend_locks/4 re-scores every matching active member under one server-clock read and answers the failed list — the ids whose token was stale or whose row was gone. The lease in this Queue is the active-set score, so extending it is re-scoring the active member; there is no separate lock. Step the batch on the right, then read the two beats and the contrast with the single verb below.

Extend four leases · step the members

job 1 JOB-a1 token 1 == attempts 1 → ZADD now+lease job 2 JOB-b2 token 2 == attempts 2 → ZADD now+lease job 3 JOB-c3 token 1 != attempts 2 → into failed job 4 JOB-d4 token 1 == attempts 1 → ZADD now+lease one TIME read shared by all four · returns failed = [JOB-c3]

Batch lease extension, in two beats

Read it the way the protocol is built: the handle (the Elixir verb that gates the ids and runs the script) and the body (the Lua that re-scores the members). The handle is above the line; the body is the protocol.

Beat one — the named handle. extend_locks/4 gates every id at Keyspace.job_key/2 first, flattens the {id, token} pairs into ARGV after the queue base root and the lease window, and runs @extend_locks declaring only the active set. It answers {:ok, failed} — the ids it could not extend.

# echo_mq — EchoMQ.Jobs
# extend_locks/4 re-scores MANY active members under ONE server-clock read.
# Gate every id first (an ill-formed id raises before the wire). Flatten the held
# {id, token} pairs into ARGV after the queue base root + the lease window. The
# declared key is [active]; each per-job key is derived IN-SCRIPT from the base
# root. Answers {:ok, failed} — the ids that could NOT be extended.
def extend_locks(conn, queue, held, lease_ms)
    when is_list(held) and is_integer(lease_ms) and lease_ms > 0 do
  Enum.each(held, fn {id, _token} -> Keyspace.job_key(queue, id) end)   # gate every id

  pairs = Enum.flat_map(held, fn {id, token} -> [id, Integer.to_string(token)] end)
  argv = [Keyspace.queue_key(queue, ""), Integer.to_string(lease_ms) | pairs]

  case Connector.eval(conn, @extend_locks, [Keyspace.queue_key(queue, "active")], argv) do
    {:ok, failed} when is_list(failed) -> {:ok, failed}                 # the ids not re-leased
    other -> other
  end
end

Beat two — the script body. @extend_locks reads the clock once, then walks the ARGV pairs (id, then token), re-scoring each matching member and collecting the rest. KEYS[1] is the active set; ARGV[1] is the queue base root, ARGV[2] the lease window, and ARGV[3..] the (id, token) pairs.

-- the @extend_locks script — one clock read, many members re-scored
-- KEYS[1] is the active set. ARGV[1] is the queue base root, ARGV[2] the lease
-- window in ms; ARGV[3..] is (id, token, id, token, …). The per-job key is
-- DERIVED in-script from the base root — never read out of a data value.
local base = ARGV[1]
local lease = tonumber(ARGV[2])
-- ONE server-clock read shared by every id in the batch.
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local failed = {}
local i = 3
while i < #ARGV do
  local id = ARGV[i]
  local token = ARGV[i + 1]
  local jk = base .. 'job:' .. id
  -- the fencing check: re-lease only if the row's attempts still matches the
  -- token the caller holds. A moved token (reaped and reclaimed) is NOT extended.
  local att = redis.call('HGET', jk, 'attempts')
  if att and att == token then
    redis.call('ZADD', KEYS[1], now + lease, id)   -- re-score the active member
  else
    table.insert(failed, id)                       -- stale token or gone row — collect it
  end
  i = i + 2
end
return failed

The per-job key is built in-script as base .. 'job:' .. id from the declared queue base root — never read out of a hash value. The single key the script declares is the active set; the active-set score is the lease, so re-scoring it is the whole extension. Every job whose token still matches is re-leased on the same now; every job whose token has moved on — it was reaped and reclaimed, so its attempts no longer matches — is reported in failed, not extended.

Batch lease extension is one script and one clock read: re-lease every matching active member at the shared now + lease, and return the ids whose token had moved on.

Single vs batch — collect, don't error

The single extend_lock/5 re-scores one member. It is token-fenced too, but on a stale token it errors ({:error, :stale}, the EMQSTALE wire class) and on a gone row it answers {:error, :gone}. The batch does not error on a single bad id — it collects it into failed, so one stale lease among many does not abort the others. Pick a job's fate and a verb to read the two answers; the model below is the real return shape of each.

extend_lock/5 vs extend_locks/4
 single extend_lock/5batch extend_locks/4
idsonemany
keys declared[active, job_row][active] — per-job key derived in-script
clock readsoneone, shared across all ids
a non-matching tokenerrors — {:error, :stale}collected — the id joins failed
a gone rowerrors — {:error, :gone}collected — the id joins failed
return:ok | {:error, …}{:ok, failed}

A job's fate, two verbs · read both answers

single extend_lock/5 — batch extend_locks/4 —

The single verb errors because there is nothing to collect — one id, one answer. The batch collects because it has many ids and one result: a stale lease becomes an entry in failed, and the rest are re-leased on the same clock.

Pattern & implementation

the pattern (Redis Patterns Applied)

A batch over a sorted set should read its reference point — the clock — once, so every member is scored against the same instant, not a drifting one. Reliable queues teaches the reliable-claim move; the one-clock-many-members angle is the near side of the door.

the implementation (echo_mq)

EchoMQ.Jobs.extend_locks/4 runs @extend_locks, which reads the server TIME once and re-scores every active member whose token matches, returning the failed list — where the single extend_lock/5 re-scores one member and errors on a stale token.

The pattern says: read the clock once, score the many against it. The implementation is extend_locks/4 — one TIME, many re-scores, and the failed list for the ones that moved on.

Recap — the many, under one clock

Batch lease extension re-scores the many under one clock read: gate every id, walk the held pairs, re-lease each matching active member at the shared now + lease, and return the ids that could not be extended. The single verb errors on a bad id; the batch collects it — one stale lease does not abort the rest. That closes the Batches module: admit, compose, and extend many in one flush.

Back to the module: Batches — the three bulk verbs and the one rule they share. The next module in The Queue covers the control plane beyond enqueue and claim.

References

Where the batch lease extension is grounded, and the commands the script uses.

Sources

Related in this course