[
https://issues.apache.org/jira/browse/CAMEL-23994?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Claus Ibsen updated CAMEL-23994:
--------------------------------
Fix Version/s: 4.22.0
> camel-kafka: consumer offset-handling bugs causing silent message loss
> (resume adapter, pollOnError seek, batching commit, offset repository)
> ---------------------------------------------------------------------------------------------------------------------------------------------
>
> Key: CAMEL-23994
> URL: https://issues.apache.org/jira/browse/CAMEL-23994
> Project: Camel
> Issue Type: Bug
> Components: camel-kafka
> Reporter: Federico Mariani
> Assignee: Claus Ibsen
> Priority: Major
> Fix For: 4.22.0
>
>
> Code review of camel-kafka found several independent bugs in the consumer
> offset/commit handling that all lead to *silent message loss*. Filing them
> together since they are in the same area and interact. All line numbers refer
> to current {{main}} (4.22.0-SNAPSHOT).
> h3. 1. Resume-state restore is dead code: inverted key-format check in
> KafkaResumeAdapter
> {{org.apache.camel.component.kafka.consumer.support.resume.KafkaResumeAdapter#deserialize}}
> (lines 59-75):
> {code:java}
> final String[] keyParts = key.split("/");
> if (keyParts == null || keyParts.length != 2) { // inverted!
> String topic = keyParts[0];
> int partition = Integer.parseInt(keyParts[1]);
> ...
> resumeCache.add(new TopicPartition(topic, partition), offset);
> } else {
> LOG.warn("Unable to deserialize key '{}' because it has in invalid format
> and it will be discarded", key);
> }
> {code}
> The condition is inverted: every *valid* key ({{topic/partition}}, 2 parts)
> is discarded with an "invalid format" warning, and an actually-invalid key
> (no {{/}}) enters the parse branch and throws
> {{ArrayIndexOutOfBoundsException}}. ({{String.split}} also never returns
> {{null}}.)
> Effect: consumers configured with a {{resumeStrategy}} never restore
> persisted offsets on restart — {{resumeCache}} stays empty, {{resume()}}
> seeks nowhere, and the consumer silently restarts from the group /
> {{auto.offset.reset}} position (duplicates or skipped work). Present since
> CAMEL-18688 (59046fe140ff); the log messages prove the intent is the opposite
> of the code.
> Fix: {{if (keyParts.length == 2) \{ parse & cache \} else \{ warn \}}}.
> h3. 2. pollOnError=DISCARD/ERROR_HANDLER skips one unread record on *every*
> assigned partition
> {{KafkaFetchRecords#startPolling}} (line ~437) handles any exception from the
> poll loop with a hard-coded offset:
> {code:java}
> // why do we set this to -1
> long partitionLastOffset = -1;
> pollExceptionStrategy.handle(partitionLastOffset, e);
> {code}
> Because the offset is always -1, {{SeekUtil.seekToNextOffset}} always takes
> its second branch, which seeks {{position(tp) + 1}} for *all* assigned
> partitions. Two problems:
> * Only (at most) one partition contained the failing record; for every other
> partition {{position()}} points at an unread, valid record — seeking {{+1}}
> silently drops one good message per partition.
> * The caught exception need not be record-related at all (e.g. a transient
> {{KafkaException}} from a commit inside the loop) — records are still skipped.
> Also, the first branch of {{seekToNextOffset}} ({{partitionLastOffset !=
> -1}}) is unreachable from the only caller, and would be wrong anyway (it
> seeks every partition to the same offset taken from one partition).
> Fix suggestion: when the exception is a {{RecordDeserializationException}},
> seek only {{e.topicPartition()}} to {{e.offset() + 1}}; for other exceptions
> do not advance any position.
> h3. 3. Batching mode: auto-commit commits offsets of records that are still
> buffered/unprocessed
> {{KafkaRecordBatchingProcessor}} buffers records across polls (since
> CAMEL-20380), but batch completion commits through
> {{AsyncCommitManager.commit()}} → no-arg {{consumer.commitAsync()}}, which
> commits the consumer's *current position* (end of the last poll) for all
> partitions — including records still sitting unprocessed in {{exchangeList}}
> or not yet drained from the current poll.
> Scenario ({{batching=true}}, {{maxPollRecords=100}}): poll #1 returns 50
> records → buffered. Poll #2 returns 100 (position now 150). After adding 50
> of them the batch of 100 is dispatched and committed → *offset 150 is
> committed* although records 100-149 are unprocessed. A crash, rebalance, or
> even graceful shutdown (buffered exchanges are discarded on stop) permanently
> loses them. The same over-commit happens on every {{batchingIntervalMs}}
> flush, because {{hasExpiredRecords()}} dispatches before the just-polled
> records are added.
> Introduced by the interaction of CAMEL-19241 (auto-commit for batching) and
> CAMEL-20380 (cross-poll buffering).
> Fix: track the max offset per {{TopicPartition}} actually contained in the
> dispatched batch and commit explicitly via {{commitAsync(Map<TopicPartition,
> OffsetAndMetadata>, callback)}}.
> h3. 4. Off-by-one when Sync/Async commit managers write to the
> offsetRepository
> The offset-repository contract (see
> {{OffsetPartitionAssignmentAdapter#resumeFromOffset}}: "the state contains
> the last read offset, so seek from the next one", i.e. seek {{state+1}}) is
> honored by {{CommitToOffsetManager}} (writes the raw record offset), but:
> * {{SyncCommitManager}} (lines 70-79) writes {{offset + 1}}
> * {{AsyncCommitManager#postCommitCallback}} (lines 93-99) writes the
> committed offset ({{OffsetAndMetadata.offset()}} = record offset + 1)
> Round trip: record at offset 41 processed → repo stores 42 → on restart the
> adapter seeks {{42 + 1 = 43}} → the record at offset 42 is never delivered.
> One message lost per partition per restart. Introduced by CAMEL-18717 (its
> ITs verify the repo write but not the restart/seek round-trip).
> Fix: save the record offset ({{partitionLastOffset}}) in both managers,
> matching {{CommitToOffsetManager}} semantics.
> h3. 5. Batching + DefaultKafkaManualAsyncCommitFactory: manual.commit() never
> reaches Kafka
> {{DefaultKafkaManualAsyncCommit}} only calls
> {{commitManager.recordOffset(...)}} into the {{OffsetCache}}; the cache is
> flushed to Kafka only by {{AsyncCommitManager.commit(partition)}}. The
> *streaming* facade calls that at the end of each partition batch — the
> *batching* facade ({{KafkaRecordBatchingProcessorFacade}}) never does.
> Scenario:
> {{batching=true&allowManualCommit=true&kafkaManualCommitFactory=#class:...DefaultKafkaManualAsyncCommitFactory&autoCommitEnable=false}}
> — the route calls {{manual.commit()}} for every batch, but offsets reach
> Kafka only on graceful partition revocation. On crash, the group offset is
> still at the initial position → the entire history since startup is
> redelivered.
> Fix: the batching facade should flush recorded offsets after each
> {{processPolledRecords}} (call {{commit(partition)}} for the partitions seen
> in the poll), mirroring the streaming facade.
> ----
> _This issue was researched and written by Claude Code on behalf of Federico
> Mariani (GitHub: Croway). Findings were verified against the source and git
> history before filing._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)