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)
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 atransactional.idtied to the specific input partition. V2 (KIP-447) uses consumer group metadata for fencing, so one producer per instance is sufficient. Older guidance abouttransactional.idper topic-partition describes the removedEOSMode.ALPHAand 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.