Repository navigation
Async acks: a re-delivered record after an eager rebalance can leave the consumer paused forever (ackInOrder deferred-drain IndexOutOfBoundsException)
#4675
Replies: 1 comment 1 reply
|
Your reading of One correction: So for your questions: the container now owns this, which makes (b) moot. (3) still stands. On Until you can upgrade, be careful with the cooperative assignor as a workaround: it stops revoking retained partitions, but then you hit your own #4708 (fix pending in #4709). Cancelling the futures on revoke doesn't help on 3.3.x/4.1.1 either, because a cancelled future still goes through // generations: ConcurrentHashMap<TopicPartition, AtomicLong>, incremented in
// ConsumerAwareRebalanceListener.onPartitionsRevokedBeforeCommit while holding `lock`
@KafkaListener(...) // void return, asyncAcks=true, MANUAL_IMMEDIATE
void listen(ConsumerRecord<K, V> rec, Acknowledgment ack) {
TopicPartition tp = new TopicPartition(rec.topic(), rec.partition());
long g = generations.computeIfAbsent(tp, k -> new AtomicLong()).get();
process(rec).whenComplete((r, t) -> {
synchronized (lock) {
if (generations.get(tp).get() == g) {
ack.acknowledge(); // skip acks from a previous assignment
}
}
});
}Static membership ( |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Before I open an issue I would like a sanity check on whether this is a bug or a misuse on my side.
Setup
@KafkaListenermethod returningCompletableFuture<Void>(soasyncRepliesis true)AckMode.MANUAL_IMMEDIATE,ContainerProperties.asyncAcks = true, noAcknowledgmentparameter (the container acks on future completion)max.poll.records = 5, containerconcurrency = 1partition.assignment.strategy(resolves to the eagerRangeAssignor)What we observe
After a group membership change (a pod replaced), one container stays paused indefinitely on the main topic. It keeps polling and heartbeating, so it is never evicted, and the committed offset never moves. Lag grows on exactly that member's partitions until the next rebalance. Nothing is logged at any level.
Pausing for incomplete async acks: {...}at DEBUG is the only visible trace, and it names offsets whose work has long finished.What I think happens (read from the sources; the code is identical in 3.3.16 and 4.1.1)
The eager assignor revokes every partition on any join or leave.
onPartitionsRevokedremoves the partition fromoffsetsInThisBatch/deferredOffsetsbut does not cancel in-flight futures. When the partition is re-assigned to the same consumer,captureOffsets()rebuilds the lists from the committed offset, and the uncommitted records are re-delivered while their first copies are still running. So each offset now has two futures that will each callacknowledge().ackInOrder()handles a duplicate of a lower offset (rejected as "acknowledging a stale record"), but a duplicate of a higher offset is appended todeferredwith no check. The drain loop then assumesdeferrednever holds more entries thanoffshas remaining:Minimal sequence on one partition with offsets a < b < c, each with two in-flight copies:
offs.remove(0)on an empty list throwsIndexOutOfBoundsExceptionIllegalStateException("Unexpected ack ...; offsets list is empty")The exception is thrown before
processAck(recordToAck)and before theoffsetsInThisBatch.remove(part)cleanup, so:offsetsInThisBatch.isEmpty()is false anddoResumeConsumerIfNeccessary()never clearspausedForAsyncAcks,Both exceptions surface inside the
whenCompleteinMessagingMessageListenerAdapter.handleResult()on a future nobody observes, which is why nothing is logged.Two offsets are enough to trigger it (two copies of the higher one completing before the lower one). With several records in flight per partition and rebalances from routine pod churn, the ordering hits often enough for us to see it every few days.
Why I am not sure it is a bug
The
ConsumerAcknowledgmentcarries only the record, so the container has no way to tell an ack from the previous assignment generation apart from one for the current one. Maybe the intended contract is that an application using async acks must itself cancel or suppress completions for revoked partitions (for example viaonPartitionsRevokedBeforeCommit)? I could not find that stated in the reference docs, and the container's own pause/resume logic (repauseIfNeededre-pausing after a rebalance) suggests the rebalance-while-paused case was considered.Version notes
deferred.get(0).offset() == recordToAck.offset() + 1, so duplicates were harmless.containsUnackedOffsets, which fixed the gap reported there but left the duplicate case.mainhave the same loop.Questions
deferred.addand guard theoffs.remove(0)in the drain loop so a duplicate cannot leave the partition mapped to an empty list?acknowledge()insidehandleResult'swhenCompleteis lost, which makes this very hard to diagnose in production.I have a deterministic reproduction in our application's test suite (Testcontainers Kafka, a plain
KafkaConsumerjoining and leaving the group once while records are in flight) and can distil it into a standalone project if that helps.All reactions