shashank created CAMEL-25756:
--------------------------------

             Summary: camel-kafka - batching consumer loses the records of an 
incomplete batch when a poll fails (e.g. a record that cannot be deserialized, 
pollOnError=ERROR_HANDLER): they are never routed and the next batch commits 
past them
                 Key: CAMEL-25756
                 URL: https://issues.apache.org/jira/browse/CAMEL-25756
             Project: Camel
          Issue Type: Bug
          Components: camel-kafka
            Reporter: shashank
            Assignee: shashank


With {{batching=true}} the Kafka consumer keeps the records of several polls in 
{{KafkaRecordBatchingProcessor.exchangeList}} until the batch is complete 
({{maxPollRecords}} records), the poll timeout expires or 
{{batchingIntervalMs}} passes (since CAMEL-20380). The processor lives in the 
record processor facade, and {{KafkaFetchRecords.startPolling()}} (main 
ddca15435112, line 333) creates a new facade every time it is entered:

{code:java}
final KafkaRecordProcessorFacade recordProcessorFacade = 
createRecordProcessor();
{code}

{{startPolling()}} is entered again, on the same connected Kafka consumer, 
after every exception of the polling loop that the poll exception strategy 
continues on (line 396 and the do-while at line 174): a failed {{poll}} with 
the default {{pollOnError=ERROR_HANDLER}}, and with {{DISCARD}} or {{RETRY}}. 
The new processor starts with an empty batch, so the records held for the 
incomplete batch are dropped. The Kafka consumer does not return them again 
(its position is already past them), and the next complete batch commits its 
highest offset per partition, which is past them. Once a later batch with 
records of the same partition is committed, they are never redelivered, even 
after a restart or a rebalance. Nothing is logged about them. (If no later 
batch of that partition is committed before the consumer stops or the partition 
is revoked, they are polled again from the committed offset: redelivery, not 
loss. With {{allowManualCommit}} the commit past them is the route's own 
{{KafkaManualCommit}} of a later batch.)

A realistic trigger: a record that cannot be deserialized by a deserializer 
that can fail (Avro, JSON schema, a custom one; the default 
{{StringDeserializer}} does not fail). {{poll}} throws 
{{RecordDeserializationException}}, the default error handler skips that one 
record ({{SeekUtil}}, CAMEL-23994), and the good records polled before it, on 
every partition, are lost with it. Up to {{maxPollRecords - 1}} records are 
lost per failed poll. Other poll exceptions the strategy continues on (for 
example a {{KafkaException}} for a corrupt record batch) drop the held records 
the same way. An authentication or authorization error ends the consumer with 
{{ERROR_HANDLER}}; nothing is committed past the held records then.

Example ({{batching=true&maxPollRecords=10}}, one partition): a poll returns 
offsets 0-2 (held, the batch is not complete); the next poll fails on the 
record at offset 3; the error handler seeks to 4; the next poll returns 4-13; 
the batch 4-13 is routed and offset 14 is committed. Offsets 0-2 are never 
routed.

*Fix* (one class): {{KafkaFetchRecords}} creates the record processor facade 
when the Kafka consumer is connected (and reconnected) and keeps it while the 
consumer is connected, so a failed poll keeps the held records and they are 
routed and committed with the next batch. After a reconnect a new Kafka 
consumer starts from the committed offsets, so a new facade (and an empty 
batch) is still created then. The streaming facade holds no state between 
polls; it is unaffected.

*Tests*: new {{KafkaBatchingConsumerPollErrorTest}} (2 tests, a real route and 
Kafka's {{MockConsumer}}; {{batching=true&maxPollRecords=10}}): three records 
are polled and held, a poll fails with a {{KafkaException}} (first test) or a 
{{RecordDeserializationException}} for offset 3 (second test), then ten more 
records arrive. Both fail on main ({{expected: <[a0, a1, a2, b0, b1, b2, b3, 
b4, b5, b6]> but was: <[b0, b1, b2, b3, b4, b5, b6, b7, b8, b9]>}}) and pass 
with the fix; the camel-kafka unit tests pass (222 run, 0 failures, 1 skipped).

*How it was found*: deterministic simulation testing with a fake Kafka world 
(the camel-kafka consumer, its fetcher loop, commit managers and rebalance 
listener real; a fake {{Consumer}} written from the KafkaConsumer Javadoc of 
kafka-clients 4.3, bound through {{kafkaClientFactory}}). The seed picked the 
configuration (streaming or batching, {{autoCommitEnable}}, manual commit with 
the sync / async / no-op commit manager, {{breakOnFirstError}}, 
{{maxPollRecords}} 1 to 50, {{pollTimeoutMs}}, {{batchingIntervalMs}}), poison 
records, failed polls and commits, partial fetches, route restarts and 
suspends. The invariant "every record sent reaches the route" failed in 103 of 
1000 seeds on main, all with {{batching=true}} and a failed poll (first failing 
seed 7); with the fix, 0 of these 1000 seeds fail it with {{batching=true}}.

*Affected*: main, camel-4.23.x, camel-4.22.x, camel-4.18.x and camel-4.14.x 
(same {{createRecordProcessor()}} call in {{startPolling()}} and the same batch 
kept across polls; read, not run). On camel-4.18.x and camel-4.14.x the error 
handler's seek is the one before CAMEL-23994, the held records are dropped the 
same way.

_Filed with Claude Code on behalf of allthingssecurity._




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to