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: 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

beat 1 reap: expired leases → pending (server clock) beat 2 promote: due schedules → pending (pump_batch) beat 3 drain: rotating Lanes.claim until :empty beat 4 park: BLPOP wake (one beat) — then loop park, don't poll — a parked consumer costs the wire nothing

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

handler verdict :ok transition run complete/4

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

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

Related in this course