Skip to content

Transaction

Every field of POST /api/v1/transaction, how an ack finds its lease, the results, every rollback reason, and when a retry is safe.

Updated View as Markdown

POST /api/v1/transaction is the call Queen was built around. It writes a worker’s whole step (the acks of what it took, the events it produces, the KV state it changes, the timers it sets, where a consumer group should read next) as one entry of the replicated log, so all of it applies or none of it does. There is no outbox table to poll and no idempotency table to consult before you act: the step and its effects are the same write.

It answers HTTP 200 both when it commits (success: true) and when it rolls back (success: false with a reason), because a rollback is a verdict about your data, not a failure of the call. This page is every field, every result and every reason. For the model and the patterns built on it, read transactions first.

Request

{
  "operations": [],      // { "type": "ack" | "push", ... }, in order
  "requiredLeases": [],  // lease ids, strings
  "kv": [],              // { "op": "put" | "putIfAbsent" | ..., ... }
  "timers": [],          // { "op": "schedule" | "reschedule" | "cancel", ... }
  "positions": []        // { "queue", "consumerGroup", "partition", "offset" }
}
Field Type Meaning
operations array Acks and pushes, in order. Each element has type: "ack" or type: "push".
requiredLeases array of strings Lease ids for the acks that name none. See how an ack finds its lease.
kv array KV operations. Top level, never inside operations.
timers array Timer schedules and cancels. Top level, never inside operations.
positions array Set or forget where a consumer group reads a partition.

At least one of operations, kv, timers and positions must be non-empty. A kv or timer element inside operations is refused with 400, and so is a top-level field that is present but not an array.

Push operation

Either a list of items, or one item inline (queue, partition, payload on the operation).

Field Required Meaning
items[].queue yes Queue name. A queue that does not exist is created.
items[].partition no Partition name, default Default. A partition that does not exist is created.
items[].payload yes Any JSON value.
items[].transactionId no The dedup key, at most 65,535 bytes. Default: the minted message id.
items[].traceId no A UUID; an invalid one is ignored.

Two items with the same (queue, partition, transactionId) in one call store one message; the second result says duplicate: true. An item whose transactionId is already in the partition’s dedup window rolls the whole transaction back with reason: "duplicate".

Ack operation

Field Required Meaning
transactionId yes The popped message’s transactionId.
partitionId yes The popped message’s partitionId: a JSON string of digits, as the pop returns it.
consumerGroup no The group the message was popped for. Default __QUEUE_MODE__ (queue mode).
leaseId no The pop’s leaseId.
status no completed (default), failed, retry or dlq. Any other value reads as completed.
error no The reason recorded on a dead letter (failed, dlq).

failed counts one attempt: the message is redelivered while the queue’s retryLimit lasts, then dead-lettered (or dropped with deadLetterQueue: false). retry releases it for redelivery without counting an attempt. dlq dead-letters it now.

How an ack finds its lease

Each ack gets at most one lease, found in this order:

  1. its own leaseId;
  2. otherwise the one lease named by requiredLeases and the other acks’ leaseIds together, when they all name the same lease;
  3. otherwise none.

An ack with a lease is fenced. The lease must be the one the partition holds for that group, not expired, and the message must be inside the leased batch. If not (the lease expired, the message went to another worker, an earlier commit already acked it and released the lease), the whole transaction rolls back with reason: "rejected_ack".

An ack with no lease is not fenced. The broker skips the lease check: it acks the message even if another worker holds it. It still rolls back with rejected_ack when the message is not found after the group’s cursor, or was already acked with a failed, retry or dlq status. A completed ack of a message that was already acked is a no-op, and the rest of the transaction commits.

requiredLeases fences nothing on its own: it only supplies the lease of the acks in the same body. Every SDK puts each acked message’s leaseId on its ack as well as in requiredLeases, so a transaction that acks messages from two different pops is fenced by both leases. Over HTTP, put leaseId on every ack.

KV operations

The same operations as POST /api/v1/kv, without getPrefix.

op Fields
get ns, key
getMany ns, keys (array)
put ns, key, value, expiry, expect?, required?
putIfAbsent ns, key, value, expiry, required? (it is put with expect: 0)
delete ns, key, expect?, required?
incr ns, key, delta (number), min?, max?, expiry, required?

Every put, putIfAbsent and incr carries exactly one expiry: ttlSeconds (an integer greater than 0) or forever: true; the JS SDK turns ttl: '30d' into ttlSeconds. A value is any JSON value of at most QUEEN_KV_MAX_VALUE_BYTES (64 KiB) as compact JSON. expect is a version number, and 0 means the key must not exist.

required: true turns an operation into a gate. If its precondition loses (exists, absent, version, limit, type), the whole transaction rolls back with reason: "kv_precondition". Without required, a lost precondition is applied: false in the results and the rest commits. The JS .once(ns, key) is putIfAbsent with required: true.

