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

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 — that is how a completion knows which lane 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.claim/3 honors the queue-wide pause flag first, then runs @gclaim with two declared keys — the ring and the active set — and the queue base 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), then runs
# @gclaim. KEYS = [ring, active]; ARGV = [the queue base "emq:{q}:", the lease].
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 = [Keyspace.queue_key(queue, "ring"), Keyspace.queue_key(queue, "active")]
    argv = [Keyspace.queue_key(queue, ""), Integer.to_string(lease_ms)]

    case Connector.eval(conn, @gclaim, keys, argv) do
      {:ok, []} -> :empty
      {:ok, [id, payload, att, group]} -> {:ok, {id, payload, att, group}}  # the group rides back
      other -> other
    end
  end
end

Beat two — the script body. @gclaim 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; ARGV[1] is the queue base, ARGV[2] the lease window.

-- the @gclaim 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
local lane = ARGV[1] .. 'g:' .. g .. ':pending'
-- SERVE: pop this lane's oldest id. An emptied lane leaves the ring and the
-- claim answers empty (a producer will return it to the ring 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 = ARGV[1] .. 'job:' .. id
-- 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[2]), 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 a completion frees a slot.
local act = redis.call('HINCRBY', ARGV[1] .. 'gactive', g, 1)
local lim = redis.call('HGET', ARGV[1] .. '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. 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 @gclaim bookkeeping over a fixed three-lane rota.

Lane B limited to 1 in-flight · claim repeatedly

lane Ain-flight 0 lane B · limit 1in-flight 0 lane Cin-flight 0 ring: A · B · C

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, 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.

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