Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Spring Kafka manual acknowledgement: commit work before advancing the offset

Last updated: 1 Oct 20264 min read
tutorial
IntermediateBy AITrove Editorial

A listener can acknowledge a record only after its local database operation finishes, but a broker offset and database commit remain separate events.

Download Spring source kit

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

Java
@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

spring
spring-boot
kafka
kafka-listener-manual-ack
Storage details