shashank created CAMEL-25313:
--------------------------------
Summary: camel-kafka - a consumer resumed after a suspend may
never consume again: the fetcher thread ends while the consumer is suspended,
and a pause or resume request can be overwritten
Key: CAMEL-25313
URL: https://issues.apache.org/jira/browse/CAMEL-25313
Project: Camel
Issue Type: Bug
Components: camel-kafka
Reporter: shashank
Suspending the route of a Kafka consumer ({{KafkaConsumer.doSuspend}}) only
sets the requested state of each {{KafkaFetchRecords}} task
({{state.set(PAUSE_REQUESTED)}}, from the caller thread); resuming sets
{{RESUME_REQUESTED}}. The fetcher thread applies the request in
{{updateTaskState()}} after the next poll. Callers are the route controller and
JMX, and the route policies ({{ThrottlingExceptionRoutePolicy}} suspends when
the circuit opens and resumes from its half-open timer thread; {{keepOpen}}
suspends when the route starts).
*1. The fetcher thread ends while the consumer is suspended.* {{run()}} starts
with {{if (!isKafkaConsumerRunnable()) return;}} and loops {{do { ...
startPolling(); } while ((canContinue || reconnect) &&
isKafkaConsumerRunnable());}}, where {{isKafkaConsumerRunnable()}} is false for
a suspended consumer. The polling loop inside {{startPolling}} uses
{{isKafkaConsumerRunnableAndNotStopped()}} since CAMEL-18327, so it keeps
polling (paused) while suspended, but as soon as the thread leaves it, or
starts, while the consumer is suspended, it terminates ("Terminating
KafkaConsumer thread"). {{doResume}} then only sets the state of a task that no
thread runs: the route is started, the consumer never consumes again, and
nothing is logged as an error. This happens:
* with {{ThrottlingExceptionRoutePolicy}} and {{keepOpen=true}}, always (not a
race) when the route starts with the CamelContext: the policy suspends the
consumer in {{onStart}}, and the fetcher tasks are only submitted once the
context has started ({{KafkaComponent.pendingConsumer}}), so {{run()}} returns
at once; setting {{keepOpen}} to false later (JMX) resumes a consumer without a
thread. This is the first part of CAMEL-19358 ("couldn't be resumed after we
changed the keepOpen to false");
* when a poll fails while the consumer is suspended (default
{{pollOnError=ERROR_HANDLER}}: {{startPolling}} returns after handling the
exception), or with a reconnect ({{pollOnError=RECONNECT}}, or
{{breakOnFirstError}} when the suspend arrives during the batch).
*2. A request made while the previous one is applied is lost.*
{{updateTaskState}} reads the state, calls {{consumer.pause(assignment)}} (or
seeks and calls {{consumer.resume(assignment)}}) and then sets {{PAUSED}} (or
{{RUNNING}}) unconditionally. A resume requested between the two is
overwritten: the Kafka consumer stays paused while the route is started (never
consumes again, {{isKafkaPaused}} stays true). A suspend requested during the
resume is overwritten by {{RUNNING}}: the consumer keeps consuming while the
route is suspended.
h3. Reproduction
{{KafkaConsumerSuspendResumeTest}} (camel-kafka, no broker: a
{{KafkaClientFactory}} returning a {{MockConsumer}} whose {{pause}}/{{resume}}
can be held on a latch to force the interleaving), on main:
{noformat}
testStartedWithOpenCircuit (keepOpen=true at start, then keepOpen=false):
mock://result Received message count. Expected: <1> but was: <0>
testPollErrorWhileSuspended (one poll throws while suspended, then resume):
mock://result Received message count. Expected: <1> but was: <0>
testResumeWhileThePauseIsApplied (resumeRoute while the thread is in
consumer.pause()):
mock://result Received message count. Expected: <1> but was: <0>
testSuspendWhileTheResumeIsApplied (suspendRoute while the thread is in
consumer.resume()):
The Kafka consumer must be paused while the route is suspended ==> expected:
<true> but was: <false> within 20 seconds.
testStartedWithOpenCircuitConsumesNothing (a record on the topic when the route
starts with keepOpen=true):
ConditionTimeout: the consumer is never paused (the thread is gone)
{noformat}
{{testStartedWithOpenCircuitConsumesNothing}} also fails with only the first
three parts of the fix ({{Expected: <0> but was: <1>}}). No sleeps: latches,
Awaitility, {{MockEndpoint}}.
The defect was found with a TLA+ model of the fetcher thread against route
suspend/resume ({{state.get()}}, the consumer call and {{state.set()}} as
separate steps; the run-loop conditions; a poll that can fail). "Once every
operation is done and no request is pending, a started route has a polling
thread with the consumer not paused, and a suspended route has its consumer
paused" is violated in 4 steps (suspend, the thread starts and returns,
resume), in 5 steps with a failing poll, and in 7 steps for the overwritten
resume (suspend, poll, read PAUSE_REQUESTED, resume, consumer.pause, set
PAUSED). With the fix it holds, as does the liveness property, also with two
suspend/resume cycles, failing polls and reconnects. The model has no rebalance
and abstracts the records ("alive and not paused"), so it does not cover the
rebalance-listener part, which the MockConsumer test does.
h3. Proposed fix
* {{updateTaskState}}: {{state.compareAndSet(PAUSE_REQUESTED, PAUSED)}} and
{{state.compareAndSet(RESUME_REQUESTED, RUNNING)}}, so a newer request is
handled on the next iteration.
* {{run()}} and its do-while end only when the consumer is stopping or stopped
({{isKafkaConsumerRunnableAndNotStopped()}}, as the polling loop): a suspended
consumer keeps its thread, which polls with the consumer paused.
* After a (re)connect, {{state.compareAndSet(PAUSED, PAUSE_REQUESTED)}}: the
new Kafka consumer is not paused.
* The rebalance listener ({{onPartitionsAssigned}}, on the fetcher thread
inside {{poll}}) pauses the assigned partitions when a pause is requested or
applied, so that the poll which assigns them does not return their records.
Without it a thread that starts or reconnects while suspended (now kept alive)
processes the first batch before {{updateTaskState}} pauses: with
{{keepOpen=true}} the records on the topic at startup would be consumed with
the circuit open (the other half of CAMEL-19358's report). Records of a batch
already polled when the suspend arrives are still processed first, as today.
With the fix the new tests and the camel-kafka unit tests (224, 1 skipped)
pass; the integration tests need Docker and were not run.
Affected: 4.14.x, 4.18.x and main (same code, GitHub contents API).
Duplicate check (2026-10-04): JIRA "kafka" with suspend/resume and consumer
since 2022 (17 issues: CAMEL-19358 above, CAMEL-18327, CAMEL-18760,
CAMEL-18759, CAMEL-20227 offsets of the pausable consumer, none about the
thread or the lost requests), "KafkaFetchRecords" (CAMEL-25021 open, oversized
records; others unrelated). GitHub pull requests "kafka pause resume": none
open; open draft #26557 (exactly-once) does not change {{KafkaFetchRecords}}.
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)