Jobs, lanes & the consumer · dive 03
The consumer loop
Park, do not poll. A supervised loop that costs the wire nothing while it waits, and survives a bad job.
The consumer is the loop that owns the rhythm. A supervised process holding its own connector
beats on a cadence: reap expired leases, promote due schedules,
drain the ring with rotating claims, then park on the wake key with
BLPOP until readiness arrives or the beat elapses. It does not spin asking "is there
work" — a parked consumer costs the wire nothing. Step the four beats on the right, then read the loop below.
The beat · step the four moves
The loop — reap, promote, drain, park
The whole consumer is one recursive function. Each pass checks for a control message at the settle point, runs
the two pump sweeps, drains the ring, parks for one beat, and recurses. EchoMQ.Consumer
runs it on a supervised process holding a dedicated connector.
# echo_mq — EchoMQ.Consumer # The beat. check_control honors a stop/shutdown at the settle point (between # jobs, never inside one); reap returns expired leases; promote releases due # schedules; drain claims and runs until the ring is empty; park blocks on the # wake key for one beat. Then it recurses — the loop never busy-waits. defp loop(s) do check_control() {:ok, _} = Jobs.reap(s.conn, s.queue) # expired leases → pending {:ok, _} = Jobs.promote(s.conn, s.queue, s.pump_batch) # due schedules → pending drain(s) # claim and run until the ring is empty park(s) # BLPOP the wake key, or the beat elapses loop(s) end
The consumer is one recursive beat: reap, promote, drain, park. The rhythm is the function, and the park is what keeps it cheap.
Park, don't poll
The fourth beat is the one that matters for cost. Instead of looping to ask whether work has arrived, the
consumer blocks on the wake key with BLPOP for up to one beat. When a
producer or a completion makes a lane serviceable, the script that returns the lane to the ring also pushes a
wake — so the parked consumer returns at once. No readiness, no wake: the block elapses after the beat and the
loop runs anyway (the beat doubles as the pump cadence).
# echo_mq — EchoMQ.Consumer # park blocks on emq:{q}:wake for up to one beat. The blocking verb runs on the # consumer's OWN connector lane, so a parked BLPOP never stalls another caller — # a blocking command holds its connection for the whole block. defp park(s) do secs = :erlang.float_to_binary(s.beat_ms / 1000, decimals: 3) wake = Keyspace.queue_key(s.queue, "wake") _ = Connector.command(s.conn, ["BLPOP", wake, secs], s.beat_ms + 2_000) :ok end
A wake is one tiny element pushed onto emq:{q}:wake by every script that makes a lane
serviceable — grouped admission, a completion that frees a concurrency slot, a resume, a promote. The list is
trimmed to a small bound, so a burst of wakes does not accumulate. The consumer pops one and continues.
A parked consumer holds no CPU and sends no command — it waits on the wire and is woken by the same script that creates the work. That is what "park, don't poll" buys.
The drain — a raising handler becomes a typed retry
The third beat runs the work. drain/1 claims off the rotating ring; for each job it
runs the handler, captures the verdict, and either completes or retries. The handler takes
%{id:, payload:, attempts:, group:} and answers :ok or
{:error, reason} — but a handler that raises, exits, or throws is
caught and converted to a typed retry. A bad job does not take the loop down.
# echo_mq — EchoMQ.Consumer # drain claims off the ring and runs the handler. A raise/exit/throw is caught # and turned into {:error, reason} — the loop survives a bad job. :ok completes # (token-fenced on att); {:error, _} retries with backoff or dies at max. defp drain(s) do check_control() case Lanes.claim(s.conn, s.queue, s.lease_ms) do :empty -> :ok {:ok, {id, payload, att, group}} -> verdict = try do s.handler.(%{id: id, payload: payload, attempts: att, group: group}) rescue e -> {:error, Exception.message(e)} catch :exit, reason -> {:error, "exit: " <> inspect(reason)} :throw, value -> {:error, "throw: " <> inspect(value)} end case verdict do :ok -> Jobs.complete(s.conn, s.queue, id, att) {:error, reason} -> Jobs.retry(s.conn, s.queue, id, att, s.retry_delay_ms, s.max_attempts, to_string(reason)) end drain(s) end end
Handler outcome · which transition runs
The catch turns three failure modes — a return, a raise, an exit/throw — into the same typed
{:error, reason}, so the retry path is uniform and the loop is never interrupted by a
handler bug.
Drain and stop, and the rhythm defaults
The loop traps exits, so control arrives as messages honored at the settle points — between jobs, never inside
one. stop/2 drains: the job in hand settles, nothing more is claimed, and the process
exits :normal; its self-started connector lane closes with it.
# echo_mq — EchoMQ.Consumer # stop/2 is synchronous: it monitors the loop, sends a stop message, and returns # when the loop is down. A parked consumer notices when its park returns, so # stop latency is bounded by the beat plus the job in hand. def stop(pid, timeout \\ 5_000) when is_pid(pid) do ref = Process.monitor(pid) send(pid, {:emq_stop, self(), ref}) receive do {:DOWN, ^ref, :process, ^pid, _reason} -> :ok after timeout -> Process.demonitor(ref, [:flush]) {:error, :timeout} end end
The rhythm is tuned by five options, with sensible defaults: :lease_ms 30 000
(the claim window), :beat_ms 1 000 (the park duration and the pump cadence),
:retry_delay_ms 1 000, :max_attempts 3, and
:pump_batch 100 (how many schedules promote per beat). The consumer is a permanent
child via child_spec/1; its dedicated connector lane dies and returns with it.
EchoMQ.Pump is the optional dedicated cadence twin — a separate process that runs the
same reap and promote sweeps on its own schedule, so the pump cadence can be tuned apart from the drain. It is
named here; its depth lives in the lifecycle controls module.
A consumer that traps exits and parks can be stopped cleanly, tuned by a handful of options, and restarted whole — the loop and its lane live and die together.
Pattern & implementation
A reliable consumer claims, runs, and acknowledges work, blocking for the next item rather than busy-polling a list. Reliable queues teaches the blocking-consumer move.
EchoMQ.Consumer runs reap → promote → drain → park,
blocking on the wake key with BLPOP, and converts a raising handler to a typed retry
— so a parked loop is cheap and a bad job is survivable.
The pattern says: block for the next item, do not busy-poll. The implementation is the park beat —
BLPOP the wake key, woken by the script that makes the work.
Recap — the loop that owns the rhythm
The consumer is a supervised, exit-trapping loop: reap expired leases, promote due schedules, drain the ring
with rotating claims, then park on the wake key with BLPOP. A raising handler is caught
and converted to a typed retry; stop/2 drains the job in hand. That closes the module:
the producer admits once, the fair worker claims in turn, and this loop owns the cadence.
Back to the module hub for the next module — Jobs, lanes & the consumer — or on to the rest of the Queue pillar from The Queue.
References
Where the parking loop and the typed-retry catch are grounded, and the commands the loop uses.
Sources
- Valkey — BLPOP — the blocking pop the consumer parks on instead of polling.
- Valkey — LMOVE — the ring rotation the drain claims off of, one step per claim.
- Redis — EVALSHA — load-once, run-by-SHA dispatch of the claim, complete, and retry scripts.
- Valkey — Documentation — the substrate of record the consumer beats against.
Related in this course
- Jobs, lanes & the consumer — the module this dive belongs to.
- Fair lanes & the ring — the ring this loop drains.
- Enqueue & claim — the verbs the loop runs.
- redis-patterns · Reliable queues — the reliable-queue / consumer pattern.