jonnifyechomq

Jobs, lanes & the consumer · dive 01

Enqueue & claim

Admit a job with one idempotent script; take it with its mirror. The producer and the flat worker are two verbs over one wire.

To enqueue a job is to run the @enqueue script: it admits the job by kind, refuses a duplicate, writes the three-field row, and inserts the pending entry — in one indivisible step. To claim one is to run @claim2: pop the oldest pending id, skip it if its row is gone, lease it on the server clock, and hand it to a worker. Both transitions also run as native commands on a node that loads the trafikey module. Step the four enqueue moves on the right, then read the two beats and run the round trip below.

The @enqueue transition · step the moves

move 1 gate: ARGV[1] first 3 bytes == 'JOB' (else EMQKIND) move 2 idempotency: EXISTS KEYS[1] == 1 → return 0 move 3 row: HSET KEYS[1] state / attempts / payload move 4 pending: ZADD KEYS[2] 0 ARGV[1] → return 1 one atomic step — all four, or none
Move 1 — the kind gate
if string.sub(ARGV[1], 1, 3) ~= 'JOB' then return EMQKIND
the id must be JOB-namespaced; a non-JOB id is refused at the wire before any write.

The enqueue transition, in two beats

Read a transition the way the protocol is built: the handle (the Elixir verb that builds the keys and runs the script) and the body (the Lua that performs the transition). The handle is above the line; the body is the protocol.

Beat one — the named handle. EchoMQ.Jobs @enqueue is run by enqueue/5. It checks the queue name, builds the two keys the transition touches — the job row and the pending set — and evaluates the script through via, the dispatch module named in opts: EchoMQ.Connector by default, or a signature-compatible module such as EchoMQ.Pool. The keys are built host-side by EchoMQ.Keyspace; the values ride ARGV. The script's integer return becomes a verdict.

# echo_mq — EchoMQ.Jobs
# enqueue/5 is the handle. opts defaults to [], so a call without it routes
# the eval through EchoMQ.Connector.
def enqueue(conn, queue, job_id, payload, opts \\ [])
    when is_binary(job_id) and is_binary(payload) do
  # a queue name holding {, } or NUL raises here, before any key is built
  Keyspace.valid_queue!(queue)
  # :via names the dispatch module; it is carried, never inspected
  via = Keyword.get(opts, :via, Connector)
  # KEYS = [the row emq:{q}:job:<id>, the pending set emq:{q}:pending]; job_key/2
  # raises host-side unless the id is a well-formed 14-byte branded id
  keys = [Keyspace.job_key(queue, job_id), Keyspace.queue_key(queue, "pending")]

  # ARGV = [the id, the payload]; the reply becomes a verdict
  case via.eval(conn, @enqueue, keys, [job_id, payload]) do
    # 1: the row was new — written and made pending
    {:ok, 1} -> {:ok, :enqueued}
    # 0: a row already existed — nothing written
    {:ok, 0} -> {:ok, :duplicate}
    # EMQKIND: the id is not JOB-namespaced — refused before any write
    {:error, {:server, "EMQKIND" <> _}} -> {:error, :kind}
    other -> other
  end
end

Beat two — the script body. @enqueue is the protocol: the four moves the stepper above walks, in one atomic transition. KEYS[1] is the job row, KEYS[2] the pending set; ARGV[1] is the branded id, ARGV[2] the payload.

-- the @enqueue script — one atomic transition, every key declared
-- The kind gate: the id must be JOB-namespaced (first three bytes 'JOB'),
-- refused at the wire. The gate runs in Lua, so no speaker can admit a
-- job under another namespace — the rule lives below the language line.
if string.sub(ARGV[1], 1, 3) ~= 'JOB' then
  return redis.error_reply('EMQKIND job id must be JOB-namespaced')
end
-- Idempotency: a row that already exists is a no-op. Two enqueues of the same
-- id leave exactly one row and one pending entry — the second returns 0.
if redis.call('EXISTS', KEYS[1]) == 1 then
  return 0
