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
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.
| Concern | Choice |
|---|---|
| Wait for the next job | block on a primitive with a timeout, not a RPOP/sleep loop |
| The primitive | BLMOVE/BRPOPLPUSH on the work list, or BZPOPMIN on a marker ZSET |
| The wake-up signal | the producer ZADDs the marker (ZADD emq:{queue}:marker 0 "0") |
| The connection | a dedicated blocking connection, so the main command connection stays free |
| Bound the wait | a 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 busy-poll cost
The naive RPOP/sleep loop: every empty poll is a wasted round-trip, a job
waits up to one interval before pickup, and the interval is the only dial — no setting wins.
Blocking pop
BLMOVE/BRPOPLPUSH/BZPOPMIN
with a timeout park the connection until work arrives — no interval, no wasted round-trips — on a
dedicated blocking connection that keeps the main one free.
The marker wake-up
The EchoMQ handshake: the worker parks on BZPOPMIN emq:{queue}:marker; the
producer ZADDs the marker; the parked block returns; one ring wakes one worker.
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.
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
BLMOVEsucceeds. - Redis — ZADD — add a member to a sorted set; the producer's
ZADD markerthat wakes a blocked worker. - Redis — Sorted sets — the ZSET data type behind the marker and the
BZPOPMINpop. - BullMQ — the queue protocol — the marker /
BZPOPMINworker wait EchoMQ ports.
Related in this course
- R3.05.1 · The busy-poll cost — the cost the polling loop pays.
- R3.05.2 · Blocking pop — the primitive that removes it.
- R3.05.3 · The marker wake-up — the EchoMQ marker handshake.
- R3 · The reliable queue — the family in one place, where the blocking variant is introduced.
- R3.01 · The processing list — the wait/active lists the worker pops from.
- R3 · Reliable Queues — the chapter.
- E6 · The job lifecycle — the dedicated EchoMQ course: the marker / heartbeat coordination and the polyglot concurrency models.