The lifecycle · dive 02
Claim & the lease
One script pops the oldest job, stamps it with an expiry, and hands the worker a token.
To claim a job is to run @claim2: pop the oldest pending id, skip it if
its row is gone, increment attempts, set the row active, and add
the id to the active set at now + lease. Two facts carry the rest of the lifecycle and
need no lock service: the active-set score IS the lease deadline, and
attempts IS the fencing token. Step the four moves on the right, then
read the two beats and the native twin.
The @claim2 transition · step the moves
local popped = redis.call('ZPOPMIN', KEYS[1]) ; local jk = KEYS[3] .. 'job:' .. id ; if redis.call('EXISTS', jk) == 0 then return {} end
ZPOPMIN removes the lexically-smallest member — the mint-oldest id — before any other claimer can pop it; an empty pop, or a popped id whose row is gone (a ghost), returns {} (:empty), and the ghost stays consumed.
The claim transition, in two beats
Beat one — the handle. EchoMQ.Jobs @claim2, run by
claim/3. It reads the queue-wide pause flag first, host-side, in its own round trip
(paused?/2 sends HGET emq:{q}:meta paused); then it evaluates
@claim2 with KEYS = the pending set, the active set and the
queue base, and ARGV = [lease_ms], and maps the reply to
:empty or {:ok, {id, payload, att}}. A paused queue answers
:empty and leaves the pending set untouched.
# echo_mq — EchoMQ.Jobs # claim/3 is the handle. The pause flag is read first, host-side, in its own # round trip: a paused queue answers :empty and the pending set is not touched. def claim(conn, queue, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do if paused?(conn, queue) do :empty else # three declared keys: the pending set, the active set, and the queue base # emq:{q}: that the script derives the row key from keys = [ Keyspace.queue_key(queue, "pending"), Keyspace.queue_key(queue, "active"), Keyspace.queue_key(queue, "") ] # one argument: the lease window in milliseconds argv = [Integer.to_string(lease_ms)] case Connector.eval(conn, @claim2, keys, argv) do # pending was empty, or the popped id was a ghost {:ok, []} -> :empty # the job, its payload, and its fencing token {:ok, [id, payload, att]} -> {:ok, {id, payload, att}} other -> other end end end
Beat two — the script body. @claim2 is
the transition. KEYS[1] is the pending set, KEYS[2] the active
set, KEYS[3] the queue base the row key is derived from; ARGV[1]
is the lease window. The empty pop and the ghost guard both return an empty table, which the handle reads as
:empty.
-- the @claim2 script — KEYS[1] = pending, KEYS[2] = active, KEYS[3] = the queue base; ARGV[1] = lease_ms -- Pop the oldest pending id. The pending set is scored at 0, so ZPOPMIN returns -- the lexically-smallest member — and because the member is the branded id, -- lexically-smallest is mint-oldest. An empty pop returns {} (the handle's :empty). local popped = redis.call('ZPOPMIN', KEYS[1]) if #popped == 0 then return {} end local id = popped[1] -- The row key is derived from the declared root KEYS[3], so it shares the -- queue's {q} slot. local jk = KEYS[3] .. 'job:' .. id -- The ghost guard: a popped id with no row is already consumed by the pop; -- answer {} and write nothing, so no skeleton row is minted. if redis.call('EXISTS', jk) == 0 then return {} end -- Increment attempts and KEEP the new value: this number IS the fencing token. -- The worker that holds it is the current claimant; a later complete or extend -- must present this exact number or be refused as stale. local att = redis.call('HINCRBY', jk, 'attempts', 1) redis.call('HSET', jk, 'state', 'active') -- The SERVER clock, not the caller's. now + lease is the lease DEADLINE, and it -- is stored as the active-set SCORE — so the active set is, by construction, an -- index of leases ordered by expiry. Recovery is a range query over it. local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) redis.call('ZADD', KEYS[2], now + tonumber(ARGV[1]), id) -- Return the id, the payload, and the fencing token to the worker. return {id, redis.call('HGET', jk, 'payload'), att}
No part of this can interleave with another claim: ZPOPMIN
removes the id before any other claimer can pop it, and the whole script runs as one server-side step.
Two workers cannot claim the same job. A ghost — an id in pending whose row is gone — is
consumed by the pop and answers empty, so the next claim serves the real head and no row is minted for it. The
two outputs that matter most are not the payload — they are the lease deadline written into the active set, and
the token handed back to the worker.
A claim is not "take a job." It is: pop it, lease it on the server clock, and fence it with a token — all in one indivisible step, so the active set records when the lease ends and the worker holds the only token that can finish it.
The same claim, native
On a node that loads the trafikey module (wire 6), claim/3 through a
wire: 6 connector, or any raw client, sends the same keys and the same lease to a
native command in place of the script:
EVALSHA <sha> 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms> becomes
EMQ.CLAIM 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>. The reply has
the same shape, [<id>, <payload>, <att>], or an empty array when the
queue is paused, pending is empty, or the head is a ghost (still popped). A caller can tell the two apart in
three places:
The pause is folded in. The command reads paused on
emq:{q}:meta inside the same atomic step, so no round trip separates the pause check
from the pop.
Checks before any write. It peeks the head, types the row and the active set, and predicts
the attempts increment before it pops; a refusal (a malformed layout or lease, a
wrong-typed key, a non-integer attempts) writes nothing.
The lease carries the server epoch. Beside state active it writes
lease_epoch, the server's run_id. The lease is still the
active-set score and attempts is still the token; only the counted stall sweep reads
the stamp, to exempt a lease from before a server restart.
claim/3 evaluates @claim2 over the default wire 4 and sends
EMQ.CLAIM over a wire: 6 connector, in both cases after its
own host-side paused?/2 read. One
transition, two executors reads the handler line by line.
Same keys, same score, and on the ordinary path the same reply: the native claim moves every check ahead of the pop and adds one field to the row.
The lease and the token
Pick a "now" and a lease window and read what the claim writes: the active-set score
(now + lease) that a sweep later compares against, when a client calls one, and the new
attempts value the worker must present to complete. Then see why a second claimer,
racing the first, finds the pending set already popped. The model is the real @claim2
arithmetic over a fixed job — pure, no network.
Claim arithmetic · lease deadline + fencing token
HINCRBY attempts → token = 1 · ZADD active score = now + lease = 1700000030000
worker receives {:ok, {JOB0KHTOWnGLuC, payload, 1}}
a second claimer racing this one finds ZPOPMIN already removed the id → :empty — two workers cannot hold the same job
EMQ.CLAIM writes the same score and token, and stamps lease_epoch on the row
The lease deadline is computed from the server's TIME, not
the worker's clock — so the reaper and the claimer always agree on what "expired" means; EMQ.CLAIM
reads the server's cached command time, the same value TIME reports. The fencing token is
the post-increment attempts: a worker whose lease lapsed, and whose job a sweep returned
to pending and another worker re-claimed, holds an older number, and its completion is refused.
Pattern & implementation
A reliable claim atomically moves a job out of the wait list and stamps it with an expiry, so a dead worker's job can be recovered. The reliable queue teaches the claim-with-visibility-timeout; Reliable Queues is the chapter.
@claim2 pops, skips a ghost, fences (attempts), and leases
(the active-set score) in one atomic script; claim/3 gates on
paused?/2 first and returns the fencing token to the worker;
EMQ.CLAIM runs the same transition as one native command.
The pattern says: claim atomically and stamp an expiry. The implementation makes the active-set score
the expiry and the attempts the token — no lock string, no separate timeout map.
Recap — pop, lease, fence
The claim is one atomic script: ZPOPMIN the oldest pending id, skip a ghost,
HINCRBY attempts to mint the fencing token, set the row active,
and ZADD the id to the active set at the server-clock lease deadline.
claim/3 honors paused?/2 first; on wire 6 the same transition is
EMQ.CLAIM, with the pause folded in and the server epoch stamped. The next dive reads how
the job leaves the active set: the token-fenced completion, and the recovery transitions when it does not.
Next in the lifecycle: Completion & recovery — the token fence in
@complete, the schedule-or-dead fork in @retry, and the
lapsed-lease reaper.
References
Where the atomic claim, the lease, and the fencing token are grounded, and the commands the body uses.
Sources
- Valkey — ZPOPMIN — the atomic pop of the oldest pending member the claim begins with.
- Valkey — ZADD — the active-set insertion that scores the lease deadline.
- Valkey — HINCRBY — the attempts increment that mints the fencing token.
- Redis — EVALSHA — the load-once dispatch the handle runs @claim2 by.
Related in this course
- The lifecycle — the module this dive belongs to.
- The four sets — the active set the lease score lives in.
- Completion & recovery — where the token is checked.
- One transition, two executors — the claim as a script and as EMQ.CLAIM, line by line.
- redis-patterns · The reliable queue — the claim-with-timeout pattern.