ns matches ^[a-z0-9][a-z0-9._-]{0,63}$, a key is 1 to 512 bytes with no NUL, and a key may be written once per call. A transaction carries at most 64 KV operations naming at most 256 keys.

Timer operations

Field Required Meaning
op yes schedule, reschedule (the same upsert) or cancel.
queue yes The queue the message is pushed to when the timer fires.
timerKey yes The timer’s name, unique per queue. Scheduling an existing key replaces it.
partition no Partition of the fired message, default Default.
delayMs schedule A delay in milliseconds, counted on the leader when it plans the transaction. Zero or negative fires on the next tick.
txn schedule The fired message’s transactionId. On a cancel, echoed back.
payload schedule The message payload, base64.
payloadZstd no true when payload is zstd-compressed; the broker decompresses it.

The broker owns producerSub, messageId, deliverAt, attempts, the tenant fields and every field starting with _: a timer op that carries one is refused with 400. A (queue, timerKey) may appear once per call.

Positions

Field Required Meaning
queue yes Must exist, or the transaction rolls back with queue_not_found.
consumerGroup yes The group. It is registered on the queue if it is new.
partition no Default Default. A partition that does not exist is created.
offset yes The next offset the group reads, or null to forget the position.
metadata no A string of at most 4096 bytes, stored with the position.
subscriptionMode no How a new group is registered, default DEFAULT_SUBSCRIPTION_MODE.

A position set here releases any lease the group holds on that partition. At most 16,384 per call.

Response

{ "transactionId": "0199a7c2-61f0-7b02-9a4c-0e5d3f2b8c11", "success": true, "results": [] }

results has one element per part, in one index space: the operations first, in order, then kv, then timers, then positions. Every element carries index and type; KV, timer and position results also carry opIndex, their place in their own array. The top-level transactionId is minted for this call; it is not a dedup key.

type Fields
ack success, transactionId, error, dlq (true when the ack dead-lettered the message)
push success, transactionId, messageId, queueName, and duplicate: true for an in-call repeat
kv op, applied, reason (when not applied), key, value, version; a read has found or rows/missing
timer ok, status (scheduled, rescheduled, cancelled, or absent with ok: false), queue, timerKey, txn, messageId, deliverAt
position success

absent on a cancel means no pending timer has that key: it may already have fired.

Example

An order worker acks the event, records the order state, pushes a receipt, schedules a reminder and gates the whole step on a marker:

curl -s http://localhost:6632/api/v1/transaction \
  -H 'Content-Type: application/json' -d '{
  "operations": [
    { "type": "ack", "transactionId": "order-7-paid", "partitionId": "42",
      "consumerGroup": "billing", "leaseId": "0199a7c2-5e1b-7f3a-8c21-4d9e0b6a1f37" },
    { "type": "push", "items": [
      { "queue": "receipts", "partition": "customer-123",
        "transactionId": "receipt-order-7", "payload": { "orderId": "order-7", "total": 120.5 } } ] }
  ],
  "kv": [
    { "op": "put", "ns": "orders", "key": "order-7", "value": { "status": "paid" },
      "ttlSeconds": 2592000 },
    { "op": "putIfAbsent", "ns": "paid", "key": "order-7-paid", "value": true,
      "ttlSeconds": 86400, "required": true }
  ],
  "timers": [
    { "op": "schedule", "queue": "reminders", "timerKey": "order-7", "delayMs": 86400000,
      "txn": "reminder-order-7", "payload": "eyJvcmRlcklkIjoib3JkZXItNyJ9" }
  ]
}'

The commit:

{
  "transactionId": "0199a7c2-61f0-7b02-9a4c-0e5d3f2b8c11",
  "success": true,
  "results": [
    { "index": 0, "type": "ack", "success": true, "transactionId": "order-7-paid",
      "error": null, "dlq": false },
    { "index": 1, "type": "push", "success": true, "transactionId": "receipt-order-7",
      "messageId": "0199a7c2-61f1-7d55-b0e2-7a1c9e4f6d20", "queueName": "receipts" },
    { "index": 2, "op": "put", "applied": true, "key": "order-7",
      "value": { "status": "paid" }, "version": 90101, "opIndex": 0, "type": "kv" },
    { "index": 3, "op": "putIfAbsent", "applied": true, "key": "order-7-paid",
      "value": true, "version": 90102, "opIndex": 1, "type": "kv" },
    { "ok": true, "status": "scheduled", "queue": "reminders", "timerKey": "order-7",
      "txn": "reminder-order-7", "messageId": "0199a7c2-61f1-7d55-b0e2-8b3f0a2d4e71",
      "deliverAt": "2026-10-02T09:14:03.512887Z", "opIndex": 0, "index": 4, "type": "timer" }
  ]
}

If the same event comes back (the group was seeked back and pops it again under a new lease), the step runs again. Its receipt carries the same transactionId, so the step rolls back and writes nothing:

