This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 184450a65483 CAMEL-25753: camel-kafka - stop the consumer without
waiting for its current poll (#28060)
184450a65483 is described below
commit 184450a65483f98764a84c9afbc81e4d69ad98ae
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 18 18:03:03 2026 +0200
CAMEL-25753: camel-kafka - stop the consumer without waiting for its
current poll (#28060)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../camel/component/kafka/KafkaFetchRecords.java | 87 ++++++---
.../component/kafka/KafkaConsumerStopTest.java | 197 +++++++++++++++++++++
2 files changed, 255 insertions(+), 29 deletions(-)
diff --git
a/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
b/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
index 8994c2d80641..23f09a65f9da 100644
---
a/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
+++
b/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
@@ -90,6 +90,8 @@ public class KafkaFetchRecords implements Runnable {
private PollExceptionStrategy pollExceptionStrategy;
private final BridgeExceptionHandlerToErrorHandler bridge;
private final ReentrantLock lock = new ReentrantLock();
+ // whether the fetcher thread is in poll, which stop() may then wake up
(CAMEL-25753)
+ private volatile boolean polling;
private CommitManager commitManager;
private volatile Exception lastError;
private final KafkaConsumerListener consumerListener;
@@ -317,12 +319,6 @@ public class KafkaFetchRecords implements Runnable {
protected void startPolling() {
try {
- /*
- * We lock the processing of the record to avoid raising a
WakeUpException as a result to a call
- * to stop() or shutdown().
- */
- lock.lock();
-
long pollTimeoutMs =
kafkaConsumer.getEndpoint().getConfiguration().getPollTimeoutMs();
Duration pollDuration = Duration.ofMillis(pollTimeoutMs);
@@ -338,27 +334,51 @@ public class KafkaFetchRecords implements Runnable {
// which must happen using this polling thread, so we use the
commitRecordsRequested to trigger this
metricsCollector.collectCommitMetrics(consumer);
- ConsumerRecords<Object, Object> allRecords =
consumer.poll(pollDuration);
- if (consumerListener != null) {
- if (!consumerListener.afterConsume(consumer)) {
- continue;
+ /*
+ * The poll is not under the lock: stop() wakes up a consumer
that is waiting in poll, which then
+ * throws a WakeupException, so the consumer stops at once
instead of after the poll timeout
+ * (CAMEL-25753). The stop is checked again once polling is
set, so a stop that began just before is
+ * seen here, or stop() sees polling and wakes up the poll.
+ */
+ ConsumerRecords<Object, Object> allRecords;
+ polling = true;
+ try {
+ if (!isKafkaConsumerRunnableAndNotStopped()) {
+ break;
}
+ allRecords = consumer.poll(pollDuration);
+ } finally {
+ polling = false;
}
- ProcessingResult result =
recordProcessorFacade.processPolledRecords(allRecords);
- if (result != null && result.getTopic() != null) {
- metricsCollector.storeLastRecord(result);
- }
- updateTaskState();
-
- // when breakOnFirstError we want to unsubscribe from Kafka
- if (result != null && result.isBreakOnErrorHit() &&
!this.state.get().equals(State.PAUSED)) {
- LOG.debug("We hit an error ... setting flags to force
reconnect");
- // force re-connect
- setReconnect(true);
- setConnected(false);
- }
+ /*
+ * The processing of the polled records is under the lock:
stop() waits for it to finish, including
+ * the commit, before it wakes up the consumer, so a
WakeupException does not interrupt it.
+ */
+ lock.lock();
+ try {
+ if (consumerListener != null) {
+ if (!consumerListener.afterConsume(consumer)) {
+ continue;
+ }
+ }
+ ProcessingResult result =
recordProcessorFacade.processPolledRecords(allRecords);
+ if (result != null && result.getTopic() != null) {
+ metricsCollector.storeLastRecord(result);
+ }
+ updateTaskState();
+
+ // when breakOnFirstError we want to unsubscribe from Kafka
+ if (result != null && result.isBreakOnErrorHit() &&
!this.state.get().equals(State.PAUSED)) {
+ LOG.debug("We hit an error ... setting flags to force
reconnect");
+ // force re-connect
+ setReconnect(true);
+ setConnected(false);
+ }
+ } finally {
+ lock.unlock();
+ }
}
if (!isConnected()) {
@@ -400,7 +420,6 @@ public class KafkaFetchRecords implements Runnable {
safeUnsubscribe();
safeConsumerClose();
}
- lock.unlock();
}
}
@@ -470,6 +489,9 @@ public class KafkaFetchRecords implements Runnable {
} catch (IllegalStateException e) {
LOG.warn("The consumer is likely already closed. Skipping
unsubscribing thread {} from kafka {}", threadId,
printableTopic);
+ } catch (WakeupException e) {
+ // stop() woke up the consumer after the last poll: the consumer
is closed next, which leaves the group
+ LOG.debug("The kafka consumer was woken up while unsubscribing
thread {} from {}", threadId, printableTopic);
} catch (Exception e) {
LOG.debug("Something went wrong while unsubscribing from Kafka:
{}", e.getMessage());
kafkaConsumer.getExceptionHandler().handleException(
@@ -500,7 +522,8 @@ public class KafkaFetchRecords implements Runnable {
/*
* This wraps a safe stop procedure that should help ensure a clean
termination procedure for consumer code.
* This means that it should wait for the last process call to finish
cleanly, including the commit of the
- * record being processed at the current moment.
+ * record being processed at the current moment. The lock is held only
while records are processed: when the
+ * consumer is waiting in poll, the lock is free and the wakeup ends the
poll at once (CAMEL-25753).
*
* Note: keep in mind that the KafkaConsumer is not thread-safe, so no
other call to the consumer instance
* should be made here besides the wakeUp.
@@ -517,13 +540,19 @@ public class KafkaFetchRecords implements Runnable {
of whether the processing have finished or not.
*/
LOG.info("Waiting up to {} milliseconds for the processing to
finish", timeout);
- if (!lock.tryLock(timeout, TimeUnit.MILLISECONDS)) {
+ boolean locked = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
+ if (!locked) {
LOG.warn("The processing of the current record did not finish
within {} seconds", timeout);
}
- // As advised in the KAFKA-1894 ticket, calling this wakeup method
breaks the infinite loop
- LOG.trace("Waking up Kafka consumer");
- consumer.wakeup();
+ // As advised in the KAFKA-1894 ticket, calling this wakeup method
breaks the infinite loop. Only a
+ // consumer in poll is woken up: a consumer between two polls sees
the stop before it polls again, and a
+ // wakeup would stay pending and interrupt the processing of the
records it already has. A processing
+ // that did not finish in time is woken up regardless, as before.
+ if (polling || !locked) {
+ LOG.trace("Waking up Kafka consumer");
+ consumer.wakeup();
+ }
} catch (InterruptedException e) {
LOG.trace("Interrupted while waiting for processing to finish:
waking up Kafka consumer");
consumer.wakeup();
diff --git
a/components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerStopTest.java
b/components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerStopTest.java
new file mode 100644
index 000000000000..8638b90ff417
--- /dev/null
+++
b/components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerStopTest.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.kafka;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.kafka.clients.consumer.Consumer;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.ConsumerRecords;
+import org.apache.kafka.clients.consumer.MockConsumer;
+import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.common.TopicPartition;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Stopping a route with a Kafka consumer (CAMEL-25753): a consumer waiting in
poll is woken up at once instead of after
+ * the poll timeout, and a consumer processing records finishes them first. A
{@link MockConsumer} instead of a broker,
+ * whose poll waits like a real one.
+ */
+class KafkaConsumerStopTest extends CamelTestSupport {
+
+ private static final String TOPIC = "stop";
+ private static final TopicPartition PARTITION = new TopicPartition(TOPIC,
0);
+
+ private final BlockingMockConsumer mockConsumer = new
BlockingMockConsumer();
+ private final CountDownLatch processing = new CountDownLatch(1);
+
+ @BindToRegistry("mockFactory")
+ private final KafkaClientFactory factory = new KafkaClientFactory() {
+ @Override
+ public Producer getProducer(Properties kafkaProps) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Consumer getConsumer(Properties kafkaProps) {
+ return mockConsumer;
+ }
+
+ @Override
+ public String getBrokers(KafkaConfiguration configuration) {
+ return "localhost:9092";
+ }
+ };
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ @Test
+ void testStopWhileWaitingInPoll() throws Exception {
+ startRoute(0);
+ // the fetcher thread is in a poll that waits up to 10 seconds
+ await().atMost(20, TimeUnit.SECONDS).until(mockConsumer::isWaiting);
+
+ long start = System.nanoTime();
+ context.getRouteController().stopRoute("kafka");
+ long millis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+
+ assertTrue(millis < 3000, "The route stopped after " + millis + " ms,
it should not wait for the 10 s poll timeout");
+ }
+
+ @Test
+ void testStopWhileProcessing() throws Exception {
+ startRoute(1000);
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("a");
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
mockConsumer.assignment().contains(PARTITION));
+ mockConsumer.addRecord(new ConsumerRecord<>(TOPIC, 0, 0, null, "a"));
+ assertTrue(processing.await(20, TimeUnit.SECONDS));
+
+ // the record takes a second: the stop waits for it, and the wakeup
does not interrupt it
+ long start = System.nanoTime();
+ context.getRouteController().stopRoute("kafka");
+ long millis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+
+ mock.assertIsSatisfied();
+ assertTrue(millis >= 500, "The route stopped after " + millis + " ms,
before the record was processed");
+ }
+
+ private void startRoute(long processingMillis) throws Exception {
+ mockConsumer.updateBeginningOffsets(Map.of(PARTITION, 0L));
+ mockConsumer.schedulePollTask(() ->
mockConsumer.rebalance(List.of(PARTITION)));
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("kafka:" + TOPIC
+ +
"?brokers=localhost:9092&kafkaClientFactory=#mockFactory&groupId=test&pollTimeoutMs=10000")
+ .routeId("kafka")
+ .process(e -> {
+ processing.countDown();
+ Thread.sleep(processingMillis);
+ })
+ .to("mock:result");
+ }
+ });
+ context.start();
+ }
+
+ /**
+ * A poll that waits up to its timeout for records, as a real consumer on
an idle topic, unless it is woken up. The
+ * wait is outside the monitor of {@link MockConsumer}, whose poll and
wakeup are synchronized.
+ */
+ private static final class BlockingMockConsumer extends
MockConsumer<Object, Object> {
+ private final Object signal = new Object();
+ private boolean wokenUp;
+ private boolean waiting;
+
+ BlockingMockConsumer() {
+ super("earliest");
+ }
+
+ @Override
+ public ConsumerRecords<Object, Object> poll(Duration timeout) {
+ long deadline = System.nanoTime() + timeout.toNanos();
+ while (true) {
+ // throws a WakeupException when woken up
+ ConsumerRecords<Object, Object> records =
super.poll(Duration.ZERO);
+ if (!records.isEmpty()) {
+ return records;
+ }
+ synchronized (signal) {
+ long left = deadline - System.nanoTime();
+ if (left <= 0) {
+ return records;
+ }
+ if (!wokenUp) {
+ waiting = true;
+ try {
+ signal.wait(Math.max(1,
TimeUnit.NANOSECONDS.toMillis(left)));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return records;
+ } finally {
+ waiting = false;
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void addRecord(ConsumerRecord<Object, Object> record) {
+ super.addRecord(record);
+ synchronized (signal) {
+ signal.notifyAll();
+ }
+ }
+
+ boolean isWaiting() {
+ synchronized (signal) {
+ return waiting;
+ }
+ }
+
+ @Override
+ public void wakeup() {
+ super.wakeup();
+ synchronized (signal) {
+ wokenUp = true;
+ signal.notifyAll();
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ // keep the mock usable: a stopped route closes the consumer
+ }
+ }
+}