jonnify·redis /redis-patterns/flow-control/groups

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 through the lane machinery. Select a move to read what the store does. A group is admitted onto its lane, the ring rotates to serve it in turn, its ceiling caps how much it holds at once, and a pause holds one tenant while the others run.
a lane joins the ring when serviceable,leaves when empty, at its ceiling, or paused enqueue/5lane + ring claim/3rotate & serve limit/4de-ring at cap pause/3hold one
readout · Select a move. A group's work is admitted onto its own lane and the lane joins the ring; a claim rotates the ring and serves the rotated lane; a per-group ceiling de-rings a lane that is at its limit; a pause holds one lane while the rest keep running.
§1

A group is a lane

lanes.ex · enqueue/5

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

§2

Round-robin across tenants

lanes.ex · claim/3 · the ring

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

§3

The per-group ceiling

lanes.ex · limit/4 · glimit / gactive

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

built ready to read
§4

Applied in EchoMQ

echo/apps/echo_mq

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

EchoMQ.Lanes · echo/apps/echo_mq — the @glimit script: set the ceiling, then de-ring or re-ringverbatim
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.

the pattern

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.

its EchoMQ application

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.

“Group-aware pause and resume act on a lane without touching the queue, so one tenant can be held while the others run.” The lane is the unit of fairness and of control. — The Branded Component System, Part B3 · Jobs and lanes
The multi-tenant problem → the technique. Select a load problem to read the technique that answers it and the real EchoMQ surface that carries it.
the problem the technique & surface one tenant floods round-robin · the ring + claim/3 hogs the workers per-group ceiling · limit/4 one misbehaves hold one lane · pause/3, resume/3 matters more now change lane · reassign/4
readout · Select a problem. Each multi-tenant load problem maps to one lane move: a flood is bounded by the rota, a worker hog by the per-group ceiling, a misbehaving tenant by a pause on one lane, and "matters more now" by a change of lane — never a numeric per-job priority.

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.

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.

§5

When to use / when to avoid

the decision

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

§6

References

sources · related

Sources

  • 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 glimit hash; the cap is a hash field, not a key per tenant.
  • Valkey — HINCRBY — increment and decrement the in-flight count gactive atomically 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