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.