One route bundles pushes and acks into a single PostgreSQL transaction:
POST /api/v1/transactionEverything in the call commits together or nothing does. That is the whole feature, and it is enough to build a pipeline whose stages cannot drop or duplicate work at the handoff.
The shape of a call
{
"operations": [
{ "type": "ack", "transactionId": "in-42", "partitionId": "8f1c…", "status": "completed" },
{ "type": "push", "queue": "orders.shipped", "partition": "eu", "payload": { "id": 42 } }
],
"requiredLeases": ["0191…"]
}Only push and ack operations exist. Anything else fails the request with 400 and
segments transaction supports only push and ack operations, before any work is attempted.
A push operation may carry a single message inline (as above) or an items array of them.
Each item takes queue, an optional partition (default Default), payload (or data),
an optional transactionId and an optional traceId. An ack operation takes
transactionId, partitionId, an optional status (default completed), an optional
consumerGroup and an optional leaseId, with exactly the meanings described in
Acknowledgement is an offset commit.
The pipeline handoff
await client
.transaction()
.queue('test-queue-v2-txn-basic-b')
.push([{ data: { value: messages[0].data.value + 1 } }])
.ack(messages[0])
.commit()This is the pattern the route exists for. The consumer acks the message it just processed and pushes its result in the same transaction, so there is no window in which the output exists without the input being consumed, or the input is consumed without the output existing.
flowchart LR IN["input partition<br/>message in-42"] --> W["consumer"] W --> TX subgraph TX["one PostgreSQL transaction"] direction TB A["ack in-42<br/>moves the input cursor"] P["push orders.shipped<br/>the next stage input"] end TX -->|"commit"| OUT["orders.shipped / eu<br/>durable"] TX -->|"any raise"| RB["nothing written<br/>in-42 redelivers"]
The boundary is drawn around both operations at once, which is the only thing that makes the handoff safe: there is no ordering of two separate calls that gives you the same property.
Chain that across stages and no hop can lose work or emit an orphan: a stage’s output exists exactly when its input was consumed, because the commit that records the consumption is the commit that records the production. A failed attempt rolls back both halves, so the input redelivers and the output is produced by whichever attempt succeeds. The duplicate risk that remains is a client retry after a lost response. See the limit.
What rolls a transaction back
The stored procedure raises on every failure path, and a raise takes down every other operation in the call, including pushes that already ran earlier in the same procedure.
- A duplicate push. On a queue with deduplication enabled, a
transactionIdthat already exists inside the deduplication window makes the whole transaction fail with aQDUPerror. This is the one place where a duplicate is harder than elsewhere: a plain push reportsstatus: "duplicate"and carries on, but inside a transaction all-or-nothing leaves no other option. On a queue with deduplication disabled there is no probe, so the message is stored again. - A rejected ack. An invalid or expired lease, a position beyond the leased batch, or a missing consumer row. Any of these aborts the transaction.
- An ack for a transaction id the broker cannot find. This one is checked before the
procedure runs: the broker probes each acked hash against the partition’s hash sidecar and,
if any of them resolves nowhere, returns
QTXN ack references unknown transactionId; transaction rolled backwithout touching the database. Without that pre-check the pushes would commit while the bogus ack quietly moved nothing. - An unresolvable partition or queue name. An ack naming an unknown partition, or a push whose queue and partition cannot be resolved, fails the call.
A rolled-back transaction leaves cursors, offsets and segments exactly as they were. Nothing partially applied, nothing to clean up.
Reading the response
The route answers HTTP 200 even when the transaction failed:
{
"transactionId": "0191…",
"success": false,
"error": "QDUP duplicate messages in queue \"orders.shipped\" partition \"eu\"; transaction rolled back",
"results": []
}On success, results is one entry per operation, in request order: push entries carry
transactionId, messageId and queueName (plus duplicate: true when the item repeated an
earlier item of the same call), ack entries carry transactionId and dlq.
Two details of the ack entries are worth knowing before you rely on them:
- They are always reported as successful when the transaction commits. The per-item honesty
reporting of the plain ack routes (
noop,already committed,unresolvable) is not surfaced here. An ack for a position already below the cursor therefore commits the transaction and moves nothing. If you need that signal, ack outside a transaction. - A repeated
transactionIdamong the pushes of one call is first-wins within the call: the later item echoes the first item’smessageIdwithduplicate: true, and no error is raised. This is separate from the duplicate-against-history case above, which does raise.
Leases inside a transaction
An ack still needs to prove it owns the lease. The broker resolves the lease for each
(partition, consumer group) in this order: a leaseId on the operation itself, then the
current lease holder read from the consumer row, then the single unambiguous entry in
requiredLeases.
If the request carries no lease information anywhere, every ack in it is treated as lease-less: the ownership check is skipped and the cursor still advances. That mirrors the plain ack route. Sending a lease id that has since expired is not the same thing: it is validated, rejected, and rolls the transaction back.
Ordering and locks inside the call
Pushes execute in request order. Acks execute in ascending (partition, consumer group)
order, regardless of the order you listed them. Every multi-partition writer in Queen takes
partition locks in ascending partition id and only then takes consumer-row locks, so
concurrent transactions over overlapping sets of partitions cannot deadlock against each
other, against a plain push, or against retention.
The practical consequence: you can bundle acks and pushes across many partitions and queues
in one call without thinking about lock order. What you cannot do is assume the results
array reflects execution order for acks: it is stamped back to your operation indices.
The limit: this is not end-to-end exactly-once
Atomicity covers the broker’s state. It does not cover the network.
If the response to a transaction is lost (a timeout, a dropped connection, a client crash after the commit), the client does not know whether the transaction committed. Retrying it blindly can duplicate the pushed messages, because the retry is a new set of messages as far as the broker is concerned.
The fix is a deterministic transactionId on each pushed item, derived from the input (for
example the input message’s own transaction id plus a stage name), and a deduplication window
long enough to cover your retry horizon. Then the retry behaves predictably:
- If the first attempt never committed, the retry commits normally.
- If the first attempt did commit, the retried push is a duplicate inside the window, so the
transaction is rejected with a
QDUPerror and nothing is written twice. The error means “already applied”, but only if your ids really are deterministic and the window really did cover the gap.
Both conditions are yours to guarantee. Outside the deduplication window, a repeat is
accepted as a new message. And note that the JavaScript transaction builder sends only
queue, partition and payload per item. To set a deterministic id inside a transaction,
call the HTTP route directly.
The deduplication mechanism, and what “exact” means for it, is on Guarantees.
When not to use it
A transaction is one database transaction, so it holds locks for its duration and commits
once. For a stage that only pushes, a plain batched push is cheaper and has the same
durability. Reach for /api/v1/transaction when the coupling matters: when a push must not
survive a failed ack, or an ack must not survive a failed push.