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 — 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
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
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:
@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
- 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.
- The native module — the wire-6 commands, and why a lane job is refused.
- redis-patterns · Reliable queues — the reliable-queue pattern under fair lanes.