jonnifyredis
Redis Patterns / R3 · Reliable Queues / Blocking vs polling

R3.05 · Reliable Queues

Blocking vs polling

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

The reliable queue answers where a job lives: it parks in an in-flight list under a lock so a crash leaves it recoverable. This module answers a second question that queue raises — how does a worker take the next job off an empty queue? A queue is empty most of the time. A worker that spins a RPOP/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 worker parks on a blocking primitive, and Redis returns the instant work arrives. Slide the poll interval below and switch to block to watch the wasted polls and the pickup latency collapse.

The 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. It does not park on the work list. It parks on a dedicated marker ZSET, emq:{queue}:marker, with BZPOPMIN — a doorbell. When a producer adds a job to wait, it also ZADDs a member onto the marker, which makes the parked BZPOPMIN return; the worker then fetches the job from wait on its main connection. Same trade as BLMOVE — block instead of poll, wake the instant work arrives, burn no idle round-trips — applied through a separate signalling key rather than the work list.

Poll vs block · a job arrives at t = 330 ms over a 1000 ms window

200ms
worker timeline (1000 ms) job @330ms —

Polling trades latency for round-trips on one dial and pays both; blocking parks the worker and returns the instant work arrives, paying neither. EchoMQ blocks on a marker ZSET, not the work list.

The busy-poll cost

The naive wait is a spin: RPOP wait; 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.

Blocking pop

A blocking pop — BLMOVE, BRPOPLPUSH, or BZPOPMIN 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 busy — a command sent on the same connection waits behind the block. EchoMQ avoids that by parking the block on a dedicated blocking connection (EchoMQ.RedisConnection.blocking_connection/1, documented as "a dedicated blocking connection for operations like BRPOPLPUSH or BZPOPMIN"), leaving the worker's main command connection free for fetches and acknowledgements. 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 blocking connection keeps off the main one.

The marker wake-up

EchoMQ's blocking-fetch path makes the handshake precise. The worker parks on emq:{queue}:marker — a ZSET, built by EchoMQ.Keys.marker/1 and documented as the "marker sorted set (for blocking operations)". The blocking-fetch internals are EchoMQ.Worker.wait_for_job/2 → do_wait_for_job/3 (both private worker functions): do_wait_for_job/3 runs BZPOPMIN marker_key timeout and returns :job_available when a marker arrived or :timeout when none did.

The producer side rings the doorbell: adding a job runs addBaseMarkerIfNeeded, which is ZADD emq:{queue}:marker 0 "0" (verified in addStandardJob-9.lua and drain-5.lua). The ZADD makes the parked BZPOPMIN return, and the worker fetches the job. The marker is a signal, not a queue: BZPOPMIN pops the lowest-scored member, so one ZADD wakes one parked BZPOPMIN. The third dive walks the full handshake and the fan-out fairness.

# EchoMQ.Worker.do_wait_for_job/3 — park on the marker, wake when a job is added (real, Elixir)
case Redix.command(conn, ["BZPOPMIN", marker_key, timeout_seconds], timeout: :infinity) do
  {:ok, nil}                  -> :timeout         # the park elapsed, no job
  {:ok, [_key, _member, _sc]} -> :job_available   # a marker arrived → a job is available
end

EchoMQ's worker parks on BZPOPMIN emq:{queue}:marker; the producer's ZADD marker 0 "0" rings the doorbell, and the parked block returns — one ring wakes one worker.

When to Use

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

ConcernChoice
Wait for the next jobblock on a primitive with a timeout, not a RPOP/sleep loop
The primitiveBLMOVE/BRPOPLPUSH on the work list, or BZPOPMIN on a marker ZSET
The wake-up signalthe producer ZADDs the marker (ZADD emq:{queue}:marker 0 "0")
The connectiona dedicated blocking connection, so the main command connection stays free
Bound the waita timeout, so the worker re-checks liveness and is never parked forever

Block on a primitive with a timeout, ring the marker when a job is added, and park the block on a dedicated connection — the four choices this module's three dives make precise.

The three dives

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-specific marker handshake that wakes the worker. Read them in order — the cost first, the primitive second, the marker wake-up third.

the pattern

Block 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 wakes one parked worker.

in EchoMQ

The worker parks on BZPOPMIN emq:{queue}:marker via do_wait_for_job/3 on a blocking_connection/1, woken by the producer's ZADD marker 0 "0" (addBaseMarkerIfNeeded). The Go port's time.Sleep empty-queue loop is the cautionary contrast.

The pattern says block on a signal and ring it when work arrives; EchoMQ's worker parks on the marker ZSET, the producer rings it with a ZADD, and the Go port's sleep-poll loop shows the cost of not.

A door, not a depth

This module cites one excerpt of EchoMQ's protocol — the marker BZPOPMIN block and the producer's ZADD that wakes it — as proof the blocking fetch ships. The full worker fetch loop, the dedicated blocking connection's lifecycle, and the polyglot concurrency models that govern how each runtime parks and wakes are the subject of the dedicated EchoMQ course, built next with this toolkit. The worker fetch loop is E2 · the engine; the marker and heartbeat coordination is E6 · the lifecycle. This module teaches the blocking-vs-polling pattern; that course teaches the engine that runs it.

R3.05 closes the Reliable Queues chapter's pickup story: after the processing list and the at-least-once guarantees, how a worker takes the next job off an empty queue is the last detail. Return to the Reliable Queues chapter to continue.

References

The Redis primitives behind the blocking wait — the marker pop, the blocking moves, the sorted set, and the ZADD that rings it — and where the engine that parks on them is taught.

Sources

  • Redis — BZPOPMIN — block on a sorted set until a member is added, then pop the lowest-scored; the marker pop EchoMQ's worker parks on.
  • Redis — BLMOVE — the modern blocking move, the reliable-queue pop that parks on the work list.
  • Redis — BRPOPLPUSH — the older blocking reliable-queue pop, the form BLMOVE succeeds.
  • Redis — ZADD — add a member to a sorted set; the producer's ZADD marker that wakes a blocked worker.
  • Redis — Sorted sets — the ZSET data type behind the marker and the BZPOPMIN pop.
  • BullMQ — the queue protocol — the marker / BZPOPMIN worker wait EchoMQ ports.

Related in this course