jonnify·redis /redis-patterns/queues/blocking-vs-polling

R3.05 · Reliable Queues

Blocking vs polling.

Guarantee at-least-once message delivery using an atomic transfer to a processing list, enabling recovery if consumers crash before completing work.

The reliable queue answers where a job lives. This module answers a second question it raises — how does a worker take the next job off an empty queue? A worker that spins a pop/sleep loop on an idle queue burns a round-trip on every empty poll and picks a job up to one interval late. The blocking variant replaces that spin: the consumer parks until work arrives. The grounding is the real Elixir EchoMQ.Consumer (echo/apps/echo_mq) — BLPOP emq:{queue}:wake on a dedicated connector lane, rung by the admit script's LPUSH, with the beat as the fallback cadence and one EchoMQ.Metronome per pool. The worked consumer is codemojex.

§1

The blocking variant

reliable-queue.md.txt · Blocking Variant

The source's reliable-queue loop, in polling form, pops the work list and falls back to a sleep: pop; if the result is nil, sleep a fixed interval; retry. For efficient consumption without polling, the source offers the blocking form — BLMOVE work_queue processing RIGHT LEFT 30 — which waits up to thirty seconds for a message. If the queue is empty, the connection parks on the server rather than returning immediately. There is no poll interval and no wasted round-trip: the call returns the instant an element is available, or after the timeout elapses.

EchoMQ keeps the idea and changes the primitive. The consumer does not park on the work list. It parks on a dedicated wake key, emq:{queue}:wake — a short capped list — with BLPOP emq:{queue}:wake <beat>. When a producer admits a job, the admit script runs LPUSH emq:{queue}:wake '1' (then LTRIM emq:{queue}:wake 0 63 to bound the list), which returns the parked BLPOP; the consumer then drains the ring with EchoMQ.Lanes.claim/3. Same trade as BLMOVE — park instead of poll, wake the instant work arrives, burn no idle round-trips — applied through a separate signalling key, so a wake fires no matter which lane the new work landed on.

Poll vs park — a job arrives at t = 330 ms over a 1000 ms window. Choose a mode and a poll interval; the timeline marks the checks and the pickup.
200
worker timeline (1000 ms) job @330ms —

Static verdict — poll → checks every interval, several empty, pickup up to one interval late · park → one parked BLPOP, zero empty checks, returned at the arrival.

Polling trades latency for round-trips on one dial and pays both; parking returns the consumer the instant work arrives, paying neither. EchoMQ parks on a wake key, not the work list.

§2

The busy-poll cost

the loop the Blocking Variant replaces

The naive wait is a spin: pop the work list; if the result is nil, sleep(interval); repeat. Every pass that returns nil is a wasted round-trip — a command sent and a nil sent back, for no work. On a queue that is empty most of the time, that is the steady state.

A job rarely arrives exactly when the worker polls. It arrives in the gap between two polls and waits the rest of the gap before the next poll picks it up, so average pickup latency is about half the interval. The interval is the only dial, and it has no good setting: a short interval cuts latency but multiplies the empty round-trips on an idle queue; a long interval cuts the round-trips but lengthens pickup latency. No value is cheap on both axes, because polling pays for checking whether work exists separately from doing the work. The first dive quantifies the trade with a poll-cost calculator.

Polling pays a wasted round-trip on every empty poll and adds about half an interval of pickup latency; the interval dials one cost against the other, and no setting wins.

§3

Blocking pop

consumer.ex · the dedicated connector lane

A blocking pop — BLMOVE, BRPOPLPUSH, or BLPOP with a timeout — parks the connection on the server until an element is available or the timeout elapses. There is no poll interval, so a job is picked up the moment it arrives, not up to one interval later. There are no empty round-trips, because the call does not return until there is work or the timeout fires.

