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

R6.05.3 · Worker concurrency — dive

Capacity planning.

Plan worker capacity from the structural knobs, not a benchmark: pool width is the concurrent in-flight count, :beat_ms and :lease_ms set the claim cadence and the reap window, :pump_batch is the release-side knob, and the rate limiter is the ceiling where the pool is the floor. Reason in round-trips — how many are outstanding, and how many jobs each carries — not in measured milliseconds.

Grounding: echo/apps/echo_mq/lib/echo_mq/consumer.ex · the start_link tuning options; pump.ex · the release cadence (:pump_batch is the promote LIMIT per beat). EchoMQ.Pump is the release cadence, never the worker claim loop.

Structural throughput, in round-trips. Select a knob to read what it sets. The throughput a deployment offers is the claim round-trips outstanding at once, times the jobs each claim carries — both are knobs, neither is a measured millisecond.
pool widthclaims outstanding × batch sizejobs per claim = structuralthroughput claim cadence (:beat_ms) — how often a quiet consumer reaches for work throughput is in claim round-trips, not measured milliseconds
readout · Select a knob. The structural throughput is pool width (claim round-trips outstanding at once) times batch size (jobs each claim carries); the claim cadence governs how often a quiet consumer reaches for work. All three are knobs, reasoned in round-trips — none is a measured millisecond.
§1

The knobs are on the consumer

consumer.ex · start_link options

Capacity is configured, not discovered. EchoMQ.Consumer exposes the tuning surface as start_link options, and each one names a structural property of the loop:

EchoMQ.Consumer · echo/apps/echo_mq — the start_link tuning surfaceverbatim
start_link(opts):
  :queue           — the queue this loop drains
  :handler         — fn %{id:, payload:, attempts:, group:} -> :ok | {:error, reason}
  :conn | :connector  — the dedicated connector (or options to start one)
  :lease_ms        — the lease window a claimed job holds before it is reapable (30_000)
  :beat_ms         — the cadence: how often the loop reaps, promotes, drains, parks (1_000)
  :retry_delay_ms  — the back-off applied to a failed job before it is eligible (1_000)
  :max_attempts    — how many tries before a job is dead-lettered (3)
  :pump_batch      — the promote LIMIT per beat (the release-side knob, 100)
  :metronome       — opt-in: run the POOL path, fed by the queue's one blocker

These are levers on the shape of the work, not on the speed of the machine. Pool width — set when the pool is started, not on the consumer — is the count of connectors, and so the count of round-trips that can be outstanding at once. Together they describe the capacity of a deployment without measuring it.

The knobs are EchoMQ.Consumer options plus the pool width: the lease window, the beat, the retry back-off, the promote batch. Each names a structural property of the loop — capacity is configured, not discovered by a benchmark.

§2

Reason in round-trips

the planning unit

The honest unit for planning is the round-trip, because it is the cost the design controls. A claim is one round-trip and carries one job; a batch claim is one round-trip and carries up to k jobs. So the throughput a deployment can offer is, in round-trips:

  • Pool width sets how many claim round-trips are outstanding at once. A pool of four carries four; doubling the pool doubles the outstanding claims, until the server or the network is the limit.
  • Batch size sets how many jobs each claim round-trip carries. A batch of eight divides the fetch cost over eight jobs; bclaim/3 is the surface.
  • Claim cadence (:beat_ms) sets how often a consumer reaches for work when the queue is quiet. A parked consumer spends no round-trips waiting, so the beat governs the housekeeping rhythm, not the busy-path throughput.

The product — outstanding claims times jobs per claim — is the structural throughput. Measured milliseconds are a property of a particular machine, network, and load; they are what a benchmark reports, not what a capacity plan is built from. The plan is in round-trips, and the round-trips come from the knobs.

Plan in round-trips, the cost the design controls: outstanding claims (pool width) times jobs per claim (batch size) is the structural throughput. A measured millisecond is a benchmark of one moment; the round-trip plan holds across moments.

§3

The lease window and the release knob

consumer.ex · pump.ex

