jonnifyechomq

The native module · dive 03

One transition, two executors

The claim, popped and leased twice: once by a script, once by a handler, to the same end state plus one field.

Two executors ran the claim, and one is retired. Scripted, it was EchoMQ.Jobs.claim/3 running a Lua @claim2 after a separate host-side pause read; that script is gone, and the connector sends no Lua. Today @claim2 declares EMQ.CLAIM, and natively the claim is EMQ.CLAIM 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>. Same keys, same reply [id, payload, attempts], same end state — plus one field. The handler folds the pause read into the command, checks everything before it writes, takes the server's time from the cached clock, predicts and verifies the attempts increment, stamps lease_epoch with the server's run_id, and opens every derived key as the caller. The stamp buys a restart exemption in the counted stall sweep. Step both lanes on the right, the retired script beside the native verb, then read the two bodies.

One claim, two lanes · the same starting queue on each executor's node

retired — claim/3, then the @claim2 Lua script (history) pause EVALSHA ZPOPMIN EXISTS HINCRBY HSET TIME ZADD HGET native — EMQ.CLAIM on the module's node shape fence pause peek TYPE predict clock ZPOPMIN HINCRBY HSET ZADD HGET step 0 — both nodes hold the starting queue; press Step
step 0 of 12 — the starting queue on both nodes
retired script: pending [JOB0KHTOWnGLuC, JOB0KHTOWnGLuD] · active [] · job:JOB0KHTOWnGLuC {state pending, attempts 0, payload order-7}
native node: pending [JOB0KHTOWnGLuC, JOB0KHTOWnGLuD] · active [] · job:JOB0KHTOWnGLuC {state pending, attempts 0, payload order-7}
fixed: lease 30000 · now 1760345600000 · run_id 3f2c0d8e…f70812 · meta not paused

The claim, as the retired script

Beat one — the handle, which is current code. EchoMQ.Jobs @claim2, now Script.native(:claim2, "EMQ.CLAIM"), sent by claim/3 with KEYS = pending, active, the base and ARGV = [lease_ms]. The pause flag is read first, host-side, in its own round trip.

# echo_mq — EchoMQ.Jobs
# The pause flag is read first, host-side, in its own round trip (paused?/2).
# Then the claim is sent under three declared keys: the pending set, the active
# set, and the base root the row key is derived from. @claim2 sends EMQ.CLAIM.
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"),
      Keyspace.queue_key(queue, "")
    ]

    argv = [Integer.to_string(lease_ms)]

    case Connector.eval(conn, @claim2, keys, argv) do
      {:ok, []} -> :empty
      {:ok, [id, payload, att]} -> {:ok, {id, payload, att}}
      other -> other
    end
  end
end
# echo_mq — EchoMQ.Jobs
# Any non-nil reply means paused, a WRONGTYPE error reply included;
# only a wire fault reads as "not paused".
def paused?(conn, queue) do
  case Connector.command(conn, ["HGET", Keyspace.queue_key(queue, "meta"), "paused"]) do
    {:ok, nil} -> false
    {:ok, _} -> true
    _ -> false
  end
end

Beat two — the script body. The pop is the first line; every check comes after it.

-- the retired @claim2 Lua script (history) — KEYS[1] = pending, KEYS[2] = active, KEYS[3] = the base; ARGV[1] = lease_ms
-- pop first: the oldest pending id leaves the set before anything is checked
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 hashtag
local jk = KEYS[3] .. 'job:' .. id
-- a ghost (no row) is already consumed by the pop; answer empty, mint nothing
if redis.call('EXISTS', jk) == 0 then return {} end
-- the fencing token: attempts goes up by one and comes back as att
local att = redis.call('HINCRBY', jk, 'attempts', 1)
redis.call('HSET', jk, 'state', 'active')
-- the lease is a score on the active set, on the server clock: TIME in milliseconds
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 {id, redis.call('HGET', jk, 'payload'), att}

Pop, then check: the script trusts the pop and repairs nothing, because inside one script nothing else can run between its lines.

The claim, native

