jonnifyechomq
EchoMQ / The Protocol / The record hash / The state and attempts

The record hash · dive 02 · the machinery

The state and attempts

One field says where the job is. One field fences the work.

Two of the record's fields carry the job's machinery. state is the lifecycle position — one field holds where the job is, and every transition rewrites it, on either executor, with the same values. attempts is the fencing token: the claim increments it and hands the new value to the worker, and every later call the worker makes is refused once its token no longer matches. The retry budget is a separate count: the scripts cap on the token, the native module on a field of its own, failures. Pick a transition on the right to read the state it writes.

The state field · pick a transition

pending active scheduled dead enqueue claim recover · requeue fail · delay fail · at cap promote reprocess completerow deleted
enqueue — scripts: @enqueue · native module: EMQ.ENQUEUE. Writes state "pending". attempts: written "0". A job is born pending, at score zero in the pending set; a delayed admission (@schedule, EMQ.SCHEDULE) is born "scheduled" instead.

state — the lifecycle, in one field

The state field holds where the job is, and nothing else holds it. There is no separate status table, no set membership that has to agree with a flag: the one field is the answer, and every transition rewrites it in the same atomic step that moves the job between sets — on either executor, with the same values.

@enqueue / EMQ.ENQUEUE write "pending"; @schedule / EMQ.SCHEDULE write "scheduled"; @claim2 / EMQ.CLAIM write "active"; a failure (@retry / EMQ.FAIL) writes "scheduled" below the cap and "dead" at it; @delay / EMQ.DELAY write "scheduled"; @promote / EMQ.PROMOTE return a due job to "pending"; lease recovery returns an expired lease to "pending" — @reap always, the stall sweep and EMQ.STALLED.CHECK below max_stalled, dead-lettering at it; the holder's own release, EMQ.REQUEUE, which only the native module has, returns a held job to "pending"; @reprocess2 / EMQ.REPROCESS return a "dead" job to "pending". Completion writes no state: it deletes the row.

One field is the lifecycle position. Each transition rewrites it atomically on either executor, so the field and the job's real place never drift.

attempts — the fencing token

The attempts field is the worker's fencing token. Admission writes "0"; the claim mints each new value. Beat one is the named handle: EchoMQ.Jobs.claim/3 reads the queue's pause flag first — paused?/2, a separate HGET emq:{q}:meta paused, not atomic with the script — then runs @claim2 with KEYS = emq:{q}:pending, emq:{q}:active, emq:{q}: (the base) and ARGV = [lease_ms], and maps the reply [id, payload, att] to {:ok, {id, payload, att}}.