Two knobs are about correctness under failure, and they bound the plan. :lease_ms is the window a claimed job holds before it is considered stalled and reaped. Set it shorter than the time a healthy handler takes and healthy jobs are reaped mid-flight and run twice; set it far longer than the handler and a genuinely stalled worker holds its jobs for too long before another worker recovers them. The lease is sized to the handler’s real working time with headroom, and a larger batch claim multiplies the leases a single stall can strand.

:pump_batch is the release side, not the claim side. The release cadence — EchoMQ.Pump — promotes due scheduled work and fires repeatables on its own beat, and :pump_batch is the promote LIMIT per beat: how many due entries are released into the pending set at once. It governs how fast delayed work becomes claimable, which is upstream of the workers. EchoMQ.Pump is the release cadence, not the dequeue — it is cited here only as the release-batch knob, and never as the worker claim loop.

:lease_ms is sized to the handler’s real working time with headroom — too short reaps healthy jobs, too long strands stalled ones. :pump_batch is the release-side promote LIMIT, upstream of the workers; EchoMQ.Pump releases work, it does not claim it.

§4

Ceiling and floor

the pool vs the limiter

The pool and the rate limiter pull in opposite directions, and a plan uses both. Pool width and batch size are the floor: they set how much throughput the workers can offer. The rate limiter is the ceiling: a per-window counter that caps how much work is admitted per unit time, regardless of how wide the pool is. A deployment sizes the pool to keep up with demand and sets the limiter to protect a downstream that cannot absorb the full rate.

The floor and the ceiling. Select a band to read what bounds it. The pool sets how much the workers can do; the limiter caps how much they are allowed to do. The plan answers both.
ceiling — rate limiter caps admitted work per window working band — throughput between floor and ceiling floor — pool width × batch size, what workers can offer can they keep up? · should they be allowed to? size the pool to the demand; set the limiter to protect downstream
readout · Select a band. The floor is the pool — pool width times batch size, what the workers can offer. The ceiling is the rate limiter — a per-window cap on admitted work. The plan answers both: can the workers keep up, and should they be allowed to.

So capacity planning is two questions, not one. Can the workers keep up — a floor set by pool width, batch size, and the lease window. And should they be allowed to — a ceiling set by the rate limiter. The floor is concurrency made parallel; the ceiling is a budget per window. Neither is the count of BEAM processes.

the pattern

Plan capacity from the system’s structure, not a one-off measurement: the levers that set how much work can flow, and the cap that limits how much is allowed.

its EchoMQ application

Pool width, batch size (bclaim/3), the claim cadence (:beat_ms), and the lease window (:lease_ms) are the floor reasoned in round-trips; the rate limiter (a per-window counter) is the ceiling that caps the result from above.

Capacity is two questions: can the workers keep up — the floor of pool width, batch size, and the lease window — and should they be allowed to — the ceiling of the rate limiter. The floor is concurrency made parallel; the ceiling is a budget per window.

§5

The pattern, applied

echo/apps/codemojex

In codemojex the scoring consumers drain the cm queue. The plan is the pool width for the scoring fleet, the lease window sized to the scoring handler, and :pump_batch for how fast timed game events release work — all reasoned in round-trips, so the plan holds whatever the day’s network latency happens to be. A benchmark would report a number for one moment; the structural plan holds across moments.

Sizing a pool against a real production load, recovering stalled leases at scale, and the metrics that confirm the plan are the queue’s scaling layer, taught in the EchoMQ course.

Notes on ValkeyBLPOP lets the cadence cost nothing on a quiet queue: a parked consumer holds the connection on the wake key and spends no round-trips until work arrives or the beat elapses, so :beat_ms governs housekeeping, not busy-path throughput — valkey.io/commands/blpop.

§6

References

sources · related

Sources

  • Valkey — BLPOP — the blocking park that makes the claim cadence free on a quiet queue.
  • Valkey — ZPOPMIN — the per-claim fetch whose count, outstanding, is the unit of a capacity plan.
  • Valkey — Pipelining — the round-trip model that makes outstanding claims the honest planning unit rather than measured time.

Related in this course