1
0
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

Exactly-once with Spring Kafka on Spring Boot 4

Companion repository for Exactly-Once with Spring Kafka on Boot 4: Idempotent Producers and Transactions on ankurm.com.

The blog post covers the concepts most people need. This repository covers everything — every demonstration, every failure mode, every corner case, as runnable tests with real captured output.

Verified against Spring Boot 4.1.0, Spring Kafka 4.1.0, kafka-clients 4.2.1, JDK 25.0.3. Raw output of the full suite is in results/full-run.txt.


Quick start

No Docker, no Kafka install. Every test starts its own in-JVM KRaft broker.

git clone https://ankurm.com/git.app/asmhatre/spring-kafka-exactly-once-boot4.git
cd spring-kafka-exactly-once-boot4
mvn test

15 tests, about three minutes, mostly the performance sweep.

Run one demonstration at a time:

mvn test -Dtest=IdempotentProducerTest
mvn test -Dtest=TransactionMarkerOffsetTest
mvn test -Dtest=ZombieFencingTest
mvn test -Dtest=DatabaseBoundaryTest
mvn test -Dtest=TransactionCostTest

The demonstrations

Test Shows Key measured result
IdempotentProducerTest Idempotence verified from the broker via describeProducers, and the config that silently disables it default producer → 1 tracked producer state; acks=10
TransactionMarkerOffsetTest Transaction markers, aborted records, read_committed vs read_uncommitted, offset arithmetic 5 records → endOffset 6; offset span overstates record count by 100%
ReadProcessWriteTest The Spring read-process-write loop, and rollback + redelivery listener ran 2x, read_committed consumer sees 1
ZombieFencingTest What transactional.id is for, and how a random one destroys the guarantee shared id → ProducerFencedException; random id → both commit
DatabaseBoundaryTest Kafka EOS does not cover your database, and the fix naive listener → 2 rows; idempotent listener → 1 row
TransactionCostTest What transactions cost, and the setting that controls it 1 record/tx = 711 rec/s; 100/tx = 78,770 rec/s

The documentation

Chapter Covers
1. Boot 4 setup The spring-boot-starter-kafka trap, the one property that enables EOS, testing without Docker
2. Idempotence PIDs and sequence numbers, the acks=1 silent downgrade, the constraints
3. Transactions and markers sendOffsetsToTransaction, isolation levels, control records, the Last Stable Offset
4. Fencing transactional.id, epochs, EOSMode V2, KIP-890 on Kafka 4.x
5. The database boundary Why there is no 2PC, deprecated ChainedKafkaTransactionManager, dedupe keys, the outbox pattern
6. Performance Measured cost per transaction size, and why max.poll.records is the knob
7. Corner cases Timeouts, error handlers, DLTs, compaction, cross-cluster, Kafka Streams

Six findings you will not read elsewhere

  1. Boot 4 moved Kafka auto-configuration out of spring-boot-autoconfigure. Depend on spring-kafka alone and you get a NoSuchBeanDefinitionException for KafkaTemplate that looks like a generics problem. Use spring-boot-starter-kafka. → docs/01
  2. acks=1 silently disables idempotence — proven from the broker, not the config. Admin.describeProducers() shows 1 tracked producer state for a default producer and 0 for an acks=1 producer. Setting enable.idempotence=true explicitly turns that silent downgrade into a startup failure. → docs/02
  3. Transactions do not inflate consumer lag. This widely repeated claim is wrong: a drained read_committed consumer reaches exactly zero lag, because its position advances over markers too. What markers do break is counting records by offset arithmetic — measured here at 100% overstatement. → docs/03
  4. A random transactional.id per start-up silently defeats fencing. Measured: two producers with different UUIDs both commit, and nothing is fenced. Everything appears to work while delivering exactly the duplicate processing you enabled transactions to prevent. → docs/04
  5. Aborted records reach the log only if they were already flushed. abortTransaction() discards un-flushed batches, so whether a rolled-back record occupies a permanent offset depends on timing. Rely on read_committed, not on either behaviour. → docs/03 §3.6
  6. One record per transaction is ~111x slower than one hundred. The cost is per commit, not per record, and the setting that controls it in Spring is max.poll.records — not anything named "transaction". → docs/06

Reference machine

CPU AMD Ryzen 5 5600U, 6 cores / 12 threads
RAM 15.3 GB
OS Windows 11 Home
JDK java 25.0.3+9-LTS-195
Kafka in-JVM EmbeddedKafkaKraftBroker, kafka-clients 4.2.1, transaction.version=2

A laptop and a single-node broker. The absolute throughput figures are illustrative; the relationships are what generalise. See docs/06 §6.3 for what to trust.


Licence

MIT. See LICENSE.

Description
Exactly-once with Spring Kafka on Spring Boot 4.1: idempotent producers verified from the broker, transactions, markers and offset arithmetic, zombie fencing, the database boundary, and measured transaction cost. Runnable with EmbeddedKafka (KRaft) - no Docker. Companion code for the ankurm.com guide.
Readme 96 KiB
Languages
Java 100%