{
  "transactionId": "0199a7c3-0a2e-7c11-8d40-5f6e7a8b9c02",
  "success": false,
  "reason": "duplicate",
  "error": "QDUP a pushed message to receipts/customer-123 is a duplicate (original offset 17); the transaction rolled back",
  "results": []
}

Rollback reasons

A rolled-back transaction wrote nothing. The body is {transactionId, success: false, reason, error, results: []}. When several guards would fire, the reason is the first in this order: the acks (rejected_ack), the pushes (duplicate), the positions, the KV operations (kv_precondition), the timers.

A lost required KV operation adds the operation that lost:

{
  "transactionId": "0199a7c3-0a2e-7c11-8d40-5f6e7a8b9c02",
  "success": false,
  "reason": "kv_precondition",
  "error": "QKV a required KV precondition failed; the transaction rolled back",
  "results": [],
  "ok": false,
  "failedIndex": 3,
  "kvReason": "exists",
  "version": 90102,
  "value": true
}

failedIndex is in the same index space as results. The JS client returns this answer from commit(); every other success: false throws with error.reason set.

reason HTTP When What to do
kv_precondition 200 A required KV operation lost. The body adds failedIndex, kvReason, version, value. The step already happened. The ack rolled back with it: ack the input on its own (POST /api/v1/ack).
duplicate 200 A pushed item’s transactionId is already in its partition, inside the queue’s dedupWindowSeconds. The step already happened. Ack the input on its own; do not retry.
rejected_ack 200 A fenced ack’s lease is not the partition’s live lease, or its message is outside the leased batch; an ack’s message is not found after the cursor; or the partitionId is unknown or belongs to another tenant. Do not retry: the message is redelivered to whoever holds it, or was already done.
reserved 200 Another transaction, or the engine’s checkpoint, holds a cursor this transaction acks or positions. Retry.
unavailable 200 The leader could not read the state the transaction needs. Retry.
queue_not_found 200 A position names a queue that does not exist. Create the queue.
too_large 200 The pushes to one partition, or the positions, exceed QUEEN_RAFT_ENTRY_MAX_BYTES (96 MiB) once planned, or a position’s metadata exceeds 4096 bytes. Split the transaction.
name_too_long, kv_key_too_large 200 A name passed the edge check but not the store’s key limit. Shorten it.
bad_request 400 or 200 400: the body is not valid JSON of this shape, an operation type is missing or not push/ack, or a timer op is invalid. 200: no parts at all, an inline push without queue or payload, a non-numeric partitionId, an invalid position, or encrypted on a timer for a queue that encrypts at rest. Fix the request.
timers_server_owned_field 400 A timer op carries producerSub or a field starting with _. Remove it.
kv_* 400, 413 A KV operation fails validation: kv_bad_request, kv_unknown_op, kv_bad_namespace, kv_bad_key, kv_bad_ttl, kv_expiry_not_specified, kv_bad_expect, kv_too_many_ops, kv_too_many_keys, kv_duplicate_key_in_call, kv_tenant_not_an_input, kv_get_prefix_not_allowed_in_transaction; 413 for kv_key_too_large and kv_value_too_large. Fix the request.
internal, entry_encode_failed 200 A broker fault. Report it.

Some failures use the engine’s error body {"error", "code"} instead, with no reason: 413 name_too_long (a pushed queue or partition name over the key budget), 400 bad_request (a transactionId over 65,535 bytes), 429 overloaded, 503 retry, no_leader or timeout, 507 storage_full, 500 internal, and the 401/403 of authentication. See errors.

Retries

Inside one call, the broker retries a leader change itself, under the call’s request id, until the deadline (QUEEN_STMT_TIMEOUT_MS, 30 s), and a transaction that committed before the change is answered from its record and does not run again.

Across calls there is no client-supplied request id: a request you send again is a new transaction, and after a 503 or a lost connection the first attempt may have committed. If it did, the retry rolls back and writes nothing only because of what the body carries: a fenced ack whose lease the first commit released (rejected_ack), a pushed transactionId already in the partition (duplicate), or a required marker (kv_precondition). An ack with no lease does not stop it, because acking a message twice is a no-op.

Give every pushed item a deterministic transactionId, or gate the step with a required marker, so that a retry is harmless. A transaction made only of KV writes and timers, with no gate, runs again when retried: an incr counts twice, and a timer that already fired is scheduled again. The JS client retries 5xx answers itself (three attempts by default), so this applies to SDK calls too. See dedup.

Limits

  • One call, one tenant. There is no interactive BEGIN and COMMIT: a transaction cannot read a value and branch on it. expect and required are the conditions it can carry.
  • The timer horizon (QUEEN_TIMERS_MAX_HORIZON_S, 90 days), the 1 MiB timer payload ceiling and the 256-op cap of POST /api/v1/timers are not applied to the timers array of a transaction.
  • Throughput and commit latency, measured against Kafka, Redpanda and Pulsar on the same machines, are on the transactions benchmark.
  • An external side effect, such as an HTTP call to a payment provider, is outside the commit.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close