jonnifyechomq

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

move 1 pop: ZPOPMIN KEYS[1] → oldest pending id (or {}) move 2 token: HINCRBY attempts 1 → the fencing token move 3 state: HSET state active move 4 lease: ZADD KEYS[2] (now + lease) id → deadline one atomic step — two claimers cannot pop the same id

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

1700000000000
30000
row attempts (HINCRBY) · active score (ZADD) ·

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

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq)

@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

Related in this course