1
0

Exactly-once with Spring Kafka on Spring Boot 4: demonstrations, docs and captured output

Companion suite for the ankurm.com guide. Every test runs against an in-JVM KRaft broker
(spring-kafka-test), so there is no Docker requirement. Verified on Spring Boot 4.1.0,
Spring Kafka 4.1.0, kafka-clients 4.2.1, JDK 25.0.3. results/full-run.txt is unedited
output of mvn test: 15 tests, 0 failures.

Demonstrations
  _01_idempotence   verifies idempotence from the BROKER via Admin.describeProducers rather
                    than by reading config back: a default producer registers one producer
                    state, an acks=1 producer registers zero. Also covers the ConfigException
                    you get only when idempotence is requested explicitly.
  _02_transactions  transaction markers consuming offsets, aborted records surviving in the
                    log, read_committed vs read_uncommitted, and a correction: markers do NOT
                    inflate consumer lag (a drained consumer reaches zero), they break
                    counting records by offset arithmetic (measured 100% overstatement).
  _03_read_process_write
                    the Spring loop with container-managed transactions; a listener that
                    fails after sending runs twice and is committed once.
  _04_fencing       shared transactional.id fences the zombie; a random per-startup id lets
                    both producers commit and silently defeats the guarantee entirely.
  _05_database      Kafka EOS does not extend to a database. Naive listener writes 2 rows for
                    1 message; the same listener keyed on topic-partition-offset writes 1.
  _06_performance   cost per transaction size, median of 5 interleaved rounds.

Docs
  Seven chapters: Boot 4 setup (auto-configuration moved to spring-boot-starter-kafka),
  idempotence, transactions and markers, fencing and KIP-890, the database boundary and the
  outbox pattern, performance, and corner cases.

Measured highlights
  acks=1 producer          0 broker-tracked producer states (idempotence silently off)
  5 records in one tx      endOffset 6; offset span overstates record count by 100%
  1 record  per tx            711 records/s
  100 records per tx       78,770 records/s  (~111x)
This commit is contained in:
2026-07-31 22:27:35 +05:30
commit e23ca359eb
26 changed files with 3635 additions and 0 deletions

91
docs/04-fencing.md Normal file
View File

@@ -0,0 +1,91 @@
# 4. Zombie fencing and `transactional.id`
> Runnable companion: [`ZombieFencingTest`](../src/test/java/com/ankurm/kafka/eos/_04_fencing/ZombieFencingTest.java)
## 4.1 The zombie
A processor reads, transforms and writes. It stalls — a long GC pause, a network partition, a
container the orchestrator has decided is dead. A replacement starts, the group rebalances, the new
instance owns the partition.
Then the old instance wakes up. It holds a partially completed transaction and does not know it was
replaced. If it were allowed to commit, the same input would be processed twice and both results
would be committed. That is the zombie.
## 4.2 The defence
`transactional.id` is a **stable, durable identity** the transaction coordinator remembers, along
with an **epoch**. Every `initTransactions()` for an existing id bumps the epoch and invalidates
anything holding the old one.
Measured:
```
Producer A: initTransactions, beginTransaction, send (transaction OPEN)
Producer B: initTransactions with the SAME id (epoch bumped)
Producer A: commitTransaction
-> REJECTED with ProducerFencedException
Producer B: commits normally
```
The zombie's work is discarded **by the broker**, not by any code you wrote.
## 4.3 The two ways to get it wrong
### Random per start-up — fencing never happens
```java
// Catastrophic, and everything appears to work
props.put(TRANSACTIONAL_ID_CONFIG, "processor-" + UUID.randomUUID());
```
Measured: producer A opens a transaction, producer B starts with a fresh UUID and commits, then A
commits too. **Nothing is fenced. Both commit.** The coordinator saw two unrelated identities and
had no reason to fence either.
This is the most damaging Kafka transactions misconfiguration precisely because it is invisible:
transactions commit, tests pass, and you have exactly the duplicate-processing problem you turned
transactions on to prevent.
### Shared across instances — everything fences everything
Every instance bumps every other instance's epoch continuously, and the group thrashes with
`ProducerFencedException`s.
## 4.4 Getting it right in Spring
```yaml
spring.kafka.producer.transaction-id-prefix: order-processor-tx-
```
Spring appends a suffix per producer. The prefix must be **stable across restarts** and **unique per
application instance** — typically the application name plus a stable instance identifier
(a StatefulSet ordinal, a pod index, a configured node id), *not* a random value and *not* a value
shared by every replica.
Since Spring Kafka 3.2, `TransactionIdSuffixStrategy` lets you control suffix allocation, which
matters when producers are cached and reused across partitions.
> With `EOSMode.V2` — the only mode since Spring Kafka 3.0 — the producer no longer needs a
> `transactional.id` tied to the specific input partition. V2 (KIP-447) uses consumer group metadata
> for fencing, so one producer per instance is sufficient. Older guidance about
> `transactional.id` per topic-partition describes the removed `EOSMode.ALPHA` and does not apply.
## 4.5 Kafka 4.x: KIP-890 changes which exception you see
[KIP-890](https://cwiki.apache.org/confluence/display/KAFKA/KIP-890) strengthened the transaction
protocol against hanging transactions. Under `transaction.version=2` — the default on Kafka 4.x, and
what the embedded broker in this repository reports at start-up — the **producer epoch is bumped on
every transaction**, not only at `initTransactions()`.
Practical effect: the exception on fencing may be `InvalidProducerEpochException` rather than the
`ProducerFencedException` that older articles promise. Catch both, or catch neither and let the
container restart the producer, which is what Spring does.
A hanging transaction is worth understanding because its blast radius is large: it holds the Last
Stable Offset back, which stalls every `read_committed` consumer on the partition and prevents log
compaction. KIP-890 exists to make that class of bug impossible rather than merely rare.
---
Next: [05-database-boundary.md](05-database-boundary.md).