jonnify·redis /redis-patterns/flow-control/worker-concurrency

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.

The consumer loop, beat by beat. Select a step to read what the loop does. It reaps expired leases, promotes due schedules, drains the ring one rotating claim at a time, then parks on the wake key — park, don’t poll, so an idle worker costs the wire nothing.
a parked consumer spends no round-tripsuntil a wake or the beat elapses, then loops reapexpired leases promotedue schedules claim/3rotate & serve BLPOPpark
readout · Select a step. The consumer beats on a cadence: it reaps expired leases, promotes due scheduled work, drains the ring one rotating claim at a time, then parks on the wake key with BLPOP — spending no round-trips while idle, woken by an enqueue or the beat.
§1

The wire is the shared resource

consumer.ex · the dedicated connector

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

§2

Park, don’t poll

consumer.ex · BLPOP on the wake key

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

§3

The pool is the concurrency primitive

pool.ex · round-robin connectors

Real 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
EchoMQ.Pool · echo/apps/echo_mq — the moduledoc and the lock-free round-robin dispatchverbatim
@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.

§4

The per-claim fetch is the ceiling

jobs.ex · the @claim ZPOPMIN

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

EchoMQ.Jobs · echo/apps/echo_mq — the @claim spine: one ZPOPMIN, one job, one round-tripverbatim
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.

built ready to read

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.

§5

Applied in EchoMQ

echo/apps/echo_mq

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

the pattern

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.

its EchoMQ application

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.

“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.” Pool width is the real parallelism; the count of processes is only concurrency. — EchoMQ.Pool · echo/apps/echo_mq
The bottleneck → the lever. Select a throughput problem to read the lever that answers it and the real EchoMQ surface that carries it.
the problem the lever & surface claims serialize widen the pool · EchoMQ.Pool the fetch dominates batch the claim · bclaim/3 polling wastes trips park · BLPOP in the consumer a wake herd one blocker · the metronome
readout · Select a problem. Each throughput problem maps to one lever: serialized claims are answered by a wider pool, a dominating fetch by a batch claim, wasted polling trips by a 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.

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.

§6

When to use / when to avoid

the decision

Widen 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 :metronome pool 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.

§7

References

sources · related

Sources

  • 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