1
0
Files
Ankur e23ca359eb 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)
2026-07-31 22:27:35 +05:30

4.0 KiB

4. Zombie fencing and transactional.id

Runnable companion: ZombieFencingTest

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

// 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 ProducerFencedExceptions.

4.4 Getting it right in Spring

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 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.