Federico Mariani created CAMEL-23994:
----------------------------------------

             Summary: 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


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)

Reply via email to