A listener can acknowledge a record only after its local database operation finishes, but a broker offset and database commit remain separate events.
Spring Kafka manual acknowledgement: commit work before advancing the offset
Place the acknowledgement after the write
The checked listener accepts a stock reservation, opens a database transaction, inserts a stable event ID and conditionally debits one SKU. It calls acknowledge only after the transaction callback returns. If the debit fails, the method throws and the test observes no acknowledgement. The event marker rolls back with the debit. The event ledger is what makes a later delivery safe to inspect again.
Manual acknowledgement is a container setting, not a method parameter that changes consumer behavior by itself. The listener names a manualAckKafkaListenerContainerFactory; the fixture also checks a factory configured with MANUAL_IMMEDIATE. An actual application must register that named factory with compatible deserializers and a consumer group. A method invoked directly in a unit test does not prove that a Kafka container loaded it or committed an offset.
Draw the failure cut points
A process can commit the database transaction and stop before acknowledging. Kafka may then redeliver the same event. The event-ID constraint prevents a second debit, after which the listener may acknowledge the duplicate. If the acknowledgement succeeds but a separate downstream side effect fails, that side effect has no retry guarantee from this offset. Put such work behind its own durable boundary. The outbox write covers one database commit; the Kafka handoff still needs replay handling.
Code boundary
@KafkaListener(topics = "stock-reservations", groupId = "stock-ledger-v1",
containerFactory = "manualAckKafkaListenerContainerFactory")
public void receive(ConsumerRecord<String, StockReservation> record,
Acknowledgment acknowledgment) {
StockReservation reservation = record.value();
if (reservation == null || record.key() == null
|| !record.key().equals(reservation.sku())) {
throw new IllegalArgumentException("record key and reservation SKU differ");
}
try {
transactions.executeWithoutResult(status -> {
jdbc.update("insert into applied_reservation(event_id, sku, units) values (?, ?, ?)",
reservation.eventId(), reservation.sku(), reservation.units());
int updated = jdbc.update(
"update stock_balance set available = available - ? where sku = ? and available >= ?",
reservation.units(), reservation.sku(), reservation.units());
if (updated != 1) throw new IllegalStateException("stock item absent or insufficient");
});
} catch (DuplicateKeyException duplicate) {
var recorded = jdbc.queryForMap(
"select sku, units from applied_reservation where event_id = ?", reservation.eventId());
if (!reservation.sku().equals(recorded.get("SKU"))
|| reservation.units() != ((Number) recorded.get("UNITS")).intValue()) {
throw new IllegalStateException("event ID reused for a different reservation", duplicate);
}
}
acknowledgment.acknowledge();
}Verification boundary
The Java 21 / Spring Boot 4 source kit checks acknowledgesOnlyAfterCommittedStockChange and insufficientStockRollsBackMarkerAndDoesNotAcknowledge; listenerDeclaresManualAcknowledgmentContainerAndStableGroup checks annotation and factory settings.
Cost and limits
Each record incurs a database transaction and at least an event-ID index write. Throughput depends on partition count, database contention and acknowledgement policy. The source kit runs no broker, so it cannot establish redelivery timing, rebalance behavior or offset durability.
Common Mistakes
- Do not infer manual acknowledgement from the presence of an Acknowledgment argument alone.
- Do not acknowledge before the business transaction commits.
- Do not call this an atomic Kafka-and-JDBC transaction.
Read next
Spring consumer idempotency: reserve an event ID with the stock mutation, Spring transaction propagation: joined rollback and independent commit, Spring transactional outbox: commit a receipt and event row together, Spring Kafka duplicate delivery: let a unique event ID decide the second debit, Spring Kafka testing layers: know what a direct listener test cannot prove.
Related boundary
Spring Cloud Stream consumer group: scale one logical reader without copying every event
Related boundary
Spring AMQP prefetch: bound unacknowledged messages before raising concurrency
