jonnify·redis /redis-patterns/flow-control/fairness/lanes-vs-separate-queues

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.

One queue with lanes, or N separate queues. Select a shape to read where the fairness lives. Both isolate the producers; only one builds fairness into the queue.
one queue, lanes A B C consumers one ring rotates the lanes — fairness built in N separate queues qA cons qB cons qC cons no ring across queues — a scheduler must keep them fair
readout · Select a shape. One queue with lanes holds A, B and C on a shared ring drained by one consumer pool — fairness is the rotation. N separate queues each have their own consumers and no shared ring — fairness must be imposed from outside by a scheduler, and the consumer pool fragments N ways.
§1

The two shapes

where the fairness lives

A 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).

§2

What the lane buys

claim/3 · one {q} slot

One 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:

EchoMQ.Lanes · echo/apps/echo_mq — claim/3: the ring and active keys, declared, on one slotverbatim
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.

§3

What separate queues buy, and the cost

isolation against the scheduler

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

The trade, dimension by dimension. Select a dimension to read how one queue with lanes compares to N separate queues. Lanes build fairness in; separate queues isolate at a cost.
one queue, lanes N separate queues fairness built into the ring external scheduler cross weight cross-lane, wclaim/3 none control moves atomic on one slot non-atomic, cross-keyspace consumers one pooled set N fragmented sets isolation shared blast radius hard isolation
readout · Select a dimension. One queue with lanes wins on fairness, cross weight, atomic control, and a pooled consumer set — all from the shared ring. N separate queues win on one thing: hard isolation, at the cost of an external scheduler, no cross-queue weight, non-atomic moves, and N times the consumers and operations.

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.

the pattern

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.

its EchoMQ application

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.

§4

References

sources · related

Sources

Related in this course