Beat three — the handler. EMQ.CLAIM 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>, arity 6; KEYS[3] is the base and there is one ARGV. Read it top to bottom: every read and every check comes before the write guard, and each write is verified against what the reads predicted.

// trafikey — EMQ.CLAIM, the verb EchoMQ.Jobs @claim2 declares, paused?/2 folded in
/// G3 `EMQ.CLAIM 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>`.
pub(crate) fn claim(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult {
    // shape: numkeys exactly 3, exactly one ARGV
    let s = host::shape("EMQ.CLAIM", &args, 3)?;
    s.expect_argc(1)?;
    // layout: KEYS[3] is the base emq:{q}: with a non-empty q; KEYS[1], KEYS[2] are its pending and active
    let q = claim_queue(&s).ok_or_else(|| s.layout_error())?;
    // lease_ms: ASCII digits, at least 1, within i64
    let lease = s.lease(1)?;
    // the fence, read as the caller; the checked Caller comes back for the derived keys
    let caller = host::fence(ctx)?;
    let (pending, active) = (s.key(1), s.key(2));
    let empty = || Ok(ValkeyValue::Array(Vec::new()));

    // Step 1: the pause read, folded into the command. Any non-nil `paused` answers empty,
    // `0` and the empty string included, as Jobs.paused?/2 reads it. A wrong-typed meta key
    // is refused with WRONGTYPE, where Jobs.paused?/2 takes that error reply as paused. meta
    // and the row are derived from the base, outside the key-spec, so each is opened as the
    // caller at its first access.
    let meta = q.meta();
    caller.open(&meta, Access::Read)?;
    if host::read(ctx, cmd::HGET, &[&meta, b"paused"])? != Reply::Null {
        return empty();
    }

    // Step 2, peeked: the member ZPOPMIN would pop (same order, same atomic command).
    let id = match host::read(ctx, cmd::ZRANGE, &[pending, b"0", b"0"])? {
        Reply::Array(head) => match head.into_iter().next() {
            None => return empty(),
            Some(Reply::Bulk(id)) => id,
            Some(_) => return Err(errors::emq(errors::HOST_REPLY)),
        },
        _ => return Err(errors::emq(errors::HOST_REPLY)),
    };
    // D5.5: `meta` and the row are derived from the base, outside the key-spec, so each is opened
    // as the caller at its first access; the row is re-opened for write before any write.
    let row = q.job(&id);
    caller.open(&row, Access::Read)?;

    // Step 3 checks, before the pop: a ghost (no row) is popped and answers empty; a real row
    // must be a hash whose `attempts` HINCRBY accepts, and active must take a ZADD.
    let ghost = match host::read(ctx, cmd::TYPE, &[&row])? {
        Reply::Bulk(t) if t == b"none" => true,
        Reply::Bulk(t) if t == b"hash" => false,
        Reply::Bulk(_) => return Err(ValkeyError::WrongType),
        _ => return Err(errors::emq(errors::HOST_REPLY)),
    };
    let mut planned = None;
    if !ghost {
        // the row is re-opened for write before any write
        caller.open(&row, Access::Write)?;
        let stored = match host::read(ctx, cmd::HGET, &[&row, b"attempts"])? {
            Reply::Null => None,
            Reply::Bulk(b) => Some(b),
            _ => return Err(errors::emq(errors::HOST_REPLY)),
        };
        // the increment is predicted with Valkey's own integer rules, before any write
        let att = next_attempt(stored.as_deref()).map_err(errors::emq)?;
        host::expect_type(ctx, active, b"zset")?;
        // the server's cached clock: the value TIME reports, read once; no TIME call
        let at = host::deadline(s.verb, host::now_ms()?, lease)?;
        // the server epoch (run_id), read once at load, travels with the lease
        planned = Some((att, at, epoch::current()?));
    }

    // every check passed: the write guard, then the writes
    let w = host::begin_writes(ctx)?;
    // the pop, verified against the peek
    match w.call(cmd::ZPOPMIN, &[pending])? {
        Reply::Array(popped) if popped.first() == Some(&Reply::Bulk(id.clone())) => {}
        _ => return Err(errors::emq(errors::HOST_REPLY)),
    }
    let Some((att, at, run_id)) = planned else {
        return empty();
    };
    // the increment, verified against the prediction
    match w.call(cmd::HINCRBY, &[&row, b"attempts", b"1"])? {
        Reply::Int(n) if n == att => {}
        _ => return Err(errors::emq(errors::HOST_REPLY)),
    }
    // the one extra field: the lease carries the server epoch
    w.call(
        cmd::HSET,
        &[&row, b"state", b"active", b"lease_epoch", run_id],
    )?;
    w.call(cmd::ZADD, &[active, at.to_string().as_bytes(), &id])?;
    let payload = match host::read(ctx, cmd::HGET, &[&row, b"payload"])? {
        Reply::Bulk(p) => ValkeyValue::StringBuffer(p),
        Reply::Null => ValkeyValue::Null,
        _ => return Err(errors::emq(errors::HOST_REPLY)),
    };
    // the same reply shape as the script: id, payload, attempts
    Ok(ValkeyValue::Array(vec![
        host::bulk(&id),
        payload,
        ValkeyValue::Integer(att),
    ]))
}
// trafikey — the two helpers the handler leans on: first, the clock
/// The server's millisecond time (G0.8): `server.ustime / 1000`, the value TIME reports and
/// frozen for the command, so it equals Lua's `t[1]*1000 + floor(t[2]/1000)`. Read it once.
pub fn now_ms() -> Result<i64, ValkeyError> {
    let micros = api(unsafe { raw::RedisModule_CachedMicroseconds })?;
    Ok(unsafe { micros() } / 1000)
}
// trafikey — and the counter
// what HINCRBY <row> attempts 1 replies, decided before any write; the sweep reuses it for stalled
pub(super) fn next_attempt(stored: Option<&[u8]>) -> Result<i64, &'static str> {
    match stored {
        None => Ok(1),
        Some(raw) => string2ll(raw)
            .ok_or(NOT_INTEGER)?
            .checked_add(1)
            .ok_or(OVERFLOW),
    }
}

