R6.05 · Flow Control & Scale — module hub
Worker — concurrency is not throughput.
Running many workers against one store without the wire becoming the bottleneck: the BEAM gives concurrency cheaply, but a claim is a round-trip, so throughput is set by how many round-trips are in flight — pool width and batch size — not by how many processes are running. A consumer that parks instead of polling costs the wire nothing while idle; a pool of pipelined connectors raises the round-trips outstanding at once.
Technique, not a fresh catalog pattern: the per-claim fetch ceiling and how to plan capacity around it. Grounding: echo/apps/echo_mq · EchoMQ.Consumer (the claim loop, BLPOP park), EchoMQ.Pool (round-robin connectors), the per-claim ZPOPMIN in claim/3, amortized by EchoMQ.Lanes.bclaim/3. Consumer: codemojex — the scoring worker drains the cm queue.
BLPOP — spending no round-trips
while idle, woken by an enqueue or the beat.The wire is the shared resource
consumer.ex · the dedicated connectorThe BEAM makes processes cheap. A deployment can run thousands of consumer processes for a few megabytes, and OTP supervises them for free, so the instinct is to add workers when a queue falls behind. But adding BEAM processes does not, on its own, raise throughput against Valkey.
The reason is the connector. A single owned socket carries one command at a time: requests go out and replies come back in order, on the wire. Ten processes sharing one connector do not get ten times the work done — they queue at the socket, and the wire serves them one round-trip at a time. Concurrency on the BEAM is not parallelism on the wire. The shared resource is the connection, and the unit of cost is the round-trip.
The claim loop is EchoMQ.Consumer. It holds a dedicated connector — a
private lane, so a blocking verb cannot stall a shared one — and beats on a cadence: reap expired
leases, promote due schedules, drain the ring with rotating claims, then park.
EchoMQ.Consumer.start_link(queue: "orders", handler: &handle/1, connector: conn_opts)
# → a supervised loop with its own connector lane; beats reap → promote → drain → park
The handler is a function taking %{id:, payload:, attempts:, group:} and answering
:ok or {:error, reason}. A returned error becomes a typed retry; a raising handler
is caught and retried too, and the loop survives.
A queue does not drain faster for more BEAM processes if they share a connector — they serialize at the socket. The shared resource is the connection; the unit of cost is the round-trip. The consumer holds its own connector so a blocking verb never stalls a shared one.
Park, don’t poll
consumer.ex · BLPOP on the wake keyA naive worker loop polls: claim, and if the queue is empty, sleep a little and claim again. Polling spends
round-trips on an idle queue and adds latency on a busy one. The consumer does neither. When the ring is
drained it parks on the wake key with BLPOP until readiness arrives as a wake or
the beat elapses:
# inside the consumer's park step:
BLPOP emq:{q}:wake <beat seconds>
BLPOP blocks the connection until something is pushed to the wake key or the timeout expires.
An enqueue pushes a wake token, which unparks exactly the consumers waiting on it. A parked consumer
costs the wire nothing — no round-trips spent waiting, and no polling delay when work arrives.
The beat doubles as the cadence for the reap and promote sweeps, so an idle consumer still wakes periodically
to do its housekeeping.
Because BLPOP holds the connection for the duration of the block, a blocking consumer needs
its own connector lane — which is exactly why the consumer holds a dedicated connector and never shares
one. A shared connector would have every other command on it wait behind the block.
The consumer parks on BLPOP emq:{q}:wake rather than polling: idle, it spends no
round-trips; on an enqueue, a wake token unparks it at once. Park, don’t poll — and a blocking park
is why the loop owns its connector.
The pool is the concurrency primitive
pool.ex · round-robin connectorsReal parallelism against Valkey means more sockets. EchoMQ.Pool is a fixed pool of pipelined
connectors with lock-free round-robin dispatch:
EchoMQ.Pool.start_link(name: :work_pool, size: 4)
# → 4 supervised connectors, each a pipeline; next/1 picks the next by an atomic counter
@moduledoc """ A fixed pool of pipelined connectors with lock-free round-robin dispatch. Each member is already a pipeline, so a small pool multiplies throughput without checkout ceremony: callers hit the next member by an atomic counter and the member's own FIFO does the rest. One supervisor, N connectors, one dispatcher -- the appendix connector, multiplied. """ defp next(name) do {size, at} = :persistent_term.get({__MODULE__, name}) member(name, rem(:atomics.add_get(at, 1, 1), size) + 1) end
There is no checkout, no lock, no waiting for a free connection. A caller hits the next
member by an atomic counter (:atomics.add_get) and the member’s own FIFO does the rest. One
supervisor, N connectors, one dispatcher — the single connector, multiplied. Pool width is the number of
round-trips that can be outstanding at once: it is the real parallelism, where the count of BEAM processes is
only concurrency.
The consumer’s opt-in :metronome mode fans a pool’s readiness from one blocker:
each consumer registers idle with the queue’s metronome, awaits a :claim_once poke, claims
once, settles, and re-registers — one block per queue, the herd gone, readiness handed out fairly across
the pool.
More sockets, not more processes, is the lever: EchoMQ.Pool is N pipelined
connectors dispatched round-robin by an atomic counter, with no checkout. Pool width is the round-trips
outstanding at once — the real parallelism.
The per-claim fetch is the ceiling
jobs.ex · the @claim ZPOPMINEvery claim pops one job. claim/3 runs one ZPOPMIN against the pending set, mints
a lease on the server clock, and returns one job — one job per claim, one round-trip per claim. The
fetch is the throughput ceiling: a worker cannot go faster than its round-trips, and each round-trip carries
one job.
local popped = redis.call('ZPOPMIN', KEYS[1]) if #popped == 0 then return {} end local id = popped[1] local jk = ARGV[1] .. id 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[2]), id) return {id, redis.call('HGET', jk, 'payload'), att}
The amortization is the batch claim. EchoMQ.Lanes.bclaim/3 is the count-variant of the same
spine: it reads the pending depth, clamps k = min(requested, depth) so it never over-pops, reads
one TIME for the whole batch so every served job is leased on the same deadline,
then loops the per-job transitions and returns up to k jobs in one round-trip. The same fetch,
amortized over k jobs.
So three knobs raise throughput, and they are independent. Pool width sets how many fetches are outstanding
at once. Batch size sets how many jobs each fetch carries. The claim cadence (:beat_ms) sets how
often a consumer reaches for work. None of them is the count of BEAM processes.
Parallel vs concurrent
The BEAM gives cheap concurrency, but the wire is the shared resource: real parallelism is N
EchoMQ.Pool members (N sockets), not N processes on one connector.
The per-claim fetch bottleneck
Each claim/3 is one ZPOPMIN round-trip; at high worker count the
round-trip dominates, not server CPU. bclaim/3 amortizes it — k jobs per trip.
Capacity planning
The structural knobs — pool width, :beat_ms/:lease_ms,
:pump_batch — reasoned in round-trips. The limiter is the ceiling, the pool the floor.
The per-claim ZPOPMIN is the unit of throughput: one job per claim, one round-trip
per claim. Three independent knobs raise it — pool width (fetches outstanding), batch size
(bclaim/3, jobs per fetch), and the claim cadence — never the count of processes.
Applied in EchoMQ
echo/apps/echo_mqThe technique is the consumer loop plus the pool plus the fetch. EchoMQ.Consumer owns the
rhythm and parks on BLPOP so an idle worker is free; EchoMQ.Pool multiplies the
sockets so many round-trips run at once; and the per-claim ZPOPMIN is the unit of throughput,
amortized by EchoMQ.Lanes.bclaim/3 when one job per trip is the ceiling. The count of workers is
concurrency; the pool width and the batch size are throughput.
Worker concurrency: run many workers without the wire becoming the bottleneck. Concurrency is having work in progress; throughput is how much completes, set by the round-trips in flight.
EchoMQ.Pool (N pipelined connectors, round-robin by an atomic counter) feeds
EchoMQ.Consumer loops that park on BLPOP (no poll), each claim one
ZPOPMIN round-trip — amortized by the batch claim EchoMQ.Lanes.bclaim/3.
BLPOP park, and a wake herd by the metronome’s
single blocker — never by adding more processes alone.A consumer makes the technique concrete. In codemojex — a Telegram emoji-guessing game on the same
stack — Codemojex.Guesses.submit/3 enqueues each guess as a branded JOB on the
player’s lane (emq:{cm}:…), and Codemojex.ScoreWorker is the scoring
consumer: EchoMQ.Consumer drains the guess queue through Lanes.claim, the player id
arriving as the lane group. During a busy event — many rooms guessing at once — the per-claim
ZPOPMIN is the ceiling, and pool width plus the batch claim are what keep the scoring engine
ahead of the field.
Wiring the claim loop into a production deployment — the pool sizing, the lease recovery, the metrics per queue — is the queue’s scaling layer, taught in the EchoMQ course rather than here.
Concurrency at the claim point
The Queue pillar wires the consumer and pool into a production deployment: the claim loop, the pool sizing, the lease recovery for a stalled worker, and the metrics per queue.
/bcs/busThe Valkey-native bus
Part B3 builds the bus the consumer drains: a queue is a set of lanes named by branded ids, claimed off the ring on a server-clock lease.
R6.01The ceiling above the floor
Pool width is the throughput floor; the rate limiter is the ceiling — a per-window counter that caps how much work is admitted, however wide the pool.
Notes on ValkeyBLPOP blocks the connection it is issued on
until an element is pushed to the key or the timeout elapses — so a parking consumer must hold a dedicated
connector, and a pool of consumers needs a coordinator (the metronome) so one enqueue does not unblock every
member at once —
valkey.io/commands/blpop.
When to use / when to avoid
the decisionWiden the pool and batch the claim when:
- A queue falls behind and adding consumer processes does not help — the bottleneck is the wire, not the BEAM, so the lever is more sockets (pool width), not more processes.
- The per-job work is small relative to the round-trip, so the fetch dominates and a batch claim
(
bclaim/3) amortizes it over several jobs. - Many consumers compete on one queue and the thundering herd of wakes is a cost — the
:metronomepool path fans readiness from one blocker.
Leave the defaults when:
- The queue keeps up at one connector and a modest beat — concurrency is not the constraint, and a wider pool buys nothing.
- The per-job handler work dwarfs the round-trip; then the fetch is not the ceiling, the handler is, and batching the claim changes little.
- A hard ceiling on the rate of work is the goal rather than throughput — that is the rate limiter, a per-window counter, not pool width.
Reach for pool width and a batch claim when the wire is the bottleneck and the fetch dominates; leave the defaults when one connector keeps up or the handler is the cost. Throughput is a structural choice, reasoned in round-trips — not a count of processes.
References
sources · relatedSources
- Valkey — BLPOP — block the connection until an element is pushed or the timeout expires; the park that lets an idle consumer cost the wire nothing.
- Valkey — ZPOPMIN — pop the lowest-scored member of a sorted set; one pop per claim is the per-claim fetch, the throughput ceiling.
- Valkey — Pipelining — send many commands without waiting for each reply, so a pipelined connector keeps several round-trips outstanding.
- Valkey — Cluster specification — the
{q}hash tag pins a queue’s keys to one of 16384 slots, so a multi-key claim script is slot-legal.
Related in this course
- R6 · Flow Control & Scalethe chapter
- R6.05.1 · Parallel vs concurrentwhy the wire, not the BEAM, is the shared resource
- R6.05.2 · The per-claim fetch bottleneckone ZPOPMIN per claim, and how bclaim/3 amortizes it
- R6.05.3 · Capacity planningthe structural knobs, reasoned in round-trips
- /echomq/queuethe consumer and pool wired into a production deployment
- /bcs/busPart B3, the Valkey-native bus the figures draw from