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 @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

move 1 pop: ZPOPMIN KEYS[1] → oldest id; no row → {} 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
Move 1 — pop the oldest
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

1700000000000
30000
row attempts (HINCRBY) 0 → 1 active score (ZADD) 1700000030000
claim JOB0KHTOWnGLuC · lease 30000ms
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

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)

@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

Related in this course