Peek, predict, then pop and verify: the handler writes nothing it has not already read the answer to.

Six differences, all native

Same keys, same reply as the retired script. Six things differ, all on the native side:

1. Pause folded in. The retired script never read the flag: its caller reads emq:{q}:meta paused in a separate round trip, and claim/3 still does before it sends the verb. The handler reads it inside the command too. Any non-nil value answers empty; a wrong-typed meta key is refused with WRONGTYPE.

2. Checks before any write. The retired script popped first and checked after. The handler peeks the head with ZRANGE 0 0, types the row and the active set, predicts the increment, and only then pops — verifying that ZPOPMIN returned the peeked member and that HINCRBY returned the predicted value. A call a check refuses writes nothing; a write whose reply differs from the prediction is reported as ERR EMQ unsupported host reply, and the writes before it stay in place.

3. The cached clock. No TIME command: now_ms reads the server's cached microseconds, the value TIME reports, frozen for the command.

4. attempts predicted and verified. next_attempt applies Valkey's own integer parsing to the stored field; a non-integer or an overflow is refused before the first write.

5. lease_epoch stamped. The HSET writes state active and lease_epoch <run_id> together; the script wrote state alone. The lease itself stays a score on the active set, as it was.

6. Derived keys opened as the caller. meta and the row are outside the key-spec, so the handler opens each through the checked accessor before reading it, and the row again for write before the first write.

A row without payload replies [id, nil, attempts], as it did from the script: the script's HGET answered false, and a false in the reply table became a nil element. The harness did not compare that case.

Six differences and one new field: everything else the two claims write is byte for byte the same.

The restart exemption and the failures budget

The server epoch is Valkey's run_id, the forty-hex-character field of INFO server, generated once per process. The module reads it once at load, holds it in memory, and every lease write — EMQ.CLAIM, EMQ.EXTEND, EMQ.EXTENDS — stores it as the row field lease_epoch. Only the counted sweep reads it:

// trafikey — the one comparison the sweep makes on the stamp
/// Pure, Δ15: an expired lease is restart-caused (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 {
    lease_epoch != Some(current)
}

