R6.02.3 · Fairness under load — dive
Lanes vs separate queues — build it in or bolt it on.
There are two ways to keep one producer from starving another: a lane per producer inside one queue, or a separate queue each. The first builds fairness into the ring; the second pushes it out to whatever schedules the queues. EchoMQ takes the lane — and keeps the control atomic on one slot.
Grounding: echo/apps/echo_mq · EchoMQ.Lanes.claim/3 (the ring rotation) · enqueue/5, wclaim/3, reassign/4, reap_group/4, drain/3 · the keys emq:{q}:ring and emq:{q}:g:<group>:pending, co-located by the {q} hash tag.
The two shapes
where the fairness livesA lane is a producer's pending set inside a shared queue
(emq:{q}:g:<group>:pending). One queue holds every lane, one ring
(emq:{q}:ring) rotates them, and one set of consumers drains the lot. Fairness is a property of
the ring — a claim rotates one step and serves the lane it lands on, so every lane is served in
turn.
A separate queue is a producer's own queue, a distinct keyspace, drained by its own consumers. There is no ring across queues and no shared rota — each queue is independent, and whatever decides how much attention each queue gets lives outside the queues, in the deployment.
Both isolate producers. The difference is where the fairness lives. With lanes it is inside the queue, constructed by the rotation. With separate queues it is outside, in whatever schedules the consumers across the queues.
Lanes and separate queues both isolate producers. The question is whether fairness is built into the queue (the ring) or bolted on outside it (a scheduler across queues).
What the lane buys
claim/3 · one {q} slotOne queue with lanes makes fairness a property you get from the claim. The ring rotates, every lane is
served in turn, and a weight (wclaim/3 over the gweight hash) tilts the share of
one lane against another — cross-lane, because the lanes share one ring. None of that is
expressible across separate queues: there is no way to say “serve lane B three times as often as lane
A” when A and B are different queues with no shared rota.
The control operations are also one-slot and atomic. Every key of one queue carries the {q}
hash tag, so the ring and all the lanes hash to one cluster slot — which is what lets a claim touch the
ring and a lane in one Lua script without a CROSSSLOT error. The host declares those keys; the claim is the
clean shape:
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")] ← both on the {q} slot
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 served job + its group
other -> other
end
end
end
Within that one slot, EchoMQ moves a job between lanes (reassign/4), recovers one group's
stalled work (reap_group/4), or drains one lane (drain/3) — each a single
atomic operation on lanes that share the slot. The same moves across separate queues are cross-keyspace and
cannot be done atomically. And the consumer pool is shared: one queue with lanes is drained by one set of
consumers the rota keeps busy across every lane, so an idle moment on one lane is spent on another and the
machine stays full.
Notes on ValkeyBecause every key of a queue shares the
{q} hash tag, a multi-key Lua script over the ring and a lane lands on one slot — the cluster
rejects a multi-key command whose keys span slots (CROSSSLOT), so the hash tag is what makes the atomic
cross-lane move legal — valkey.io/topics/cluster-spec.
One queue with lanes buys cross-lane weight, atomic cross-lane control on one slot
(reassign, reap_group, drain), and a pooled consumer set — all
from the shared ring.
What separate queues buy, and the cost
isolation against the schedulerSeparate queues buy hard isolation. Different keyspaces, different consumers, different blast radius — a queue that misbehaves, fills, or is misconfigured cannot touch another, because they share nothing. When producers must be isolated for an operational or a security reason, separate queues are the honest answer.
The cost is the fairness and the operations. There is no cross-queue rota, so fairness between queues is whatever an external scheduler imposes — and writing that scheduler is the work the ring did for free. There is no cross-queue weight. The control moves that are atomic within one slot — moving work between producers, recovering one producer's stalled work, draining one producer — become cross-keyspace and lose their atomicity. And the consumer pool fragments: N queues need consumers allocated across them, so an idle queue's capacity sits idle rather than helping a busy one, and the operational surface multiplies by N.
So the choice is the usual one between building a property in and bolting it on. Lanes build fairness into one queue and keep the control atomic on one slot, at the cost of a shared blast radius. Separate queues give hard isolation, at the cost of an external scheduler, no cross-queue weight, non-atomic cross-queue moves, and N times the consumers and the operations.
Keep producers fair without starving each other. Build the fairness into the queue with a rotation, or push it out to a scheduler across N isolated queues.
One EchoMQ queue holding a lane per producer (emq:{q}:g:<group>:pending), the
emq:{q}:ring rotated by claim/3 to serve them in turn and wclaim/3
to weight them — fairness constructed by the ring, not delegated to a scheduler across N queues.
A consumer shows the shape. codemojex (echo/apps/codemojex) runs one queue
("cm") with a lane per player, so every player's guesses are fair against every other player's
through one shared ring and one consumer pool (Codemojex.ScoreWorker) — not N per-player
queues that would need a scheduler to keep fair and N pools of consumers to drain.
One queue with lanes at scale
The Queue pillar runs the lane-per-producer shape under load: the ring driven by a consumer loop, the weighted share, the per-group ceiling, and the atomic cross-lane control.
/bcs/busThe one-namespace gate
Part B3 builds the Valkey-native bus and the BCS law: a system gates every ingress on one branded namespace, which is why a queue's lanes share one slot and one rota.
R6.02.1Why the rota exists
The starvation a single line causes, and the rotation that fixes it — the reason a lane inside one queue is worth more than a queue per producer.
References
sources · relatedSources
- Valkey — LMOVE — the shared-ring rotation that makes fairness a property of one queue.
- Valkey — Cluster specification — the
{q}hash tag co-locates a queue's keys on one slot, so cross-lane moves are atomic where cross-queue moves are not. - Valkey — Keyspace — separate queues are separate keyspaces with no shared rota.
- Redis — Documentation — the list and sorted-set primitives the ring and the lanes are built from.
Related in this course
- R6.02 · Fairness under loadthe module hub
- R6.02.1 · Starvation under loadthe FIFO flaw and the rota guarantee
- R6.02.2 · The weighted shareweights, the multi-pop, priority retired
- /echomq/queueone queue with lanes at scale
- /bcs/busPart B3, the fair-lanes architecture