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)
92 lines
4.0 KiB
Markdown
92 lines
4.0 KiB
Markdown
# 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).
|