EMQ.STALLED.CHECK 3 emq:{q}:active emq:{q}:pending emq:{q}:dead emq:{q}: <max_stalled> <limit> selects the expired leases — ZRANGE active -inf now BYSCORE LIMIT 0 limit — and gives each member one verdict. A ghost (no row) is removed from active and appears in neither list. An exempt member, its lease_epoch missing or not this process's, goes back to pending with stalled unchanged. A counted member gets stalled incremented and goes back to pending below max_stalled, or to dead at or above it, with last_error stalled and one HINCRBY on metrics:failed. The reply is [[recovered ids], [dead ids]]. A row with a group field refuses the whole sweep with EMQSTATE stalled lane not native, and a refusal in the read pass writes nothing. Sweeps run only when a client calls them; the module runs no timer.

// trafikey — one member's verdict, and the writes each verdict plans
/// One selected member's verdict (G15 step 1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Swept {
    /// No row: consumed, in neither list (Δ20).
    Ghost,
    /// Restart-caused (Δ15): recovered, `stalled` unchanged.
    Exempt,
    /// Counted, below the threshold: recovered with `stalled` = the value.
    Recover(i64),
    /// Counted, at or over the threshold: dead-lettered with `stalled` = the value.
    Die(i64),
}
// …
/// Pure: one member's G15 step-1 verdict against the current `run_id` and `max_stalled`, or the
/// next read it needs. A ghost never reads `group`; an exempt member never reads `stalled`.
fn decide_member(
    run_id: &[u8],
    max_stalled: i64,
    seen: &MemberSeen,
) -> Step<MemberRead, Result<Swept, SweepRefusal>> {
    let Some(row_type) = &seen.row_type else {
        return Step::Need(MemberRead::Type);
    };
    match row_type.as_slice() {
        b"none" => return Step::Done(Ok(Swept::Ghost)),
        b"hash" => {}
        _ => return Step::Done(Err(SweepRefusal::WrongType)),
    }
    let Some(group) = seen.group else {
        return Step::Need(MemberRead::Group);
    };
    if group {
        return Step::Done(Err(SweepRefusal::Lane));
    }
    let Some(lease_epoch) = &seen.epoch else {
        return Step::Need(MemberRead::Epoch);
    };
    // the stamp: a missing or foreign lease_epoch is a restart's expiry, not a worker's stall
    if epoch::exempt(lease_epoch.as_deref(), run_id) {
        return Step::Done(Ok(Swept::Exempt));
    }
    let Some(stalled) = &seen.stalled else {
        return Step::Need(MemberRead::Stalled);
    };
    // the counted path: stalled + 1, predicted with the same integer rules as attempts
    Step::Done(match next_attempt(stalled.as_deref()) {
        Err(reply) => Err(SweepRefusal::Counter(reply)),
        Ok(st) if st >= max_stalled => Ok(Swept::Die(st)),
        Ok(st) => Ok(Swept::Recover(st)),
    })
}
// …
// the writes, per member in selection order: every member leaves active first
fn sweep_plan(q: &Queue<'_>, keys: [&[u8]; 3], members: &[(Vec<u8>, Swept)]) -> Vec<Op> {
    let [active, pending, dead] = keys;
    let metrics = q.key(b"metrics:failed");
    let mut plan = Vec::with_capacity(members.len() * 4);
    for (id, swept) in members {
        let row = q.job(id);
        plan.push(op(cmd::ZREM, &[active, id]));
        match swept {
            Swept::Ghost => {}
            Swept::Exempt => {
                plan.push(op(cmd::ZADD, &[pending, b"0", id]));
                plan.push(op(cmd::HSET, &[&row, b"state", b"pending"]));
            }
            Swept::Recover(st) => {
                plan.push(op(
                    cmd::HSET,
                    &[
                        &row,
                        b"stalled",
                        st.to_string().as_bytes(),
                        b"state",
                        b"pending",
                    ],
                ));
                plan.push(op(cmd::ZADD, &[pending, b"0", id]));
            }
            Swept::Die(st) => {
                plan.push(op(
                    cmd::HSET,
                    &[
                        &row,
                        b"stalled",
                        st.to_string().as_bytes(),
                        b"state",
                        b"dead",
                        b"last_error",
                        b"stalled",
                    ],
                ));
                plan.push(op(cmd::ZADD, &[dead, b"0", id]));
                plan.push(op(cmd::HINCRBY, &[&metrics, b"count", b"1"]));
            }
        }
    }
    plan
}