# echo_mq — EchoMQ.Jobs: the live claim. It runs @claim2.
def claim(conn, queue, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do
  # The pause flag is a separate HGET emq:{q}:meta paused round-trip,
  # read before the script and not atomic with it.
  if paused?(conn, queue) do
    :empty
  else
    # KEYS: pending, active, and the queue base emq:{q}: (a declared root).
    keys = [
      Keyspace.queue_key(queue, "pending"),
      Keyspace.queue_key(queue, "active"),
      Keyspace.queue_key(queue, "")
    ]

    # ARGV: the lease length only; the clock is the server's.
    argv = [Integer.to_string(lease_ms)]

    case Connector.eval(conn, @claim2, keys, argv) do
      {:ok, []} -> :empty
      # att is the new attempts value: the worker's fencing token.
      {:ok, [id, payload, att]} -> {:ok, {id, payload, att}}
      other -> other
    end
  end
end

Beat two is the script body. @claim2 pops the oldest pending id, derives the row key from the declared base (KEYS[3] .. 'job:' .. id, so the row shares the queue's hashtag and slot), and answers empty for a ghost (a popped id with no row), consuming it without minting a skeleton; otherwise it increments attempts — the returned value is the token — writes state = "active", leases the id on the active set at now + lease_ms with now from TIME, and returns the id, the payload and the token.

-- the @claim2 script: lease the oldest pending job and mint its token
-- Pop the oldest pending id; an empty set ends the claim.
local popped = redis.call('ZPOPMIN', KEYS[1])
if #popped == 0 then return {} end
local id = popped[1]
-- The row key derives from the declared base KEYS[3] (emq:{q}:), so the
-- row carries the queue's {q} hashtag and lands on the same slot.
local jk = KEYS[3] .. 'job:' .. id
-- A ghost (a popped id with no row) stays consumed; no skeleton row is minted.
if redis.call('EXISTS', jk) == 0 then return {} end
-- HINCRBY mints the token: the new attempts value fences this lease.
local att = redis.call('HINCRBY', jk, 'attempts', 1)
-- The lifecycle field moves to 'active' in the same atomic step.
redis.call('HSET', jk, 'state', 'active')
-- The lease is not a field: it is the active-set score, now + lease_ms,
-- on the SERVER clock.
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)
-- The reply: the id, the payload, and the token.
return {id, redis.call('HGET', jk, 'payload'), att}

EMQ.CLAIM runs the same transition with the same keys and the same reply. It folds the pause check in, checks the row and the counter before it pops, reads the server's cached clock instead of calling TIME, and writes lease_epoch in the same HSET as state. Natively, only EMQ.CLAIM increments attempts; it predicts the HINCRBY result before its first write and verifies it after.

So the token is the lease's generation. If a worker stalls past its lease, recovery returns the job to pending; the next claim bumps attempts again, minting a new token, and the first worker's token is now stale.

attempts is the fencing token: the claim mints it with HINCRBY, and a new claim invalidates the old token.

The fencing check

Every later call the worker makes passes the token back — complete, retry, extend_lock, delay — and the transition compares it with the row before it writes anything. The head of @complete:

-- the @complete script, its first lines: the token fence
-- KEYS[2] is the row; ARGV[2] is the token the worker holds.
local att = redis.call('HGET', KEYS[2], 'attempts')
-- No attempts field: the row is gone, and the reply is 0.
if not att then return 0 end
-- A different value is a stale holder: refused before any write.
if att ~= ARGV[2] then
  return redis.error_reply('EMQSTALE complete token mismatch')
end
-- ... the body continues: the active-set ZREM, the lane and flow
-- branches, then DEL KEYS[2], and the row is retired.

A worker whose lease expired and was recovered, its job claimed again with attempts bumped, holds a stale token and cannot complete a job another worker now holds. EMQ.COMPLETE makes the same byte compare and refuses with the same EMQSTALE complete token mismatch. Run the check against the sample row, whose current token is 2.

complete with a token · the row's attempts is 2

complete(token=2) vs row attempts=2 → :ok. The current holder: its token equals the row. The row retires, on either executor.

The token is the row's attempts. A current token completes; a stale one is refused EMQSTALE on either executor.

The retry budget, two ways

The scripted @retry caps on the token itself. After the token fence and the lane bookkeeping it writes last_error, then tonumber(att) >= tonumber(ARGV[4]) dead-letters. Every claim therefore spends budget, including the claim that follows a recovered lease or a delay.

-- the @retry script: the token fence, then the cap (KEYS[4] is the row)
local att = redis.call('HGET', KEYS[4], 'attempts')
if not att then return redis.error_reply('EMQSTALE job gone') end
if att ~= ARGV[2] then
  return redis.error_reply('EMQSTALE retry token mismatch')
end
local p = ARGV[6]
-- ... the active-set ZREM and the lane bookkeeping, then the latest
-- failure, written on every failure:
redis.call('HSET', KEYS[4], 'last_error', ARGV[5])
-- THE CAP is the token itself, against max_attempts (ARGV[4]).
-- Every claim raised att, so every claim spent budget.
if tonumber(att) >= tonumber(ARGV[4]) then
  redis.call('HSET', KEYS[4], 'state', 'dead')
  redis.call('ZADD', KEYS[3], 0, ARGV[1])
  redis.call('HINCRBY', p .. 'metrics:failed', 'count', 1)
  -- ... a flow child tells its parent here (the ARGV[7] branches).
  return 'dead'
end
-- Below the cap: 'scheduled', due at now + the backoff delay (ARGV[3]).
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('HSET', KEYS[4], 'state', 'scheduled')
redis.call('ZADD', KEYS[2], now + tonumber(ARGV[3]), ARGV[1])
return 'scheduled'

The native EMQ.FAIL counts its own verdicts in a separate field, failures, and caps on that. Its verdict is a pure function, decide_fail: the token compare on attempts, the lane and flow refusals, the already-dead and not-active checks, then next_attempt(failures) against max_attempts.

// trafikey — EMQ.FAIL's verdict: a pure function of what the handler has read.
fn decide_fail(token: &[u8], max_attempts: i64, seen: &FailSeen) -> Step<FailRead, Fail> {
    // The token fence on attempts, compared as bytes, as @retry compares it.
    let Some(attempts) = &seen.attempts else {
        return Step::Need(FailRead::Attempts);
    };
    let Some(attempts) = attempts else {
        return Step::Done(Fail::Gone);
    };
    if attempts.as_slice() != token {
        return Step::Done(Fail::Stale);
    }
    // Lane and flow rows are refused: the native verbs serve flat jobs.
    let Some(group) = seen.group else {
        return Step::Need(FailRead::Group);
    };
    if group {
        return Step::Done(Fail::Lane);
    }
    let Some(parent) = seen.parent else {
        return Step::Need(FailRead::Parent);
    };
    if parent {
        return Step::Done(Fail::Flow);
    }
    // A repeated verdict on a dead row answers dead and writes nothing.
    let Some(dead) = seen.dead else {
        return Step::Need(FailRead::Dead);
    };
    if dead {
        return Step::Done(Fail::AlreadyDead);
    }
    // The holder must still be in the active set.
    let Some(active) = seen.active else {
        return Step::Need(FailRead::Active);
    };
    if !active {
        return Step::Done(Fail::NotActive);
    }
    // THE CAP: failures + 1 (absent counts as 0) against max_attempts.
    // attempts is never the cap, so a recovered lease, a delay or a
    // requeue between claims spends no budget.
    let Some(failures) = &seen.failures else {
        return Step::Need(FailRead::Failures);
    };
    Step::Done(match next_attempt(failures.as_deref()) {
        Err(reply) => Fail::Counter(reply),
        Ok(f) if f >= max_attempts => Fail::Dead(f),
        Ok(f) => Fail::Retry(f),
    })
}

So recovered leases, delays and requeues spend none of the retry budget natively, and attempts stays the token and nothing else. With no recovery, delay or requeue between claims, failures equals attempts at every failure and the two executors agree. Run the ledger: four fixed scenarios, each executor's row after every step, and the verdict. The first three run with max_attempts 2 and max_stalled 3; the restart runs with max_stalled 1, the scripted sweep's default.

The budget ledger · pick a scenario

a recovered lease, then a failure · max_attempts 2 · max_stalled 3 · run_id A
stepthe scripts’ rowthe native module’s row
1 · enqueuepending · attempts 0
born
pending · attempts 0 · failures — · lease_epoch —
born
2 · claimactive · attempts 1
token 1
active · attempts 1 · failures — · lease_epoch A
token 1
3 · the lease expires; the stall sweep runspending · attempts 1 · stalled 1
stalled 1 of 3
pending · attempts 1 · failures — · lease_epoch A · stalled 1
stalled 1 of 3
4 · claimactive · attempts 2 · stalled 1
token 2
active · attempts 2 · failures — · lease_epoch A · stalled 1
token 2
5 · faildead · attempts 2 · stalled 1
cap: attempts 2 of 2
scheduled · attempts 2 · failures 1 · lease_epoch A · stalled 1
cap: failures 1 of 2
Scripts: dead. Native module: scheduled. They disagree. The claim after the recovery raised attempts, and the scripted cap is attempts; the native cap is failures, and there has been one failure.

The scripts spend budget per claim; the native module spends it per failure. The two agree until a claim follows a recovered lease or a delay.

The restart exemption

EMQ.CLAIM, EMQ.EXTEND and EMQ.EXTENDS stamp lease_epoch with the server's run_id, which the module reads once when it loads. When a lease expires, EMQ.STALLED.CHECK recovers it without counting a stall if its lease_epoch is missing or differs from the current run_id: the expiry came from a server restart, not a stalled worker. The scripted stall sweep counts every expiry. The server runs no sweep on its own: the scripted sweep runs when EchoMQ.Stalled calls it, once through check/2 or on the beat of its opt-in timer process; EMQ.STALLED.CHECK runs when a client calls it, and the native module runs no timers.

EMQ.STALLED.CHECK applies the pure rule to each expired lease before it reads stalled:

// trafikey — the restart exemption. EMQ.STALLED.CHECK applies it to each
// expired lease before it reads or counts stalled.
// Pure: an expired lease is restart-caused, and exempt from the stall count,
// when its lease_epoch is missing or differs from the current run_id.
pub(crate) fn exempt(lease_epoch: Option<&[u8]>, current: &[u8]) -> bool {
    // current: this process's run_id, read once at module load. The lease
    // carries the run_id CLAIM, EXTEND or EXTENDS stamped when they wrote it.
    lease_epoch != Some(current)
}

The ledger's restart scenario runs it: with max_stalled 1, the scripted sweep dead-letters a job whose worker never stalled, and EMQ.STALLED.CHECK recovers it uncounted.

the pattern (Redis Patterns Applied)

Carry control state in the entity's own fields, and give each counter one meaning: a token that fences, a budget that caps. Data Modeling & Memory (R7) teaches the move in Redis Patterns Applied; Patterns become protocol is the near side.

the implementation (echo_mq + trafikey)

state is the position every transition rewrites; attempts is the token @claim2 and EMQ.CLAIM mint with HINCRBY and every holder's call compares; the native module adds failures as the budget and lease_epoch as the lease's epoch.

A restart is not a stall. The lease carries the epoch it was written in, and the native sweep counts only the expiries of leases its own process wrote.

Recap

state holds the job's lifecycle position, rewritten atomically by each transition on either executor. attempts is the fencing token: the claim mints it, every later call compares it, and a stale token is refused EMQSTALE. The scripted retry caps on attempts; the native EMQ.FAIL caps on failures, so recovered leases, delays and requeues spend no budget. lease_epoch exempts a restart's expiries from the stall count.

Next in this module: The payload — the caller's opaque cargo, stored and returned verbatim on either executor.

References

Where the writes behind the lifecycle field and the fencing token are documented.

Sources

Related in this course