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
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
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
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.
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
- Valkey — LMOVE — the atomic head-to-tail move that turns the ring one step per claim.
- Valkey — ZPOPMIN — popping the oldest id off the served lane.
- Valkey — HINCRBY — the per-group in-flight book the limit is checked against.
- Redis — EVALSHA — load-once, run-by-SHA dispatch of the declared-keys script.
Related in this course
- Jobs, lanes & the consumer — the module this dive belongs to.
- The consumer loop — the loop that drains the ring.
- Enqueue & claim — the flat claim this grouped one mirrors.
- redis-patterns · Reliable queues — the reliable-queue pattern under fair lanes.