Transactions & idempotence
Idempotent-producer dedupe, the transaction coordinator state machine on slot-sharded JSON files, and EOS v2 end to end.
In Apache Kafka, exactly-once rests on two pieces of broker machinery:
per-partition producer state (the idempotence dedupe window) and a
transaction coordinator whose state lives in the internal
__transaction_state topic. kaas keeps the first nearly verbatim and
replaces the second: there is no __transaction_state topic. Like
__consumer_offsets
(Consumer-group coordination), it is an
internal topic replaced by plain JSON files on the shared volume — the
third substitution from the introduction.
None of this is exotic. The Java producer has enabled idempotence by
default since Kafka 3.0, so every kafka-console-producer invocation
exercises this machinery — it's hot-path, not an opt-in feature. Four
layers of state, all on the shared volume:
| Layer | Where it lives |
|---|---|
PID allocation (InitProducerId) | a persisted block allocator, one file per broker under /data/__cluster/producer_ids/; transactional IDs get the same PID + epoch+1 on rejoin |
| Per-partition dedupe | a 5-batch ring per PID, held in memory under the partition mutex |
| Snapshot persistence | producer-state.snapshot next to the partition manifest |
Per-transactional.id state | slot-sharded /data/__cluster/txn_state/slot-N.json |
Idempotent producer
InitProducerId (key 22) hands a non-transactional producer a fresh PID
at epoch 0. On every Produce, classification runs under the partition
mutex, before append against a per-PID ring of the last 5 batches —
mirroring the Java client's max.in.flight.requests.per.connection=5:
- duplicate → echo the cached
baseOffset, no log write; - out-of-order sequence → error 45 (
OUT_OF_ORDER_SEQUENCE_NUMBER); - stale epoch → error 47 (
PRODUCER_FENCED); - otherwise accept and advance the ring.
The ring survives leadership moves via producer-state.snapshot
(written on segment roll + relinquish, restored on take-over — see
File-handle ownership).
PIDs are never reused
The dedupe ring is keyed by (PID, epoch), so handing the same PID to
two different producers is not a cosmetic collision — the second one
inherits the first one's sequence history. Its batches are then either
silently dropped (sequence range matches a cached batch → classified
duplicate, stale base offset echoed, produce "succeeds", consumers read
nothing) or rejected with OUT_OF_ORDER_SEQUENCE_NUMBER. Both
failure modes have been observed in practice.
Apache Kafka draws PIDs from a global counter whose next block is
persisted (ZooKeeper's /latest_producer_id_block, KRaft's
ProducerIdsRecord). kaas has no metadata quorum (a stated
non-goal), so it partitions the PID space by
broker ordinal instead:
pid = (broker_id + 1) * 2^40 + local
Each broker is the single writer of its own slice and of its own
block file /data/__cluster/producer_ids/kaas-<id>.json, so there is
no cross-broker read-modify-write on the shared volume. local
advances in blocks of 1000, and the block end is persisted (tmp +
fsync + rename) before any PID in it is handed out — a crash can
only skip PIDs forward, never rewind. The + 1 keeps broker 0 clear of
the low PIDs an earlier in-memory allocator handed out, so an upgrade
can't collide with producer state already on the volume.
Fencing across partitions and brokers
A transactional producer that reconnects gets the same PID with
epoch+1 — fencing is the monotonic epoch, exactly Apache's KIP-360
contract. Two mechanisms make the bump stick everywhere:
- Cross-partition fence: after every
epoch > 0rejoin, the InitProducerId handler walks every local partition, advances the PID's epoch and clears its dedupe window — so a zombie batch from the old session is fenced even on partitions the new session hasn't touched yet. - Cross-broker fence broadcast: the bump is appended to a
per-broker fence log under
/data/__cluster/producer_fences/; every peer polls the logs and applies the bumps it hasn't seen. Same shared-volume pattern as the marker queue below — no new RPC surface.
Transaction state machine
Per-transactional.id state is slot-sharded across
/data/__cluster/txn_state/slot-N.json (50 slots,
fnv1a(transactional.id) % 50 — the same 50 Apache Kafka defaults to
for transaction.state.log.num.partitions). The states a transaction
actually visits:
stateDiagram-v2
[*] --> Empty : InitProducerId first allocation<br/>PID assigned, epoch 0
Empty --> Ongoing : AddPartitionsToTxn /<br/>AddOffsetsToTxn<br/>stamps ongoingSinceMs
Ongoing --> PrepareCommit : EndTxn(commit)<br/>partitions + groups retained —<br/>the durable dispatch set
Ongoing --> PrepareAbort : EndTxn(abort)
Ongoing --> PrepareAbort : timeout reaper, 10 s sweep<br/>ongoingSinceMs + transactionTimeoutMs elapsed<br/>epoch bump, dispatch set retained
PrepareCommit --> CompleteCommit : every marker durable<br/>clears partitions + groups,<br/>staged offsets committed
PrepareAbort --> CompleteAbort : every marker durable<br/>staged offsets discarded
CompleteCommit --> Ongoing : AddPartitionsToTxn /<br/>AddOffsetsToTxn<br/>next transaction begins
CompleteAbort --> Ongoing : AddPartitionsToTxn /<br/>AddOffsetsToTxn
Facts the diagram compresses:
- The
Prepare*states are the durability pivot, exactly as in Apache: a prepared entry keeps its partition and group lists, and that retained list is the durable record of "these markers still owe a write". A marker-dispatch failure, a coordinator crash, or a producer retry all re-derive the identical dispatch set from it; only the transition toComplete*— taken once every marker is durable — clears the lists and releases the staged offsets. InitProducerIdon a rejoin does not reset the state: the entry keeps the same PID and bumpsepoch += 1— fencing is purely the monotonic epoch. Only epoch overflow (i16::MAX) allocates a fresh PID and resets toEmpty.- A retried
EndTxnin the matchingComplete*state is answered idempotently (no second transition); a direction mismatch returnsINVALID_TXN_STATE, andEndTxnonEmptyisINVALID_TXN_STATEtoo. Epoch mismatches returnPRODUCER_FENCEDeverywhere.
EndTxn: commit flow
Cross-broker marker dispatch goes through a queue on the shared volume —
there is no WriteTxnMarkers RPC between brokers. EndTxn is
two-phase: prepare, dispatch every marker, then complete. For a
peer-led partition, a durably written queue entry counts as a
dispatched marker (the peer's watcher retries until it applies); a
dispatch failure returns the retriable COORDINATOR_NOT_AVAILABLE and
leaves the transaction prepared, so a producer retry — or the
background reconcile below — re-derives the same dispatch set and
finishes the job.
flowchart TD
producer["Producer: EndTxn(commit)"] --> handler["EndTxn handler on the txn coordinator broker<br/>ownership gate — otherwise NOT_COORDINATOR"]
handler --> prepare["state store: prepare_end_txn<br/>Ongoing → PrepareCommit<br/>partitions + groups retained — the dispatch set<br/>persist slot-N.json (tmp + fsync + rename)"]
prepare --> split{"leader of each<br/>txn partition?"}
split -- "self-led" --> local["write COMMIT control batch directly<br/>append to the log, acks=-1"]
split -- "peer-led" --> enqueue["marker queue enqueue<br/>marker_queue/to-<broker>/<pid>-<epoch>.json"]
local --> complete["state store: complete_end_txn<br/>PrepareCommit → CompleteCommit<br/>clears partitions + groups, ongoingSinceMs = 0"]
enqueue --> complete
complete --> hook["offset hook, per recorded group<br/>commit → commit pending offsets<br/>abort → discard pending offsets"]
hook --> respond["EndTxn response error_code=0"]
split -- "any dispatch fails" --> retriable["respond COORDINATOR_NOT_AVAILABLE (retriable)<br/>entry stays PrepareCommit — reconcile finishes it"]
enqueue -.-> watcher["peer broker's marker watcher<br/>polls its own to-<self>/ every 2 s"]
watcher -.-> apply["applies marker as control-batch append<br/>to partitions it leads, then deletes the file"]
The offset hook fires on the complete transition — not the prepare —
so staged offsets only become visible to OffsetFetch once the
markers backing them are durable.
Self-led markers are written before the queue entries, so a
coordinator crash mid-dispatch never loses the local marker. A retried
EndTxn overwrites the same {pid}-{epoch}.json file — the queue is
idempotent by naming. Consumers in read_committed only see the
transaction's records once these markers land (the fetch path clamps to
the last stable offset).
Coordinator routing and staged offsets
Which broker coordinates a transaction is the same deterministic hash
story as consumer groups: hash(transactional.id) picks the slot
owner, and non-coordinators answer the txn APIs with NOT_COORDINATOR
— see Consumer-group coordination. On
coordinator failover the new owner simply reads the same slot file off
the shared volume: close-to-open consistency means the file is the
materialized state, with no log replay — this is the architectural
replacement for Apache's __transaction_state topic.
TxnOffsetCommit (key 28) stages consumer offsets in a pending
layer keyed by (group ID, PID) in the offset store — invisible to
OffsetFetch until EndTxn commits. AddOffsetsToTxn (key 25)
records which groups the transaction will touch, so the EndTxn offset
hook knows exactly which pending sets to commit or discard. That hook
firing atomically with the state transition is the KIP-447 (EOS v2)
contract.
The timeout reaper and the marker reconcile
The transaction timeout reaper fires every 10 s — Apache's
transaction.abort.timed.out.transaction.cleanup.interval.ms default.
Any Ongoing entry past ongoingSinceMs + transactionTimeoutMs
transitions to PrepareAbort with an epoch bump — and, crucially,
keeps its partition and group lists: a timed-out transaction owes
ABORT markers exactly like a client-driven abort does.
A marker reconcile pass shares the same 10 s tick: it walks every prepared transaction this broker coordinates, places the outstanding markers, and runs the complete transition — which is when the staged offsets are discarded (or committed) via the offset hook. The two halves are complementary, not redundant: the EndTxn handler's inline dispatch keeps commit latency off the sweep interval, but only the reconcile can finish a transaction whose producer crashed, was fenced, or (for a reaper abort) never existed to retry at all. A dispatch that still fails is left prepared and retried next pass, deliberately without bound.
Both sweeps are ownership-gated: a transaction slot file has exactly one legal writer — its coordinator — so each broker reaps and reconciles only the transactions it owns (an ungated sweep would have every broker read-modify-writing the same slot files on the shared volume, violating the substrate rules). The gate degrades safely at both edges: with no coordinator installed (dev/single-broker) everything is owned, and in cluster mode nothing is owned until the first assignment load — which delays a sweep by one poll rather than skipping it.
Implementation notes (for contributors)
- Dedupe ring:
crates/kaas-storage/src/idempotence.rs(ProducerStates); snapshot persistence:crates/kaas-storage/src/producer_snapshot.rs. - PID block allocator:
crates/kaas-broker/src/producer_id.rs(gh #219 — both the silent-drop and theOUT_OF_ORDERsymptoms of PID reuse were seen there; the pre-fix allocator was an in-memoryAtomicI64). - Fence-on-rejoin contract: gh #22. Cross-broker fence broadcast
(gh #108 phase 2): fence log in
crates/kaas-coordinator/src/fence_log.rs, applied by each peer'sFenceWatcher(crates/kaas-broker/src/fence_watcher.rs). - Txn state store + slot sharding:
crates/kaas-coordinator/src/txn_state.rs— the architectural answer to gh #29 (no literal__transaction_statetopic). - Marker queue: gh #175. Txn-slot hash ownership: gh #91. Two-phase
EndTxn + the marker reconcile: gh #225, shared dispatch in
crates/kaas-broker/src/txn_markers.rs(reconcile_pending_markersoverTxnStateStore::pending_marker_dispatches). - The reaper and reconcile are spawned by the broker's cluster runtime
(
bins/kaas/src/cluster.rs), both gated onBroker::owns_txn(abort_overdue_ownedon the store side; the ungatedabort_overdueis tests/dev-mode only). - The full KIP-447 consume-process-produce-commit round trip runs
against an in-process broker in
bins/kaas/tests/eos_v2.rs.