end
-- The three-field row: state, attempts, payload — the shape every speaker reads.
-- attempts starts at '0' (the fencing token); state starts at 'pending'.
redis.call('HSET', KEYS[1], 'state', 'pending', 'attempts', '0', 'payload', ARGV[2])
-- The pending set: the id itself is the member at score 0, so byte order is
-- mint order and the queue carries no second index.
redis.call('ZADD', KEYS[2], 0, ARGV[1])
return 1

Every key the script touches arrives declared in KEYS; every value rides ARGV. The four moves are one transition: the row and the pending entry appear together or not at all. Two gates guard the id: job_key/2 checks the whole 14-byte branded form host-side, and the script checks the three-byte JOB namespace on the wire, for every speaker.

Enqueue is one idempotent script. Read it in two beats — the handle that builds the keys, then the body that is the transition — and admission becomes a single atomic act.

The native twin — EMQ.ENQUEUE. On a node that loads the trafikey module, where {emq}:version holds echomq:6.0.0, EchoMQ.Jobs.enqueue/5 through a wire: 6 connector, or any raw client, sends the same transition as one command: the same numkeys, keys and arguments, with the command name in place of the script's SHA.

# wire 4 — EchoMQ.Jobs.enqueue/5, through the connector
EVALSHA <sha> 2 emq:{q}:job:<id> emq:{q}:pending <id> <payload>
# wire 6 — EchoMQ.Jobs.enqueue/5, through a wire: 6 connector
EMQ.ENQUEUE 2 emq:{q}:job:<id> emq:{q}:pending <id> <payload>

On the ordinary path the replies are the script's: 1 for a new job, 0 for a duplicate with nothing written, and EMQKIND job id must be JOB-namespaced for an id whose first three bytes are not JOB — the same three-byte gate, not the full branded-id check. Every check runs before the first write, so a refusal writes nothing. The handler checks the shape, the key layout and the node's brand before the kind gate, and refuses a row key that is not the queue's job: key for that id with ERR EMQ.ENQUEUE keys do not match the wire-4 layout. Commands, not scripts reads the handler beside the script.

Run @enqueue — the verdicts

The handle and the script between them return one of four things. Enqueue a job, enqueue it again, try an id from another namespace and a malformed one — read who is admitted and why. The model below is the real logic over a fixed in-memory keyspace: job_key/2's branded-id check host-side, then the script's three-byte gate, EXISTS on the row, and the return of 1, 0 or an EMQKIND error.

Enqueue against a fresh queue · run the handle and the script

emq:{q}:pending (ZSET) JOB0KHTOWnGLuC rows written (HSET) JOB0KHTOWnGLuC
enqueue JOB0KHTOWnGLuC → script returns 1 → {:ok, :enqueued}
the row did not exist and the id is JOB-namespaced — HSET the row, ZADD pending, return 1

The pending set and the row move together: :enqueued writes both, :duplicate writes neither, a non-JOB id never reaches the row, and a malformed id never reaches the wire. That is the atomicity of one script — there is no state where the row exists but the pending entry does not.

The flat claim — pop, lease, return

Claiming is enqueue's mirror. Beat one — the named handle. EchoMQ.Jobs @claim2 is run by claim/3, which reads the queue-wide pause flag first, host-side, in its own round trip — a paused queue answers :empty with the pending set untouched — then evaluates the script under three declared keys: the pending set, the active set, and the queue base the row key is derived from.

