jonnifyechomq

Jobs, lanes & the consumer · dive 02

Fair lanes & the ring

Fairness between identities is constructed, not hashed: a ring of lanes, rotated one step per claim.

Give each identity its own pending set — a lane. Hold the lanes that can be served right now in one Valkey LIST — the ring. Then make every claim rotate the ring one step before serving its head, and the lanes take turns: no identity starves another. There is no hash function picking a winner — the rota is a data structure the script turns. Rotate the ring on the right, then read the two beats below.

The ring · rotate one step per claim

lane Ag:A:pending lane Bg:B:pending lane Cg:C:pending emq:{q}:ring (LIST) — LMOVE head to tail, then ZPOPMIN the head lane
rota reset to A · B · C. the next claim serves A, then B, then C, then A again — round-robin

The shape — lanes, a ring, and the books

Fair lanes layer four small structures over the same machine, all on one queue's slot:

• a lane — emq:{q}:g:<group>:pending, a per-group pending set named by a branded id. One identity's backlog lives here.
• the ring — emq:{q}:ring, a Valkey LIST holding exactly the groups serviceable now: nonempty, unpaused, below their concurrency limit.
• the books — emq:{q}:gactive (a hash, in-flight count per group) and emq:{q}:glimit (a hash, the ceiling per group).
• the parked set + wake — emq:{q}:paused (groups removed from rotation) and emq:{q}:wake (the list a parked consumer blocks on).

The job row is the same three fields as the flat queue, plus a group field written at grouped admission — a completion reads it to find the lane and the in-flight count to credit.

A lane per identity, a ring of the serviceable ones, two small books for concurrency. Fairness is these structures — not a hash over the id.

The grouped claim, in two beats

Beat one — the named handle. EchoMQ.Lanes @gclaim2 is run by Lanes.claim/3, which honors the queue-wide pause flag first, then evaluates the script under three declared keys — the ring, the active set, and the queue base every lane and row key is derived from — with the lease on ARGV. It returns the job with its group.

# echo_mq — EchoMQ.Lanes
# claim/3 reads the queue-wide pause flag first: a queue-wide pause stops the
# grouped claim too, distinct from pause/3, which parks ONE lane.
def claim(conn, queue, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do
  if EchoMQ.Jobs.paused?(conn, queue) do
    :empty
  else
    # KEYS = [the ring, the active set, the base emq:{q}:] — every key declared
    keys = [
      Keyspace.queue_key(queue, "ring"),
      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, @gclaim2, keys, argv) do
      {:ok, []} -> :empty
      # the group rides back beside the job, so the worker settles the right lane
      {:ok, [id, payload, att, group]} -> {:ok, {id, payload, att, group}}
      other -> other
    end
  end
end

Beat two — the script body. @gclaim2 rotates the ring one step, serves that lane's head, leases it, and keeps the books. KEYS[1] is the ring, KEYS[2] the active set, KEYS[3] the queue base; ARGV[1] is the lease window.

-- the @gclaim2 script — rotate one step, serve the head, lease, keep the books
-- ROTATE: LMOVE pops the head group of the ring and pushes it to the tail in
-- one atomic move, so the NEXT claim starts at the next group — round-robin,
-- constructed. The returned g is the group to serve now.
local g = redis.call('LMOVE', KEYS[1], KEYS[1], 'LEFT', 'RIGHT')
if not g then return {} end
-- the lane key is derived from the declared root KEYS[3], on the queue's slot
local lane = KEYS[3] .. 'g:' .. g .. ':pending'
-- SERVE: pop this lane's oldest id. An emptied lane leaves the ring and the
-- claim answers empty; the next admission to it returns it with a wake.
local popped = redis.call('ZPOPMIN', lane)
if #popped == 0 then
  redis.call('LREM', KEYS[1], 0, g)
  return {}
end
local id = popped[1]
local jk = KEYS[3] .. 'job:' .. id
-- the ghost guard: an id with no row is consumed by the pop; nothing is leased
-- or counted, the lane leaves the ring only if it emptied, and the claim
-- answers empty
if redis.call('EXISTS', jk) == 0 then
  if redis.call('ZCARD', lane) == 0 then redis.call('LREM', KEYS[1], 0, g) end
  return {}
end
-- LEASE: the same as the flat claim — attempts is the fencing token; the
-- active-set score IS the lease deadline, read off the server clock.
local att = redis.call('HINCRBY', jk, 'attempts', 1)
redis.call('HSET', jk, 'state', 'active')
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)
-- BOOKS: count this claim in flight; if the group is now at its glimit, or its
-- lane is now empty, drop it from the ring until it is serviceable again.
local act = redis.call('HINCRBY', KEYS[3] .. 'gactive', g, 1)
local lim = redis.call('HGET', KEYS[3] .. 'glimit', g)
if lim and act >= tonumber(lim) then
  redis.call('LREM', KEYS[1], 0, g)
