QUESTPIE

Transactional dispatch

What happens when you publish a job inside a database transaction. pg-boss joins it, every other adapter commits an intent to a ledger table and a relay publishes after the commit, and both routes have to survive the process dying in between.

View markdown

This is the one place where swapping the adapter changes behavior you can observe rather than just configuration you can read. publish() looks the same either way. What backs it does not.

The two routes

pg-boss with useApplicationTransaction: true, its default, implements publishInTransaction and inserts the job through the same Drizzle transaction your business write is using. One commit, both rows, nothing in between.

BullMQ, Cloudflare Queues and any adapter without that method cannot see your transaction. QUESTPIE writes an intent row into questpie_queue_dispatch inside your transaction instead, and a leased relay publishes it to the broker after the commit lands.

SituationResult
Transaction rolls backNeither a job nor an intent exists.
Crash after commit, before the broker acceptedRecovered on the next execution opportunity.
Crash after acceptance, before the receipt savedAnother physical delivery is possible under the same dispatchId.
No transaction, no idempotencyKeyThe publish awaits adapter acceptance directly.
No transaction, with idempotencyKeyThe ledger runs, so repeated calls resolve to one dispatch.

Handlers have to be idempotent

The stable dispatchId identifies retries. It does not make a downstream side effect happen once. Pass it to the provider's own idempotency facility, or keep a processed-dispatch record your handler checks first.

The ledger table

questpie_queue_dispatch is Queue-owned and separate from the realtime outbox on purpose. It is added to the schema unconditionally, even when the adapter can publish directly, so changing adapters never generates a destructive DROP TABLE migration and never drops recovery state you still needed.

idempotencyKey and singletonKey cannot be combined. A publish suppressed by native singleton dedup has no new logical identity, so it cannot produce a trustworthy receipt for a dispatch that was never accepted.

Relay bounds

Publication recovery is finite. A row gets 25 attempts with exponential backoff capped at one hour, and then stays failed. queue.drain() counts it under terminal and logs a structured error whose fields never carry the payload.

A terminal row is not retried again. Fix whatever the adapter was rejecting and publish a new logical attempt under a new idempotencyKey, because the original key stays bound to its terminal receipt. A terminal row that never carried a secret keeps its payload so you can see what broke.

Recovery needs somewhere to run

The framework does not start a second process to drain the ledger. Something has to give it a turn.

Long-running workers

listen() drains on startup and then ticks every five seconds, each tick processing up to ten batches. You get recovery for free and never call drain() yourself.

Serverless and push

runOnce() relays before and after its own batch, and a push consumer drains on each delivery. Both only run when something invokes them, so a committed intent sitting behind a queue that receives no new traffic waits. On Cloudflare, add a platform Cron Trigger that calls app.queue.drain().

queue.drain()

Call it directly when you need a bounded relay pass on your own schedule.

A scheduled trigger
import { app } from "#questpie";

const { claimed, accepted, failed, terminal } = await app.queue.drain({
	batchSize: 100,
	maxBatches: 5,
});
OptionDefaultNotes
batchSize100Rows claimed per batch, and the page size for secret inspection.
maxBatches1Consecutive batches in this pass. An integer from 1 to 100.
concurrency8Rows relayed in parallel within a batch.

It returns { claimed, accepted, failed, terminal }. Concurrent calls collapse into the one already running rather than stacking, so a cron that fires while the previous pass is still going will not pile up.

Where each topic lives

TopicPage
Which adapters can publish in a transactionQueue
Implementing publishInTransaction yourselfWriting an adapter
idempotencyKey, singletonKey, PublishOptionsJobs
Reading a dispatch receiptSecret payloads
Starting a worker in each modelRunning a worker

On this page