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 @claim: pop the oldest pending id, 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.
The @claim transition · step the moves
The claim transition, in two beats
Beat one — the handle. claim/3 checks the queue-wide pause flag first
(the separate-gate form that keeps @claim byte-unchanged), then builds the two keys the
script touches — the pending set and the active set — passes the row-key base and the lease window as
ARGV, 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. It honors the queue-wide pause flag FIRST (a paused # queue answers :empty and leaves the pending set unmutated), then runs @claim. # KEYS = [pending, active]; ARGV[1] the job-key base ("emq:{q}:job:"), # ARGV[2] the lease window in ms. The script's reply IS the claim result. def claim(conn, queue, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do if paused?(conn, queue) do :empty else keys = [Keyspace.queue_key(queue, "pending"), Keyspace.queue_key(queue, "active")] argv = [Keyspace.queue_key(queue, "job:"), Integer.to_string(lease_ms)] case Connector.eval(conn, @claim, keys, argv) do {:ok, []} -> :empty # pending was empty {:ok, [id, payload, att]} -> {:ok, {id, payload, att}} # the job + its fencing token other -> other end end end
Beat two — the script body. @claim is the transition.
KEYS[1] is the pending set, KEYS[2] the active set;
ARGV[1] is the job-key base, ARGV[2] the lease window. The
empty-pop branch returns an empty table, which the handle reads as :empty.
-- the @claim script — one atomic transition -- 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] local jk = ARGV[1] .. id -- 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[2]), 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. 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 lease and the token
Pick a "now" and a lease window and read what the claim writes: the active-set score
(now + lease) that the reaper will later compare against, 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 @claim arithmetic
over a fixed job — pure, no network.
Claim arithmetic · lease deadline + fencing token
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. The fencing token is the
post-increment attempts; a worker that was reaped and whose job was re-claimed holds an
older number, and its completion will be 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.
@claim pops, 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.
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, 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. 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 @claim 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.
- The Lua layer — the scripts the transitions are.
- redis-patterns · The reliable queue — the claim-with-timeout pattern.