A server restart therefore recovers its in-flight leases without spending anyone's stall budget: a restart is not a worker stall. Run one sweep over a fixed active set — two counted members, a ghost, a live lease, and one job whose lease_epoch you choose.

EMQ.STALLED.CHECK · pick job C's lease_epoch and max_stalled

A · now−5000 recover(1) B · now−3000 die(2) C · now−1000 die(2) E · now−500 ghost D · now+20000 live reply [[A], [B, C]] · 12 writes · E consumed · D untouched
C's lease_epoch this process's, max_stalled 2 — reply [[JOB0KHTOWnGLuA], [JOB0KHTOWnGLuB, JOB0KHTOWnGLuC]]
selected: ZRANGE emq:{orders}:active -inf 1760345600000 BYSCORE LIMIT 0 100 → [A, B, C, E]; D's lease is live
verdicts: A no stalled, epoch equal → recover(1) · B stalled 1, epoch equal → die(2) · C stalled 1, epoch equal → die(2) · E no row → ghost
writes: ZREM active A; HSET job:A stalled 1 state pending; ZADD pending 0 A; ZREM active B; HSET job:B stalled 2 state dead last_error stalled; ZADD dead 0 B; HINCRBY metrics:failed count 1; ZREM active C; HSET job:C stalled 2 state dead last_error stalled; ZADD dead 0 C; HINCRBY metrics:failed count 1; ZREM active E

The failures budget is the other native field: EMQ.FAIL caps retries on failures, which it writes, while attempts stays the fencing token that only EMQ.CLAIM increments. A recovered lease or a requeue (EMQ.REQUEUE writes no counter at all) therefore spends none of the retry budget.

the pattern (Redis Patterns Applied)

The fencing token and the server-clock lease: a counter that only the acquirer increments, and an expiry on the server's clock, not the worker's. Distributed locking teaches both.

the implementation (echo_mq + trafikey)

attempts is the token, as it was in the script; EMQ.CLAIM adds lease_epoch so a restart's expiries are told apart from a worker's stalls, and EMQ.FAIL counts retries on failures instead.

A restart is not a stall: the epoch on the lease lets the sweep forgive what the process, not the worker, let expire.

How parity is kept — the method

Parity was kept by a differential harness while both executors ran. It ran each case twice on one scratch server, on two separate queues: the scripted side as the Elixir performed it — the host steps plus EVAL of the Lua body taken verbatim from jobs.ex and stalled.ex at a pinned revision, each body's digest checked before it was trusted — and the native side as EMQ.<VERB> numkeys KEYS… ARGV…. It compared the typed reply and every key under the queue's hashtag, with lease scores normalised to server time. The declared differences (lease_epoch, failures) were judged against their declaration, never against the Lua. Planted-defect controls — a mutated reply, edited Lua bodies posing as native, a tampered digest, a verbatim EMQ.CLAIM planted in the AOF — proved the instrument could fail. A run was evidence for the build it ran on, not acceptance. With the Lua retired, the scripts remain the reference semantics the verbs were held to.

Parity was measured, not assumed: the same case on both executors, every key compared, and an instrument shown to catch a planted defect, before the scripts were retired.

Recap — one transition, two executors

One transition, two executors, one of them retired: the claim pops, counts, leases and replies as the Lua script did, through the same keys, and today only EMQ.CLAIM runs it. The native handler checks before it writes, folds the pause in, uses the cached clock, verifies its increment, and stamps the server epoch on the lease — the field that lets the counted sweep forgive a restart. A differential harness kept the two in step until the scripts were retired. The module hub closes the module; the chapter closes in the workshop.

Back to the hub: The native module — the registration table, the fence and the three dives in one place; then the chapter's workshop.

References

Where the two claims and the sweep are grounded, and the engine behaviour they rely on.

Sources

Related in this course