# echo_mq — EchoMQ.Jobs
# claim/3 is the handle. The pause flag (field paused on emq:{q}:meta) is read
# first, in its own round trip, so it is not atomic with the script.
def claim(conn, queue, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do
  if paused?(conn, queue) do
    :empty
  else
    # KEYS = [pending, active, the base emq:{q}:] — every key declared
    keys = [
      Keyspace.queue_key(queue, "pending"),
      Keyspace.queue_key(queue, "active"),
      Keyspace.queue_key(queue, "")
    ]

    # ARGV = [the lease window in ms]
    argv = [Integer.to_string(lease_ms)]

    case Connector.eval(conn, @claim2, keys, argv) do
      # nothing pending, or a ghost popped: nothing to hand over
      {:ok, []} -> :empty
      # leased: the id, its payload, and attempts — the fencing token
      {:ok, [id, payload, att]} -> {:ok, {id, payload, att}}
      other -> other
    end
  end
end

Beat two — the script body. @claim2 pops first and checks after: inside one script nothing else runs between its lines, so the pop needs no repair.

-- the @claim2 script — KEYS[1] = pending, KEYS[2] = active, KEYS[3] = the base; ARGV[1] = lease_ms
-- pop the oldest pending id: every score is 0, so byte order is mint order
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], on the queue's slot
local jk = KEYS[3] .. 'job:' .. id
-- the ghost guard: an id with no row is consumed by the pop and answers empty,
-- so the next claim serves the real head and no skeleton row is minted
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 the active-set score: server time plus the window, in ms
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}

The active-set score is the lease deadline and attempts is the fencing token — there is no separate lock. The lifecycle's claim & the lease dive reads that mechanism in full.

Enqueue, then claim · the round trip

emq:{q}:pending (ZSET) (empty) emq:{q}:active (ZSET, score=deadline) (empty)
step 0: a fresh queue — nothing pending, nothing leased.

Enqueue and claim are two verbs over one wire: admit once, then pop the oldest, lease it, and hand it over. The pending set is the producer's; the active set is the worker's.

The native twin — EMQ.CLAIM. The scripted claim is two commands, the pause read and then the script; the native claim is one, with the pause read folded in.

# wire 4 — EchoMQ.Jobs.claim/3: the pause read, then the script
HGET emq:{q}:meta paused
EVALSHA <sha> 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>
# wire 6 — one command on a module-loaded node
EMQ.CLAIM 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>

On the ordinary path the reply is the script's: [id, payload, attempts], or an empty array when the queue is paused, pending is empty, or the head is a ghost (a ghost is still popped). The handler checks before it writes — it peeks the head, types the row and the active set, and predicts the attempts increment before it pops — so a refusal writes nothing. It takes the time from the server's cached clock, the value TIME reports, and writes one field the script does not: lease_epoch, the server's run id, beside state active. Over the default wire 4 EchoMQ.Jobs evaluates the scripts; over a wire: 6 connector enqueue/5 sends EMQ.ENQUEUE, and claim/3 sends EMQ.CLAIM after its own pause read. One transition, two executors runs both claims side by side.

Pattern & implementation

the pattern (Redis Patterns Applied)

A reliable queue admits each item once and delivers it to exactly one worker, which pops it from a list or a sorted set and runs it. Reliable queues teaches the move — admit once, claim once.

the implementation (echo_mq)

EchoMQ.Jobs's @enqueue admits idempotently — gate, refuse, row, pending — and the flat @claim2 pops the oldest id, skips a ghost, leases it on the server clock, and returns it. enqueue/5 and claim/3 are the handles; EMQ.ENQUEUE and EMQ.CLAIM are the native twins.

The pattern says: admit once, then claim once. The implementation is two scripts — @enqueue and the flat @claim2 — each one atomic step.

Recap — admit once, claim once

Enqueue is one idempotent script: gate the branded id, refuse a duplicate, write the three-field row, insert the pending entry. Claim is its mirror: pop the oldest, skip a ghost, increment attempts, set the row active, lease it on the server clock. On a node that loads the trafikey module the same two transitions run as EMQ.ENQUEUE and EMQ.CLAIM, with the same keys and replies. The next dive reads how many workers claim fairly — the ring that rotates one step per claim.

Next in this module: Fair lanes & the ring — per-identity lanes and a rotating ring, so no one identity starves the others.

References

Where the idempotent admission and the leasing claim are grounded, the commands the bodies use, and the native twins.

Sources

  • Redis — EVALSHA — the load-once, run-by-SHA dispatch the handle runs each script with.
  • Valkey — ZPOPMIN — popping the oldest pending id the flat claim leases.
  • Valkey — HSET — the hash write the three-field row is built from.
  • Valkey — ZADD — the sorted-set insertion the enqueue ends with and the claim re-scores into active.

Related in this course