ergon_pgmq (ergon v0.5.0)

View Source

Thin wrappers over pgmq, plus the queue and notification management a host needs.

pgmq is Ergon's other queue, and it is worth being clear about which is which. ergon.jobs is the bi-temporal job table: retries, workflow dependencies, history, one row per job. pgmq is a durable message transport: a host creates its own queues on it and streams through them at volume, and delivery is at-least-once by virtue of visibility timeouts rather than state transitions. Nothing here touches ergon.jobs.

ergon_pgmq_consumer is the read side; ergon_reconciler and ergon_health (Phase 6) are the operational side. Every query lives in priv/queries/pgmq/ and runs through ergon_sql, so nothing here builds SQL, except the *_sql/1,2 forms at the bottom, which exist precisely to hand SQL to a host's own migration.

Delivery contract

read/3 hides each message behind a visibility timeout. A message not archived before that timeout expires becomes visible again and is redelivered. So archive means acknowledge, and a message must be archived only after its handler succeeded. Failing to archive is how a failure is reported; there is no negative acknowledgement to send.

read_ct counts how many times a message has been delivered. Nothing here dead-letters a message that keeps failing, since pgmq has no such concept, so a handler that wants to give up on a poison message must check read_ct itself.

Sending inside your own transaction

send/2,3 and send_topic/2,3 are ordinary queries on Ergon's pool, so calling one inside ergon_repo:transaction/1 enlists it in that transaction: pgo binds the transaction's connection in the process dictionary and every nested query rides it. That makes a message and the rows that justify it commit or roll back together:

ergon_repo:transaction(fun() ->
    {ok, _} = ergon_repo:query("UPDATE accounts SET balance = balance - $1 WHERE id = $2",
                               [Amount, From]),
    {ok, _} = ergon_pgmq:send(~"payments", #{~"from" => From, ~"amount" => Amount})
end).

This is the transactional outbox pattern with no outbox: there is no window in which the balance moved but the message was lost, and none in which the message went out for a transfer that rolled back. The usual machinery (an outbox table, a poller, a reconciler for drift) exists to close a gap that does not open when the queue lives in the same database as the data. The same holds for ergon:enqueue/1.

Summary

Functions

Acknowledge MsgIds on Queue.

Bind a routing pattern to a queue.

Create a pgmq queue and its archive table.

The statement create_queue/1 runs, as literal SQL.

The channel Queue is notified on by default: pgmq_<queue>.

Stop notifying for Queue. Its consumers fall back to polling.

The statement disable_notify/1 runs, as literal SQL.

Drop a pgmq queue, its archive table, and any notification registration.

The statement drop_queue/1 runs, as literal SQL.

Register Queue for wake-up notifications on its default channel.

Like enable_notify/1 with an explicit channel. Re-registering moves it.

The statement enable_notify/1 runs, as literal SQL.

The statement enable_notify/2 runs, as literal SQL.

The header that places a message in a FIFO group.

Every pattern bound to Queue.

Health snapshot of one queue: total length, visible length, and the age of the oldest message.

Read up to Limit messages from Queue, hiding each behind a visibility timeout of VtSeconds.

Like read/3 with an explicit strategy.

Like read/4 with driver options, which is how a long-polling consumer pins the call to its own connection pool.

Force-expire every in-flight visibility lease on Queue, making the held messages immediately re-readable. Returns how many were released.

Send Message to Queue. Returns its message id.

Publish by routing key, fanning out to every queue whose bound pattern matches. Answers how many queues received it.

Like send_topic/2 with headers, e.g. a FIFO group.

Remove a binding. Answers whether one was actually removed.

Types

db_error()

-type db_error() ::
          empty_result | would_create_cycle |
          {job_not_found, ergon_job:job_id()} |
          {pgo_error, map()} |
          term().

pg_null()

-type pg_null() :: null.

pg_timestamp()

-type pg_timestamp() :: {calendar:date(), {0..23, 0..59, number()}} | infinity | '-infinity'.

pgmq_message()

-type pgmq_message() ::
          #{id := non_neg_integer(),
            read_ct := non_neg_integer(),
            message := json:decode_value(),
            headers := json:decode_value() | pg_null()}.

pgmq_metrics()

-type pgmq_metrics() ::
          #{queue_length := non_neg_integer(),
            queue_visible_length := non_neg_integer(),
            oldest_msg_age_sec := number() | pg_null()}.

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()}.

pgmq_topic_binding()

-type pgmq_topic_binding() ::
          #{pattern := binary(),
            queue_name := binary(),
            bound_at := pg_timestamp(),
            compiled_regex := binary()}.

