apiplant-queue 0.6.1

Background messages for apiplant: a durable Postgres outbox woken by LISTEN/NOTIFY, with no broker to run
Documentation

apiplant-queue

Work that happens after the response, without a broker.

A function calls publish("order.paid", …), the request returns, and some milliseconds later another function runs with that message. Nothing new is deployed to make that work: the transport is the Postgres the app already has.

Two halves, for two different reasons

Publishing does two things, and it is worth being clear about which does what — because the usual mistake is to build only one of them:

  • A row in queue_message. This is the message. It survives a restart, it records that an attempt failed and when the next one is due, and it is there to be looked at when somebody asks why a welcome email never went out. Everything about reliability lives here.
  • A NOTIFY. This is only a tap on the shoulder. It carries no payload worth trusting and losing it costs nothing but latency, because the sweep in [Queue::claim] would have found the row anyway. Everything about promptness lives here.

A design with only the notification (the tempting one — no table, no migration) drops every message published while nothing was listening, and has nowhere to put "this failed, try again in 20 seconds". A design with only the table polls, and a one-second poll is both too slow and too chatty. Together they are a queue.

What is guaranteed

At-least-once. A handler that succeeds and then dies before its row is marked done runs again when the lease expires. This is not a rough edge to be fixed later; it is the only honest guarantee a queue can give without the handler taking part, since "did my side effect happen?" is a question only the handler can answer. Write handlers that can run twice — check for the row you were going to insert, use the message id as an idempotency key, make the update the same update. billing_event exists for exactly this reason.

One subscriber, one claim. Rows are taken with FOR UPDATE SKIP LOCKED, so N replicas share the work rather than each doing all of it, and no two ever hold the same message.

Order is not promised. Messages are claimed oldest-first, but two replicas handling two messages will finish in whatever order they finish. A topic that needs strict ordering wants one subscriber and batch = 1, and even then a retry moves a message behind its successors.