elseif redis.call('ZCARD', lane) == 0 then
  redis.call('LREM', KEYS[1], 0, g)
end
return {id, redis.call('HGET', jk, 'payload'), att, g}

The one move that builds fairness is the first line: LMOVE KEYS[1] KEYS[1] LEFT RIGHT takes the head off the ring and puts it at the tail, atomically, then the script serves that group. Because the ring is turned exactly one step per claim, the next claim starts at the next group. Every other key — the lane, the row, the two books — is derived from the declared base, so the whole turn stays on the queue's one slot. The leasing and the books are bookkeeping around that one turn.

One LMOVE turns the rota; one ZPOPMIN serves the head. Fairness is the turn of a list, not the output of a hash.

The concurrency limit parks a lane

A lane carries a ceiling. When a group reaches its glimit in-flight, the claim drops it from the ring — the rota skips it until a completion frees a slot. Set a limit of 1 on lane B below and claim repeatedly: B is served once, then parked; A and C keep their turns. The model replays the real @gclaim2 bookkeeping, and the lane accounting of @complete, over a fixed three-lane rota whose lanes all hold a backlog.

Lane B limited to 1 in-flight · claim repeatedly

lane Ain-flight 0 lane B · limit 1in-flight 0 / 1 lane Cin-flight 0 ring: A · B · C
reset — ring is A · B · C, lane B limited to 1 in-flight. claim repeatedly and watch B park after one claim

A parked lane keeps its backlog and its in-flight work — it only leaves the rota. A completion that drops the group's in-flight below the limit returns it to the ring with a wake, when the lane is unpaused and still holds work, so a parked consumer picks it up at once.

The control verbs

Around the claim, EchoMQ.Lanes exposes the operator surface for lanes — each one idempotent, each one keeping the ring honest:

• enqueue/5 — grouped admission: kind policy, duplicate refusal, the row with its group field, the lane entry, and the ring bookkeeping with a wake.
• pause/3 / resume/3 — remove one lane from rotation, or return it if serviceable (the backlog and in-flight are untouched).
• limit/4 — set a lane's concurrency ceiling; lowering it below the live count parks the lane, raising it may return the lane to rotation.
• depth/3 — lane depth, a ZCARD over the lane.

Every verb that makes a lane serviceable again pushes a wake onto emq:{q}:wake, so a parked consumer returns the moment work appears — the loop the next dive reads.

Lanes run only as scripts

Every lane transition — grouped admission, the rotating claim, the control verbs, and the lane accounting inside complete, retry, promote and reap — runs as an inline Lua script. No EMQ.* command reads the ring or a lane, so the grouped claim has no native twin.

On a module-loaded node, the verbs that settle, move or recover a job refuse a lane job. They read the row's group field and refuse a grouped row before any write, where the scripts do the lane accounting. EMQ.COMPLETE answers EMQSTATE complete lane not native, where @complete credits the lane and returns it to the ring; EMQ.FAIL answers EMQSTATE fail lane not native, where @retry does the same before it schedules or buries the job.

The other settling and recovery verbs refuse a grouped row in their own words: EMQSTATE delay lane not native, EMQSTATE reprocess lane not native, EMQSTATE requeue lane not native, and — refusing the whole call — EMQSTATE promote lane not native and EMQSTATE stalled lane not native. Every refusal comes before the first write. EMQ.EXTEND, EMQ.EXTENDS and EMQ.RESCHEDULE do not read group, so a lane job's lease still extends natively. The native module teaches the mechanism.

Pattern & implementation

the pattern (Redis Patterns Applied)

A reliable queue serves work to consumers; fairness across producers is a policy layered on top — round-robin over per-producer queues, rather than one shared FIFO. Reliable queues teaches the queue this builds on.

the implementation (echo_mq)

EchoMQ.Lanes builds fairness from a rotating ring of per-identity lanes: @gclaim2 does LMOVE LEFT RIGHT to rotate one step, then ZPOPMINs that lane's head — fairness constructed, not hashed.

The pattern says: round-robin over per-producer queues. The implementation is one LMOVE per claim, turning a ring of lanes.

Recap — the rota is a data structure

Each identity gets a lane; the ring holds the serviceable lanes; every claim rotates the ring one step before serving the head, so the lanes take turns. The books cap concurrency per lane and park a lane that is full. The next dive reads the loop that drains this ring — and why it parks rather than polls.

Next in this module: The consumer loop — reap, promote, drain, park, and a raising handler that becomes a typed retry.

References

Where the rotating ring and the concurrency books are grounded, and the commands the body uses.

Sources

Related in this course