jonnifyechomq

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

beat 1 recover: :reap (default) · :stalled · :off beat 2 promote: due schedules → pending (pump_batch) beat 3 drain: Lanes.claim, settle, until :empty beat 4 park: BLPOP wake (one beat) — then loop park, don't poll — a parked consumer costs the wire nothing
Beat 1 — recover
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

handler verdict :ok transition run Jobs.complete
verdict :ok → transition Jobs.complete
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

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq)

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

Related in this course