An ack is a cursor commit. There is no per-message state to flip, so acking offset N implicitly
completes every earlier unacked offset in that partition for that group. But the wire does not
carry offsets. It carries transactionId, because that is the identifier a client actually holds.
Turning one into the other is the work this page describes, along with the two accelerators that
avoid doing it.
The wire contract:
POST /api/v1/ack {transactionId, partitionId, status, consumerGroup?, leaseId?}
POST /api/v1/ack/batch {consumerGroup?, acknowledgments:[{transactionId, partitionId, status, leaseId?}]}Both answer with a top-level array, one element per ack in request order, each carrying
index, transactionId, success, error, leaseReleased, dlq and noop.
Resolution: transactionId to hash to offset
The broker computes xxh3_128(transactionId), 16 bytes, big-endian. SQL never hashes anything; it
stores and compares bytea. Resolution then walks queen.log_txns, which holds one row per pushed
segment with 16 × msg_count bytes of hashes in frame order. Hash at index k of the row based at
base_offset belongs to offset base_offset + k.
That sidecar is written on every push whether or not deduplication is enabled, precisely so this
resolution works on a deduplication-disabled queue. On the retired engine, dedupWindow = 0 also
disabled explicit acks; that landmine is gone.
Two consequences follow from the sidecar being purged on its own clock (GREATEST(dedup window, completed retention, 900 s)):
- An ack for a
transactionIdwhose sidecar row was purged is unresolvable. The cursor stops before that position and those frames redeliver. Redelivery over loss is the standing bias. - Unresolvable hashes are now reported (
unresolvedHashes), not silently defaulted to success. The bug that motivated it is worth remembering: a client acking beyond the hash window was told the ack landed while the cursor never moved, and afailedordlqsignal there vanished with no retry charge and no DLQ hand-off, which is a silent redelivery livelock.
The authoritative decision procedure
queen.log_ack_by_hash_v1(partition_id, group, worker, hashes[], statuses[]) is the one place that
implements the full ack contract. Everything else in this page is a fast path that must reduce to it.
It locks the queen.log_consumers row (the only lock the ack path ever takes, never a
log_partitions row lock), so acks cannot form a cross-space cycle with the push serializer. Then it
validates the lease, but only when a non-empty leaseId was supplied: a lease-less ack falls
straight through and still advances the cursor.
Resolution is one join, materialised once, from which every downstream decision is a scalar aggregate:
input: the incoming hashes unnested once, each carrying its status and array position.occ: hash occurrences fromlog_txnsin the ackable span. Under a lease this is bounded to rows withbase_offset <= batch_end. That bound is result-preserving (a row above the leased batch can resolve neither to an in-span offset nor to a below-cursor one) and it is the whole optimisation: an ack at the head of a deep partition explodes only the batch’s hash blobs, not the partition’s.resolved: per input hash,eff= the minimum matching offset inside the ackable span[GREATEST(committed + 1, txns_start), batch_end], andbelow= whether any occurrence sits at or belowcommitted.MINprefers the in-span occurrence, so a re-push below the cursor never shadows the copy the client is actually acking.sig: the head signal, the lowesteffamongfailed,dlqandretry, with ties brokendlq > failed > retry.
The new cursor is the maximum acked-ok offset clamped below the signal, and acked is pure
arithmetic: new_committed - old_committed.
The two honesty guarantees
An explicit signal is never skipped. The cursor clamps at the lowest explicit
failed/dlq/retry position in the call, even when a later position in the same call was acked
completed. That lowest signal decides the action; completed positions above it simply redeliver.
An at-least-once duplicate is acceptable; a lost nack is not. Only silent gaps (positions the
client did not mention at all) are implicitly completed.
An ack that can no longer have an effect says so. Positions resolving at or below the cursor come back in one of two lists:
| List | Contents | What the client is told |
|---|---|---|
noopHashes |
below-cursor positions acked completed |
noop: true, a harmless duplicate commit |
staleHashes |
below-cursor positions acked failed/dlq/retry |
rejected, “already committed” |
unresolvedHashes |
no occurrence anywhere | rejected, “unresolvable” |
All three are arrays of 32-character lowercase hex tokens. The broker maps them back to
transactionId strings, since it holds the request’s mapping.
The branches
| Signal | Retry budget | Effect |
|---|---|---|
dlq |
bypassed | advance over the completed prefix, keep the lease, hand the poison offset to the broker |
failed |
budget remains | advance over the prefix, release the lease, charge batch_retry_count once; the batch redelivers |
failed |
exhausted, DLQ enabled | advance over the prefix, keep the lease, hand off the poison |
failed |
exhausted, DLQ disabled | drop the poison: cursor advances past it, counter resets, lease released |
retry |
untouched | advance over the prefix, release the lease (a budget-free explicit retry) |
all completed, reached batch_end |
reset | advance, release the lease, reset attempt and retry state |
| all completed, partial | untouched | advance, keep the lease so the rest of the batch can still be acked |
The retry limit comes from queen.queues.retry_limit (default 3), and the DLQ is enabled when
either dead_letter_queue or dlq_after_max_retries is true. Both default true, so an unconfigured
queue dead-letters.
batch_retry_count is charged only by an explicit failed. A lease expiry never spends it. The
redelivery counter attempt_count is separate telemetry, incremented by the pop path when a batch
redelivers from the same start offset.
Filing the DLQ row
When the procedure returns dlq: true it keeps the lease, because only the broker can decompress a
segment. The broker fetches the covering segment, extracts frame off - base_offset (payload,
message id, transactionId), picks the failure reason from the matching nack, and calls:
queen.log_dlq_head_v1(partition_id, group, worker, off, message_id, txn, payload, error)Under the validated lease that files the queen.log_dlq row with a snapshot of the payload and of
the retry budget consumed, advances the cursor past that one frame (with a GREATEST guard so a
stale caller can never move the cursor backward), resets the attempt and retry state so the next
batch starts with a fresh budget, and releases the lease. Calling it twice is safe: the first call
released the lease, so the second fails the worker match and returns ok: false with no side
effects.
The DLQ row stores a payload snapshot rather than a reference, and carries no foreign key into
log_segments. That decoupling is deliberate: the rows engine’s cascade silently dropped DLQ rows
when the source row was retained away.
Accelerator one: the ack registry
Hash resolution costs several passes over the leased range’s hash blobs, and it is pure waste in the
overwhelmingly common case: a client that acks the entire batch it was just given, all completed.
The broker already holds everything that case needs: at pop-serve time it unpacked the frames (it
wrote each transactionId into the response) and it knows the batch’s end offset.
So the registry is a process-local map keyed (partition_id, consumer_group) holding, per active
lease: the worker id, the inclusive batch_end, and the distinct set of xxh3_128 hashes
delivered.
The fast path fires for a (partition, worker) ack group only when all three hold:
- every item in the group has status
completed; - the acked hash set exactly equals the lease’s delivered hash set;
- the request’s
leaseIdmatches the lease’s worker.
When they do, the ack becomes a single positional cursor advance,
queen.log_ack_at_v1(pid, group, worker, batch_end, true, count), with no hash resolution at all.
The soundness argument is short: with every delivered message acked completed and none failed,
retried or dead-lettered, the implicit-ack rule advances committed to the maximum acked position,
which is batch_end; there is nothing below the cursor, no signal to clamp at, and no retry or DLQ
hand-off. Every other case (a partial ack, any non-completed status, a worker mismatch, an
unknown, evicted or expired entry) never reaches the fast path and takes the verbatim SQL path, so
below-cursor honesty, the retry budget and DLQ hand-off are preserved by construction: those code
paths are untouched.
And log_ack_at_v1 still re-validates the lease under the consumer row lock. A stale registry hit
(a lease that expired or was reassigned between pop and ack) is rejected by PostgreSQL and falls
back to the hash path. The registry can never grant an ack PostgreSQL would refuse.
Two implementation notes:
- Set equality, not multiset. The stored set is distinct hashes. The cover test is meant to be
order- and multiplicity-insensitive, and the degenerate case (two delivered messages sharing one
transactionId) collapses to one member. But acking thattransactionIdacks both offsets in the SQL model too, so advancing tobatch_endstays correct. - One global mutex, deliberately. The work under the lock is one lookup, one
HashSet ==against a prebuilt set, and one removal; the request-side set is built off-lock. The ack rate is the message rate divided by the pop batch size, which is small. Sharding is a drop-in if a tiny-batch workload ever needs it.
Entries are born on a leasing pop that delivered at least one frame, in every leasing variant:
wildcard binary and wire, specific-partition, discovery, and the mesh hint-targeted pop. They die on
a fast-path hit, on any SQL-path ack outcome for that (partition, group) (lease release, nack, DLQ
hand-off, partial ack), on TTL expiry (the lease length plus a 10-second margin), or to LRU eviction
under QUEEN_ACK_REGISTRY_MB (default 64). The TTL is a memory bound, not a correctness mechanism.
PostgreSQL’s lease validation is what protects against a late ack. A full sweep runs opportunistically
on insert, at most every 5 seconds, so no background task is needed. Every death mode degrades to the
SQL path.
QUEEN_ACK_REGISTRY=0 disables it.
Accelerator two: ack fusion
A registry hit is one cursor advance, but still one transaction, hence one commit and one fsync. Since a cursor advance is monotone and idempotent (acking to offset 200 implies everything through 200, including an earlier ack to 100), many independent advances can fuse into one transaction with no loss of semantics. That is what collapses the ack commit rate so the push pipeline stops queueing behind a flood of tiny ack fsyncs.
The mechanism mirrors push fusion:
- Sharded by hash of the partition id (
QUEEN_ACK_FUSION_SHARDS, default 8), so all acks for a given partition land in one shard and both same-key folding and per-partition commit order are intra-shard and race-free. - Fire-on-idle. At most one flush per shard is in flight. On arrival with nothing in flight the
shard flushes immediately, so at low load an ack commits in about one round trip, a flush of
exactly one row. Under load, arrivals accumulate for the in-flight flush’s duration and the next
flush commits all of them. Batch size is emergent.
QUEEN_ACK_FUSION_HOLD_MS(default 3) is a liveness backstop tick, not a latency floor. - Folding. Entries with the same
(partition, group, worker)fold tomax(batch_end), and every folded request receives that row’s verdict, which is sound by monotonicity. Two different workers on one(partition, group)do not fold; they stay separate rows, each independently validated by the procedure’s per-row worker and lease guard. - Synchronous response. The HTTP response resolves only when the flush commits, so a
200still means the cursor is durable.
The commit is queen.log_ack_multi_v1(pids[], groups[], ends[], workers[], counts[]). Each row runs
the identical per-row procedure as log_ack_at_v1 with ok = true: lock the consumer row, validate
worker and lease only when the worker is non-empty, clamp to the leased batch_end, advance
committed, release the lease, bump total_consumed. There is no nack, retry or DLQ branch, because
the fusion path only ever enqueues full-batch completed acks.
Two invariants in that procedure are load-bearing:
- Deterministic lock order. Rows are processed
ORDER BY (partition_id, consumer_group), so every flush takes its consumer-row locks in one total order, the same discipline as the push pre-lock and the transaction wire’s ack loop. Flushes are sharded so two never share a consumer row, but the ordering also keeps a flush from deadlocking against the transaction wire or a pop touching the same rows. - Result realignment. Verdicts are emitted by input ordinal, not execution order, so the caller maps each verdict back to its enqueue slot.
Three verdicts come back per row:
| Verdict | Meaning | Client sees |
|---|---|---|
Committed |
the cursor advanced durably | success, lease released |
Rejected |
the row was rejected (stale or expired lease, batch clamp) but the flush committed | falls back to the hash path for that one client |
FlushErr |
the whole flush failed: pool, infrastructure, timeout, parse | an error; the lease expires and the batch redelivers |
QUEEN_ACK_FUSION=0 reverts to the synchronous single log_ack_at_v1 call.
The ack-fusion log target emits a coalesce summary about every ten seconds with commits, rows
and rows_per_commit. That is the direct broker-side evidence to cross-check against
pg_stat_statements, where log_ack_multi_v1.calls should be far below the total ack count.
One connection discipline that matters
With fusion on, the ack handler never holds a pooled connection while parked on a flush commit. If it did, once more acks parked than the pool is deep, the flush task, which needs its own connection from the same pool, would deadlock. So the shared client is acquired lazily for the SQL paths and released at the end of each group’s iteration. With fusion off, the client is acquired once up front and held across the loop, exactly as before.
The positional variants
Three positional ack procedures exist, and the difference is only how the partition is addressed:
| Procedure | Addressed by | Used by |
|---|---|---|
log_ack_v1 |
(queue, partition) names, tenant-scoped |
the transaction wire’s positional form |
log_ack_at_v1 |
partition_id |
the registry fast path |
log_ack_multi_v1 |
arrays of partition_id |
ack fusion |
The ack wire is partition-id-keyed and carries no partition name (nor, for a discovery pop, a queue name), so the fast paths address by id rather than paying a name-to-id join per ack, which would reintroduce exactly the work they exist to remove.
Lease renewal
POST /api/v1/lease/:leaseId/extend calls queen.log_renew_lease_v1(worker, seconds), which renews
every live lease held by that worker (a multi-partition pop shares one worker id). It uses
GREATEST, so renewing can never shorten a lease, and reports the minimum expiry across the
renewed rows, since the earliest deadline is what the caller needs in order to schedule the next
renewal. It always answers 200; renewal is best-effort.
With tenancy enabled there is an ownership gate, and its shape is instructive. The route is addressed
by lease id alone (bearer-token semantics) and the procedure carries no tenant, so a leaked lease id
would otherwise let one tenant keep another tenant’s batch leased and delay its redelivery. The gate
and the renewal are therefore one statement: the procedure is projected only when a single
EXISTS proves that a live lease of that worker sits on a partition of the requesting tenant. No row
back means the unknown-lease body, so a foreign lease is indistinguishable from an expired or
never-existent one.
Acks inside a transaction
POST /api/v1/transaction bundles pushes and acks into one queen.log_transaction_wire_v1 call, one
PostgreSQL transaction, all or nothing. Its ack elements come in both forms: positional (uptoOff)
when the pop was served by this process, and hash-resolved (an acks array of hex tokens) when it was
not, for instance when another broker answered the pop, so there is no local registry entry.
Every failure raises, which rolls back everything else in the batch: a duplicate push escalates to
unique_violation with a QDUP message, and an ack soft failure (ok: false for an invalid or
expired lease, or a position beyond the leased batch) escalates likewise. The lock discipline is the
same total order used everywhere: provisioning, then partition rows ascending by id, then consumer
rows ascending by (partition_id, group). Consumer locks are only ever taken after all partition
locks, so the two lock spaces cannot form a cycle.
The transaction is atomic, but it is not exactly-once end to end. A lost response plus a client retry
still duplicates unless the transactionId is deterministic and the retry lands inside the
deduplication window.