query_options()

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

Functions

archive(Queue, MsgIds)

-spec archive(binary(), [non_neg_integer()]) -> {ok, [non_neg_integer()]} | {error, db_error()}.

Acknowledge MsgIds on Queue.

pgmq.archive moves them from pgmq.q_<queue> to the pgmq.a_<queue> audit table, so a processed message leaves a durable trail rather than vanishing. Returns the ids actually archived; an id already archived is silently absent.

An empty list short-circuits without a round-trip. That is not just an optimisation: it is the normal case for a batch in which every handler failed, and sending an empty array would cost a query per cycle to archive nothing.

archive/3

-spec archive(binary(), [non_neg_integer()], query_options()) ->
                 {ok, [non_neg_integer()]} | {error, db_error()}.

bind_topic(Pattern, Queue)

-spec bind_topic(binary(), binary()) -> ok | {error, db_error()}.

Bind a routing pattern to a queue.

* matches exactly one dot-separated segment and # matches zero or more, so logs.# catches both logs.error and logs.api.error while logs.* catches only the first. Idempotent: rebinding the same pattern to the same queue is a no-op.

create_queue(Queue)

-spec create_queue(binary()) -> ok | {error, db_error()}.

Create a pgmq queue and its archive table.

create_queue_sql(Queue)

-spec create_queue_sql(binary()) -> iodata().

The statement create_queue/1 runs, as literal SQL.

default_channel(Queue)

-spec default_channel(binary()) -> binary().

The channel Queue is notified on by default: pgmq_<queue>.

disable_notify(Queue)

-spec disable_notify(binary()) -> ok | {error, db_error()}.

Stop notifying for Queue. Its consumers fall back to polling.

disable_notify_sql(Queue)

-spec disable_notify_sql(binary()) -> iodata().

The statement disable_notify/1 runs, as literal SQL.

drop_queue(Queue)

-spec drop_queue(binary()) -> ok | {error, db_error()}.

Drop a pgmq queue, its archive table, and any notification registration.

drop_queue_sql(Queue)

-spec drop_queue_sql(binary()) -> iodata().

The statement drop_queue/1 runs, as literal SQL.

enable_notify(Queue)

-spec enable_notify(binary()) -> ok | {error, db_error()}.

Register Queue for wake-up notifications on its default channel.

This installs nothing. Ergon's migrations already schedule a single ergon.notify_pending_pgmq() tick that reads the registry every second and notifies each registered queue holding a visible message.

One tick rather than one cron job per queue, because every notifying transaction takes the global notification-queue lock at commit. That is the cost that makes trigger-per-insert designs plateau, measured at ~2.9K writes/sec with no resource saturation, against ~60K once notifications were batched into fewer transactions. A per-queue tick would put the notifying-transaction count back in proportion to the queue count; this keeps it at one per second regardless.

Wake latency floor is therefore the tick's 1 s cadence, pg_cron's finest granularity. Consumers that need lower latency should lower poll_interval instead; the poll is the durable path in either case.

enable_notify(Queue, Channel)

-spec enable_notify(binary(), binary()) -> ok | {error, db_error()}.

Like enable_notify/1 with an explicit channel. Re-registering moves it.

enable_notify_sql(Queue)

-spec enable_notify_sql(binary()) -> iodata().

The statement enable_notify/1 runs, as literal SQL.

enable_notify_sql(Queue, Channel)

-spec enable_notify_sql(binary(), binary()) -> iodata().

The statement enable_notify/2 runs, as literal SQL.

group_header(GroupId)

-spec group_header(binary()) -> #{binary() => binary()}.

The header that places a message in a FIFO group.

ergon_pgmq:send(Queue, Payload, ergon_pgmq:group_header(~"customer-42"))

list_topic_bindings(Queue)

-spec list_topic_bindings(binary()) -> {ok, [pgmq_topic_binding()]} | {error, db_error()}.

Every pattern bound to Queue.

compiled_regex is what pgmq actually matches routing keys against, which is the thing to look at when a binding is not catching what its author expected.

metrics(Queue)

-spec metrics(binary()) -> {ok, pgmq_metrics()} | {error, db_error()}.

Health snapshot of one queue: total length, visible length, and the age of the oldest message.

The difference between the two lengths is messages hidden behind a visibility lease: in flight, or stranded by a consumer that died. oldest_msg_age_sec is null on an empty queue.

queue_visible_length is computed against transaction-frozen now(), so a message sent inside the same transaction reads as invisible. Assert on queue_length in transactional tests.