The cost is the connection. While a connection is parked on a blocking call, it is occupied — a command sent on the same connection waits behind the block. EchoMQ parks the block on a dedicated connector lane (the consumer's moduledoc: "a dedicated connector — blocking verbs get their own lane"), so the lane that drains and settles jobs is never tied up by a park. The second dive builds the primitive and the dedicated-connection discipline.

A blocking pop parks the connection until work arrives, removing the interval and the empty round-trips; the price is a tied-up connection, which a dedicated connector lane keeps off the drain lane.

§4

The wake-up doorbell

consumer.ex · park/1 (BLPOP wake)

EchoMQ's consumer loop makes the handshake precise. The loop is reap → promote → drain → park, repeating: it reaps expired leases, promotes due schedules, drains the ring with rotating claims, then parks on the wake key with BLPOP emq:{queue}:wake <beat> until readiness arrives as a wake or the beat elapses.

# EchoMQ.Consumer.park/1 — park on the wake key for one beat, no spin (real, Elixir)
defp park(s) do
  secs = :erlang.float_to_binary(s.beat_ms / 1000, decimals: 3)
  wake = Keyspace.queue_key(s.queue, "wake")          # emq:{queue}:wake
  _ = Connector.command(s.conn, ["BLPOP", wake, secs], s.beat_ms + 2_000)
  :ok
end

The producer side rings the doorbell from inside the admit Lua: pushing a job onto the ring runs LPUSH emq:{queue}:wake '1', then LTRIM emq:{queue}:wake 0 63 to cap the list at 64 tokens (the same two calls appear in the promote and reclaim scripts in jobs.ex, lanes.ex, and stalled.ex). The LPUSH returns the parked BLPOP, and the consumer drains. The wake key is a signal, not a queue: BLPOP pops one element, so one LPUSH returns one parked block. The beat (:beat_ms, default 1000) is the fallback: when no wake arrives, the BLPOP times out and the loop runs its reap/promote pump anyway, so a due schedule or an expired lease is never stranded. The third dive walks the full handshake and the pool's one-blocker EchoMQ.Metronome.

Notes on ValkeyBLPOP removes the first element of a list, or parks the connection until one is pushed or the timeout elapses — the engine returns exactly one of several connections parked on the same key, in the order they parked — valkey.io/commands/blpop.

EchoMQ's consumer parks on BLPOP emq:{queue}:wake; the admit script's LPUSH emq:{queue}:wake '1' rings the doorbell, the parked block returns, and the beat is the fallback — one ring returns one consumer.

§5

When to use

reliable-queue.md.txt · When to Use

Park instead of poll whenever a worker waits on a queue that is empty much of the time and pickup latency matters. A busy-poll loop is acceptable only when a blocking connection is not available, or when the producer cannot ring a signal and the worker must re-check on its own cadence. Otherwise the blocking form wins on both axes it trades against: lower latency and fewer round-trips at once. EchoMQ takes a third position for a pool — one EchoMQ.Metronome holds the single block per queue and pokes idle consumers, so the connection cost of blocking is paid once, not once per worker.

ConcernChoice
Wait for the next jobpark on a primitive with a timeout, not a pop/sleep loop
The primitiveBLMOVE/BRPOPLPUSH on a work list, or BLPOP on a wake key
The wake signalthe admit script LPUSHes the wake key (LPUSH emq:{queue}:wake '1', LTRIM … 0 63)
The connectiona dedicated connector lane, so the drain/settle lane stays free
Bound the waitthe beat (:beat_ms) is the timeout, so the loop re-runs its pump and is never parked forever
A poolone EchoMQ.Metronome blocks per queue and pokes idle consumers — one blocker, no herd

Park on a primitive with a timeout, ring the wake key when a job is admitted, and park the block on a dedicated lane — the choices this module's three dives make precise.

§6

The three dives

cost → primitive → wake

Each dive takes one part of the move, in order: the cost the polling loop pays, the blocking primitive that removes it, and the EchoMQ wake handshake that returns the parked consumer.

built ready to read soon specified
the pattern

Park on a primitive with a timeout instead of polling, so the worker wakes the instant work arrives and burns no idle round-trips. Ring a signal when work is added, so one ring returns one parked worker.

its EchoMQ application

EchoMQ.Consumer parks on BLPOP emq:{queue}:wake <beat> on a dedicated lane; the admit Lua LPUSHes the key, the beat is the fallback, and one EchoMQ.Metronome blocks once for a pool. codemojex's guess queue rides it.

§7

References

sources · related

Sources

Related in this course