ergon_pgmq_consumer (ergon v0.5.0)

View Source

Streams messages off a pgmq queue, one batch at a time.

Replaces the Broadway pipeline. What it keeps is the delivery contract; what it drops is the demand protocol, which existed to move backpressure between stages that no longer exist.

The cycle

Read a batch, hand every message to the executor pool, wait for all of them, then archive the ones that succeeded in a single call. Repeat.

That shape is the backpressure, which is why there is no equivalent of ergon_worker's in-flight counter here: exactly one batch is ever outstanding, and nothing is read until the last one is accounted for. Do not add a counter by analogy with the job side. It would be measuring something that cannot exceed one batch.

A full batch means there is probably more waiting, so the next cycle starts immediately rather than sleeping out the poll interval. A short batch means the queue is drained, and the consumer goes back to waiting.

Archive means acknowledge

A message is archived only after its handler returned ok. A failure is reported by not archiving: the visibility timeout expires and pgmq redelivers. There is no negative acknowledgement, and nothing here deletes a message outright.

That is what makes delivery survive a BEAM crash mid-batch. Whatever was in flight was never archived, so it comes back. The cost is that redelivery is at-least-once, so handlers must tolerate seeing a message twice.

Two consequences of batching worth knowing:

  • One slow message delays its siblings' archive, since the cycle waits for the whole batch. Bounded by handler_timeout, which defaults to the visibility timeout.
  • A message whose handler always fails is redelivered forever. pgmq has no dead-letter queue; read_ct counts deliveries and is the only signal a handler has to give up on one.

Waking up

The poll is always underneath. On top of it, optionally, either a LISTEN subscription through the node's shared ergon_listener, or a server-side long poll that blocks until a message arrives. See ergon_pgmq_queue for which to pick.

Summary

Types

args()

-type args() :: #{queue := pgmq_queue(), pool := atom(), read_opts := query_options()}.

pgmq_queue()

-type pgmq_queue() ::
          #{name := binary(),
            poll_interval := pos_integer(),
            batch_size := pos_integer(),
            visibility_timeout := pos_integer(),
            concurrency := pos_integer(),
            handler_timeout := timeout() | undefined,
            notify_channel := binary() | undefined,
            read_strategy := pgmq_read_strategy()}.

pgmq_read_strategy()

-type pgmq_read_strategy() ::
          plain | grouped | grouped_head | grouped_rr |
          {long_poll, MaxSeconds :: pos_integer(), IntervalMs :: pos_integer()} |
          {long_poll, plain | grouped | grouped_head | grouped_rr, pos_integer(), pos_integer()}.

query_options()

-type query_options() ::
          #{pool => atom(),
            trace => boolean(),
            include_statement_span_attribute => boolean(),
            queue => boolean(),
            decode_opts => list(),
            pool_options => list()}.

Functions

child_spec(Args)

-spec child_spec(args()) -> supervisor:child_spec().

Child spec for a host that supervises its own pipelines.

ergon:start_consumer/2 is the usual way in, but a consumer is an ordinary gen_server and a host that already owns a supervision tree may prefer to place it there. Note this spec covers the consumer alone: started this way, it has no executor pool of its own, so it must be given one.

handle_call(Request, From, State)

handle_cast(Msg, State)

handle_continue/2

handle_info/2

init/1

-spec init(args()) -> {ok, map(), {continue, cycle}}.

start_link(Args)

-spec start_link(args()) -> {ok, pid()} | {error, term()}.