metrics(Queue, Opts)

-spec metrics(binary(), query_options()) -> {ok, pgmq_metrics()} | {error, db_error()}.

read(Queue, VtSeconds, Limit)

-spec read(binary(), pos_integer(), pos_integer()) -> {ok, [pgmq_message()]} | {error, db_error()}.

Read up to Limit messages from Queue, hiding each behind a visibility timeout of VtSeconds.

message and headers are jsonb, and the driver's default json configuration returns those as raw binaries. Both are decoded here, so a handler receives terms, not JSON text.

read/4

-spec read(binary(), pos_integer(), pos_integer(), pgmq_read_strategy() | query_options()) ->
              {ok, [pgmq_message()]} | {error, db_error()}.

Like read/3 with an explicit strategy.

plain takes whatever is visible, in no particular order. The three grouped strategies deliver in strict order within each group named by the x-pgmq-group header, while letting different groups proceed in parallel:

  • grouped_head takes at most one message per group, so no group can ever be worked out of order. FOR UPDATE SKIP LOCKED underneath, so distinct consumers take distinct groups.
  • grouped_rr round-robins across groups, so a busy group cannot starve others.
  • grouped batches from the earliest group, favouring throughput over fairness.

Ordering is enforced by the visibility timeout rather than by locking: the next message in a group stays hidden until the one ahead is archived or its lease expires. A message sent without the header joins a default group.

The {long_poll, MaxSeconds, IntervalMs} strategy modifier blocks server-side until a message arrives. See read/5.

read(Queue, VtSeconds, Limit, Strategy, Opts)

-spec read(binary(), pos_integer(), pos_integer(), pgmq_read_strategy(), query_options()) ->
              {ok, [pgmq_message()]} | {error, db_error()}.

Like read/4 with driver options, which is how a long-polling consumer pins the call to its own connection pool.

That pinning is not optional for {long_poll, _, _}: the call blocks server-side for up to MaxSeconds, holding its connection the whole time, so issuing it against the shared pool would take a connection out of circulation for every other query on the node.

release_leases(Queue)

-spec release_leases(binary()) -> {ok, non_neg_integer()} | {error, db_error()}.

Force-expire every in-flight visibility lease on Queue, making the held messages immediately re-readable. Returns how many were released.

The recovery tool for messages stranded by consumers that died mid-processing: rather than waiting out each visibility timeout individually, free them all at once. ergon_reconciler is the intended caller.

release_leases(Queue, Opts)

-spec release_leases(binary(), query_options()) -> {ok, non_neg_integer()} | {error, db_error()}.

send(Queue, Message)

-spec send(binary(), json:encode_value()) -> {ok, non_neg_integer()} | {error, db_error()}.

Send Message to Queue. Returns its message id.

send(Queue, Message, Headers)

-spec send(binary(), json:encode_value(), json:encode_value() | pg_null()) ->
              {ok, non_neg_integer()} | {error, db_error()}.

Like send/2 with headers.

Headers are how FIFO grouping is expressed: group_header/1 builds the x-pgmq-group header the grouped read strategies order by.

send(Queue, Message, Headers, Opts)

-spec send(binary(), json:encode_value(), json:encode_value() | pg_null(), query_options()) ->
              {ok, non_neg_integer()} | {error, db_error()}.

send_topic(RoutingKey, Message)

-spec send_topic(binary(), json:encode_value()) -> {ok, non_neg_integer()} | {error, db_error()}.

Publish by routing key, fanning out to every queue whose bound pattern matches. Answers how many queues received it.

Zero is not an error. A routing key nothing is bound to is silently dropped, which is the behaviour a topic exchange is supposed to have but is also an easy way to lose messages to a typo. Check the count, or list_topic_bindings/1, if delivery matters.

The fan-out is one transaction: every delivery succeeds or none does.

send_topic(RoutingKey, Message, Headers)

-spec send_topic(binary(), json:encode_value(), json:encode_value() | pg_null()) ->
                    {ok, non_neg_integer()} | {error, db_error()}.

Like send_topic/2 with headers, e.g. a FIFO group.

send_topic(RoutingKey, Message, Headers, Opts)

-spec send_topic(binary(), json:encode_value(), json:encode_value() | pg_null(), query_options()) ->
                    {ok, non_neg_integer()} | {error, db_error()}.

unbind_topic(Pattern, Queue)

-spec unbind_topic(binary(), binary()) -> {ok, boolean()} | {error, db_error()}.

Remove a binding. Answers whether one was actually removed.