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: recover expired leases (Jobs.reap/2 by default),
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
recover(s) — Jobs.reap(conn, queue) under the default :reap
expired leases return to pending on the server clock before any new claim; :stalled runs Stalled.check/3 instead, and :off runs no sweep.
The loop — recover, promote, drain, park
The standalone consumer is one recursive function. Each pass honors a control message at the settle point,
then runs three legs in a with: the recovery sweep, the promote sweep, and the drain.
When all three succeed the beat is healthy, and the loop 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 or a shutdown at the settle point # (between jobs, never inside one); exit_if_ttl_elapsed is a no-op unless the # opt-in :ttl_ms bounds the worker's lifetime. defp loop(s) do check_control() exit_if_ttl_elapsed(s) # three legs, each answering {:ok, s} when healthy or {:degraded, s} on a wire error with {:ok, s} <- recover(s), {:ok, s} <- promote(s), {:ok, s} <- drain(s) do # a healthy beat: reset the degraded streak, park for one beat, go again s = reset_degraded(s) park(s) loop(s) else # a wire error on any leg: skip the rest of the beat and back off {:degraded, s} -> Process.sleep(degraded_sleep(s)) loop(s) end end # The recovery leg: exactly one mechanism per beat, chosen at start by :recovery. # :off runs none. defp recover(%{recovery: :off} = s), do: {:ok, s} # :reap, the default: Jobs.reap/2 returns expired leases to pending defp recover(%{recovery: :reap} = s) do case Jobs.reap(s.conn, s.queue) do {:ok, _} -> {:ok, s} {:error, reason} -> {:degraded, degrade(s, :reap, reason)} end end # :stalled: Stalled.check/3 under the consumer's own policy, so max_stalled applies defp recover(%{recovery: :stalled} = s) do case Stalled.check(s.conn, s.queue, s.stalled_opts) do {:ok, %{recovered: _, dead: _}} -> {:ok, s} {:error, reason} -> {:degraded, degrade(s, :stalled, reason)} other -> {:degraded, degrade(s, :stalled, other)} end end # Promote runs on every recovery arm: due schedules back to pending or their lane, # pump_batch at most. defp promote(s) do case Jobs.promote(s.conn, s.queue, s.pump_batch) do {:ok, _} -> {:ok, s} {:error, reason} -> {:degraded, degrade(s, :promote, reason)} end end
A wire error on any leg does not crash the loop. The leg answers
{:degraded, s}, the streak of degraded beats grows by one (with one warning on the
first), the rest of the beat is skipped, and the loop sleeps before the next turn. That sleep is the one place
the consumer uses EchoMQ.Backoff:
# echo_mq — EchoMQ.Consumer # the degraded-beat sleep: exponential on beat_ms, capped at max(30_000, beat_ms), # indexed by the streak of degraded beats (1 on the first) defp degraded_sleep(%{beat_ms: beat_ms, degraded: streak}) do Backoff.delay_ms({:exponential, beat_ms, max(30_000, beat_ms)}, streak) end
The consumer is one recursive beat: recover, promote, drain, park. The rhythm is the function, the park is what keeps it cheap, and an outage slows the beat instead of ending it.
This loop has no wire-6 path. Its drain leg's Lanes.claim/3 and the
default recovery leg's Jobs.reap/2 run scripts with no native twin, so a
wire: 6 connector refuses them with {:wire6_unsupported, name},
although the promote, settle and :stalled legs call verbs that have one. A sweep runs because this
process calls it; nothing on the server runs one on its own.
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 sweep 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 returns a lane to the ring — grouped admission, a completion or a retry that frees a concurrency
slot, a resume, a promote or a reap that brings a lane job back. The list is trimmed to 64 entries, so a burst
of wakes does not accumulate. The consumer pops one and continues. The beat is validated at start: a
non-positive :beat_ms raises before any process is spawned, so the park never blocks
forever.
A parked consumer holds no CPU and sends no command — it waits on the wire and is woken by the script that puts a lane on the ring:
@genqueue when work arrives, and the lane paths of the settling scripts when a slot frees. A
flat @enqueue pushes no wake; the consumer drains lanes. That is what "park, don't poll"
buys.
The drain — run the handler, settle the verdict
The third beat runs the work. drain/1 claims through
Lanes.claim/3, the rotating grouped claim, so this loop serves the ring's lanes; it
does not call the flat Jobs.claim/3. For each job it runs the handler, settles the
verdict, and claims again until the ring answers :empty. A wire error on the claim
degrades the beat.
# echo_mq — EchoMQ.Consumer # drain claims off the ring until :empty. track/untrack feed the opt-in :locks # plane (a no-op when it is unset); untrack sits in an after, so it fires even # when the settle raises. defp drain(s) do check_control() # the settle boundary between claims: a recycle here never interrupts a settle exit_if_ttl_elapsed(s) case Lanes.claim(s.conn, s.queue, s.lease_ms) do :empty -> {:ok, s} {:ok, {id, payload, att, group}} -> track(s, id, att) try do verdict = run_handler(s, id, payload, att, group) settle(s, id, att, verdict) after untrack(s, id) end drain(s) {:error, reason} -> {:degraded, degrade(s, :claim, reason)} end end
run_handler/5 builds the handler's map and runs it under a
catch. The handler takes %{id:, payload:, attempts:, group:} plus two additive keys,
cancel: and checkpoint:, and answers
:ok, {:ok, output}, {:error, reason}
or {:cancel, reason}. A handler that raises, exits, or throws is caught
and converted to {:error, reason}: a bad job does not take the loop down.
# echo_mq — EchoMQ.Consumer # cancel_token is nil unless the opt-in :cancel is on; checkpoint replaces the # row's payload, so a redelivery resumes from it. defp run_handler(s, id, payload, att, group) do token = cancel_token(s) forwarder_track(s, id, token) handler_map = %{ id: id, payload: payload, attempts: att, group: group, cancel: token, checkpoint: fn p -> Jobs.update_data(s.conn, s.queue, id, p) end } # a raise, an exit or a throw becomes the same typed {:error, reason} try do s.handler.(handler_map) rescue e -> {:error, Exception.message(e)} catch :exit, reason -> {:error, "exit: " <> inspect(reason)} :throw, value -> {:error, "throw: " <> inspect(value)} after forwarder_untrack(s) end end
settle/4 maps the verdict to a transition.
:ok and {:ok, output} complete, fenced on the token
att; {:cancel, reason} completes with a
cancelled: result and is never retried; {:error, reason} calls
Jobs.retry/7 with the consumer's fixed retry_delay_ms — the
same delay on every attempt, 1 000 ms unless the consumer is started with another — and
@retry moves the job to the dead set once attempts reaches
max_attempts.
# echo_mq — EchoMQ.Consumer # settle runs the transition, then logs a settle that hit an outage, so an # at-least-once redelivery of that job is visible. defp settle(s, id, att, verdict) do result = do_settle(s, id, att, verdict) warn_settle_loss(s, id, result) result end defp do_settle(s, id, att, :ok), do: complete(s, id, att, nil) defp do_settle(s, id, att, {:ok, output}), do: complete(s, id, att, output) # … defp do_settle(s, id, att, {:cancel, reason}), do: complete_cancelled(s, id, att, reason) defp do_settle(s, id, att, {:error, reason}), do: retry(s, id, att, reason) # … defp complete(s, id, att, output) do # the token att fences the completion: a stale holder is refused result = Jobs.complete(s.conn, s.queue, id, att) # … the opt-in :record emits a durable completion record here result end # a failed job waits the fixed retry_delay_ms (1_000 by default); no backoff curve defp retry(s, id, att, reason) do err = to_string(reason) result = Jobs.retry(s.conn, s.queue, id, att, s.retry_delay_ms, s.max_attempts, err) if Map.get(s, :record) && result == {:ok, :dead} do _ = Completions.emit(s.conn, s.queue, id, "dead", att, error: err) end result end
Handler outcome · which transition runs
settle → complete → Jobs.complete(conn, queue, id, att): @complete removes the job from active, credits its lane, deletes the row and counts metrics:completed, fenced on attempts
The catch turns three failure modes — a raise, an exit or a throw — into the same
typed {:error, reason} a returned error already is, so the retry path is uniform and a handler bug never interrupts
the loop. The retry waits the fixed retry_delay_ms; the consumer computes no backoff
curve for a job.
Drain and stop, the defaults, and who else beats
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 options with defaults: :lease_ms
30 000 (the claim window), :beat_ms 1 000 (the park duration and the sweep
cadence), :retry_delay_ms 1 000 (the fixed wait before a retry),
:max_attempts 3, :pump_batch 100 (how many schedules promote
per beat), and :recovery :reap. The consumer is a permanent
child via child_spec/1; its dedicated connector lane dies and returns with it.
A lone consumer is its own metronome. In a pool, an EchoMQ.Metronome takes the beat
over: one process per queue holds the single BLPOP on the wake key, runs the reap and
the promote on its beat, and pokes each idle consumer to claim once; a consumer started with
:metronome neither parks nor sweeps. EchoMQ.Pump is a separate
opt-in cadence: each tick promotes due schedules, fires due repeatables and delivers cross-queue flow
completions. The Pump does not reap. 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 recover → promote → drain → park,
blocking on the wake key with BLPOP, and converts a raising handler to a typed retry
after a fixed delay — 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 lane script that makes the work ready.
Recap — the loop that owns the rhythm
The consumer is a supervised, exit-trapping loop: recover expired leases (Jobs.reap/2
by default), promote due schedules, drain the ring through Lanes.claim/3, then park on
the wake key with BLPOP. run_handler catches a raising handler
and settle turns it into a retry after the fixed retry_delay_ms;
a wire error degrades the beat instead of ending it; 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, the recovery leg 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.
- Completion & recovery — the complete, retry and reap transitions the loop calls.
- Lifecycle controls — the stall sweep, the Pump and the operator plane.
- redis-patterns · Reliable queues — the reliable-queue / consumer pattern.