R6.03 · Flow Control & Scale — module hub
Groups — one queue, many tenants.
A group is a tenant given its own lane: work enqueued under a branded group id rides that lane, the ring serves every lane in turn, and a per-group concurrency ceiling caps how much of the machine any one tenant holds at once. It is the multi-tenant operations layer over the fair-lanes ring — a way to carry many tenants on one queue without one starving the rest, and without a separate queue each.
Technique, not a fresh catalog pattern: it composes distributed-locking (the per-group semaphore) and rate-limiting (the per-group cap). Grounding: echo/apps/echo_mq · EchoMQ.Lanes.enqueue/5, claim/3, limit/4, pause/3, resume/3 over emq:{q}:ring and the glimit/gactive/paused hashes. Consumer: codemojex — a room is a tenant.
A group is a lane
lanes.ex · enqueue/5A queue is not one line; it is a set of lanes, each named by a branded group id. Admitting work under a group puts it on that group's lane and registers the lane for service:
EchoMQ.Lanes.enqueue(conn, "orders", group, job_id, payload)
# → writes emq:{orders}:g:<group>:pending, registers the lane on the ring
enqueue/5 is one idempotent script. It refuses a duplicate, writes the job row with its
group field, adds the job to the lane's pending set emq:{q}:g:<group>:pending,
and — if the group is not already serviced and is below its ceiling — pushes the lane onto the
ring with a wake for any parked consumer. The group is gated as a valid branded id at the key builder before
any command reaches the wire, so a lane is always named by a real identity.
Work that should ride a lane and is enqueued ungrouped is never claimed by the grouped path. The lane is the unit: no group, no lane, no service. This is the property that makes the tenant boundary real — a tenant's work is exactly the work on its lane, and nothing else can be confused for it.
A tenant is a lane named by a branded group id; enqueue/5 admits work onto it and
registers the lane on the ring. Ungrouped work rides no lane and is never claimed — the lane is the unit
of fairness and of control.
Round-robin across tenants
lanes.ex · claim/3 · the ringEvery claim rotates the ring one step before it serves. The ring emq:{q}:ring is a list
holding exactly the serviceable lanes — nonempty, unpaused, and below their
concurrency ceiling:
EchoMQ.Lanes.claim(conn, "orders", 30_000)
# → LMOVE ring ring LEFT RIGHT, then serve the head of the rotated lane
claim/3 does LMOVE ring ring LEFT RIGHT, which moves the head of the ring to the
tail and returns it, then serves the head of that lane on a server-clock lease. Because the ring advances one
step per claim, every serviceable tenant is served in turn: fairness across tenants is constructed by
rotation, never hashed. A busy tenant cannot jump the rota, and a quiet tenant is never skipped.
A lane that empties or reaches its ceiling is removed from the ring; it returns the moment it becomes serviceable again. So the rota always holds exactly the lanes that can be served right now, and rotation alone keeps the share even — three tenants are served A, B, C, A, B, C, one turn each per cycle, whatever the depth of their backlogs.
The ring holds the serviceable lanes and claim/3 advances it one step per claim,
so every tenant is served in turn. The rota is the schedule, and the order is a property of rotation, not of a
score.
The per-group ceiling
lanes.ex · limit/4 · glimit / gactiveRound-robin keeps the rate of turns fair, but it does not by itself bound how much of the machine one tenant holds at once. That is the per-group concurrency ceiling:
EchoMQ.Lanes.limit(conn, "orders", group, 5) # this tenant: at most 5 in flight at once
limit/4 writes the cap into the emq:{q}:glimit hash and reconciles the ring
immediately. The companion counter emq:{q}:gactive tracks how many of a group's jobs are
currently in flight; a claim increments it, a completion decrements it. When a lane reaches its ceiling it is
de-ringed — removed from rotation so the rota skips it entirely — and re-ringed
the moment a completion drops gactive below the limit. Lowering a ceiling below the live count
parks the lane until it drains; raising it may return the lane to rotation at once.
Two more controls act on a single lane without touching the rest of the queue. pause/3
removes a lane from the ring and adds the group to emq:{q}:paused, so the rota skips it while
its backlog and in-flight work sit untouched; resume/3 returns it to rotation if it is
serviceable, with a wake. One tenant can be held while the others keep running — group-aware pause and
resume, the operations move a single shared queue otherwise cannot make.
The ceiling is a counting semaphore: gactive against glimit, enforced
by ring membership. A lane at its ceiling is de-ringed and skipped; a completion frees a permit and re-rings
it. pause/3 holds one tenant while the rest run.
Round-robin across tenants
The lane as a tenant: enqueue/5 admits by group, the ring rotates one step per
claim, ungrouped work is never claimed — the lane is the unit of control.
Per-group concurrency
limit/4 over glimit/gactive: a lane de-rings at its
ceiling, re-rings below it, and pause/resume parks a single tenant.
Group vs separate queue
One queue with groups (a shared ring, per-tenant limits, reassign/
reap_group/drain) against N separate queues (isolation, no cross-tenant fairness,
N× ops).
Applied in EchoMQ
echo/apps/echo_mqThe whole technique is EchoMQ.Lanes, and it composes two patterns this course teaches
separately. The per-group ceiling is a distributed semaphore — a count of in-flight permits
(gactive) checked against a cap (glimit), exactly the shape of a counting lock. The
ring is the fairness rota the queues chapter builds. Together they make a queue multi-tenant: every tenant
gets a turn, and no tenant takes more than its share of the machine at once.
The de-ring ceiling is the @glimit script, verbatim from EchoMQ.Lanes. It writes
the cap, then either de-rings the lane (already at or over the cap) or re-rings it if it is serviceable:
redis.call('HSET', KEYS[1], ARGV[2], ARGV[3])
local act = tonumber(redis.call('HGET', KEYS[2], ARGV[2]) or '0')
if act >= tonumber(ARGV[3]) then
redis.call('LREM', KEYS[3], 0, ARGV[2]) ← at the cap: de-ring the lane
else
local lane = ARGV[1] .. 'g:' .. ARGV[2] .. ':pending'
if redis.call('SISMEMBER', KEYS[4], ARGV[2]) == 0 and redis.call('ZCARD', lane) > 0 and
not redis.call('LPOS', KEYS[3], ARGV[2]) then
redis.call('RPUSH', KEYS[3], ARGV[2]) ← serviceable: re-ring it
redis.call('LPUSH', KEYS[5], '1')
redis.call('LTRIM', KEYS[5], 0, 63)
end
end
return 1
HSET glimit group n writes the ceiling; then it reads gactive and, if the tenant
already has n or more in flight, LREM ring 0 group removes the lane from the rota.
Otherwise, if the lane is unpaused, has pending work, and is not already on the ring,
RPUSH ring group returns it to rotation with a wake. The membership of the ring is the
semaphore: in the rota means a permit is free, off the rota means the permits are spent.
Share capacity fairly across tenants: round-robin lanes serve every tenant in turn, and a per-group concurrency ceiling caps how much of the machine any one tenant holds at once.
A tenant is a lane emq:{q}:g:<group>:pending; EchoMQ.Lanes.enqueue/5
admits by group, claim/3 rotates the ring to serve every tenant in turn, and
limit/4 (over glimit/gactive) caps how much one tenant holds at once,
de-ringing a lane at its ceiling.
A consumer makes the tenant boundary concrete. In codemojex — a Telegram emoji-guessing game on the
same stack — a room is a tenant: work enqueued under a branded room id rides that room's lane
(emq:{cm}:g:<PLR…>:pending), so a busy room cannot starve a quiet one, and a
per-room ceiling caps how much of the engine any one room holds at once.
Wiring the ring and the ceiling into the claim path at scale — the consumer that drives
claim/3, the lease recovery, the metrics per tenant — is the queue's scaling layer, taught
in the EchoMQ course rather than here.
Groups at the claim point
The Queue pillar absorbs the scaling layer: the ring and the per-group ceiling wired into the dequeue path, the consumer that drives the rota, and lease recovery per tenant.
/bcs/bus/jobs-and-lanesThe fair-lanes architecture
Part B3.2 builds the lanes the rota serves: a queue is a set of lanes named by branded ids, drained fairly, with group-aware pause and resume.
R2The semaphore beneath the ceiling
The per-group ceiling is a counting lock — permits checked against a cap. The coordination chapter builds the distributed-lock pattern the ceiling reuses.
Notes on ValkeyEvery lane, the ring, and the
glimit/gactive hashes of a queue carry the per-queue {q} hash tag, so
they all hash to one of 16384 slots — co-located on one node, which is what lets the @glimit
script touch the ring and the hashes in one atomic execution without a CROSSSLOT error —
valkey.io/topics/cluster-spec.
When to use / when to avoid
the decisionReach for groups when:
- One queue carries work from many tenants and any one could otherwise starve the rest or monopolise the workers.
- Per-tenant operations are needed — pause one tenant, cap one tenant, move or recover one tenant's work — without a separate queue per tenant and the operational cost that brings.
- The set of tenants is open or large, so provisioning a queue each is impractical; a lane is created by the first job that names the group.
- Fairness across tenants matters as much as throughput — the rota guarantees every serviceable tenant a turn.
Avoid groups when:
- Tenants need hard isolation — separate storage, separate failure domains, separate retention — that a shared queue cannot give; then separate queues are the right tool, at N× the operational cost.
- There is effectively one tenant; the lane machinery adds bookkeeping with no fairness to buy.
- The constraint is a rate per window rather than concurrency or fairness — that is the rate limiter, a per-window counter, not a per-group ceiling.
Groups carry many tenants on one queue: the rota is the cross-tenant scheduler and the per-tenant levers are data. Reach for separate queues only when tenants must be isolated — then the scheduler is the price you pay for that isolation.
References
sources · relatedSources
- Valkey — LMOVE — atomically move the ring head to the tail and return it; the one-step rotation behind the tenant rota.
- Valkey — HSET — write a group's ceiling into the
glimithash; the cap is a hash field, not a key per tenant. - Valkey — HINCRBY — increment and decrement the in-flight count
gactiveatomically as a tenant claims and completes. - Valkey — Cluster specification — the
{q}hash tag pins every lane, the ring, and the ceiling hashes to one of 16384 slots. - AWS — Tenant isolation — the shared-vs-isolated trade groups make against a queue-per-tenant model.
Related in this course
- R6 · Flow Control & Scalethe chapter
- R6.02 · Fairness under loadthe rota and weights this technique builds on
- R6.03.1 · Round-robin across tenantsthe lane as a tenant, the rotation that serves each in turn
- R6.03.2 · Per-group concurrencythe ceiling, the de-ring, and group-aware pause
- R6.03.3 · Group vs separate queueone queue with groups against N separate queues
- /echomq/queuethe ring and the ceiling wired at the claim point
- /bcs/busPart B3, the Valkey-native bus and the fair-lanes architecture