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 0dc6d2087de9 CAMEL-25837: camel-hazelcast - the queue consumer in poll 
mode stops without losing items (#28258)
0dc6d2087de9 is described below

commit 0dc6d2087de92dff446aa3877687df3269837379
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Oct 23 02:19:07 2026 +0530

    CAMEL-25837: camel-hazelcast - the queue consumer in poll mode stops 
without losing items (#28258)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../hazelcast/queue/HazelcastQueueConsumer.java    |  29 +++-
 .../HazelcastQueueConsumerPollStopTest.java        | 176 +++++++++++++++++++++
 2 files changed, 199 insertions(+), 6 deletions(-)

diff --git 
a/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/queue/HazelcastQueueConsumer.java
 
b/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/queue/HazelcastQueueConsumer.java
index 93e790843885..5c2fa3a84ad2 100644
--- 
a/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/queue/HazelcastQueueConsumer.java
+++ 
b/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/queue/HazelcastQueueConsumer.java
@@ -33,6 +33,9 @@ public class HazelcastQueueConsumer extends 
HazelcastDefaultConsumer {
 
     // the minimum delay before polling again after a poll error, so a 
pollingTimeout of 0 does not spin
     private static final long MIN_POLL_ERROR_DELAY = 1000L;
+    // the longest a single IQueue.poll waits (or pollingTimeout when it is 
shorter): the consumer stops by letting its
+    // poll end, not by interrupting it, so the thread sees a stop within this 
time
+    private static final long MAX_POLL_WAIT = 1000L;
 
     private final Processor processor;
     private ExecutorService executor;
@@ -71,19 +74,23 @@ public class HazelcastQueueConsumer extends 
HazelcastDefaultConsumer {
             listener = null;
         }
 
-        super.doStop();
-
         if (stopLatch != null) {
             stopLatch.countDown();
         }
         if (executor != null) {
+            // do not interrupt the polling thread: a poll removes the item 
from the queue, so an interrupted exchange
+            // loses its item, and an interrupted poll keeps waiting on the 
member and takes the next item offered
+            // without anybody routing it. The thread ends after its current 
poll (at most MAX_POLL_WAIT) and its
+            // current exchange, which is why the processor is only stopped 
afterwards
             if (getEndpoint() != null && getEndpoint().getCamelContext() != 
null) {
-                
getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(executor);
+                
getEndpoint().getCamelContext().getExecutorServiceManager().shutdownGraceful(executor);
             } else {
-                executor.shutdownNow();
+                executor.shutdown();
             }
         }
         executor = null;
+
+        super.doStop();
     }
 
     class QueueConsumerTask implements Runnable {
@@ -101,9 +108,9 @@ public class HazelcastQueueConsumer extends 
HazelcastDefaultConsumer {
             while (isRunAllowed()) {
                 final Object body;
                 try {
-                    body = queue.poll(config.getPollingTimeout(), 
TimeUnit.MILLISECONDS);
+                    body = pollItem();
                 } catch (InterruptedException e) {
-                    // only doStop interrupts this thread
+                    // only a forced shutdown interrupts this thread
                     Thread.currentThread().interrupt();
                     return;
                 } catch (Exception e) {
@@ -131,6 +138,16 @@ public class HazelcastQueueConsumer extends 
HazelcastDefaultConsumer {
             }
         }
 
+        /**
+         * Polls for an item, waiting at most pollingTimeout and at most 
MAX_POLL_WAIT, so the thread sees a stop within
+         * that time. When no item was offered, the loop in {@link #run()} 
polls again.
+         *
+         * @return the item, or null if none was offered in that time
+         */
+        private Object pollItem() throws InterruptedException {
+            return queue.poll(Math.min(config.getPollingTimeout(), 
MAX_POLL_WAIT), TimeUnit.MILLISECONDS);
+        }
+
         /**
          * Waits before the next poll after a poll error, and ends early when 
the consumer stops.
          *
diff --git 
a/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/HazelcastQueueConsumerPollStopTest.java
 
b/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/HazelcastQueueConsumerPollStopTest.java
new file mode 100644
index 000000000000..e8db7f3b2ba1
--- /dev/null
+++ 
b/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/HazelcastQueueConsumerPollStopTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.hazelcast;
+
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import com.hazelcast.config.Config;
+import com.hazelcast.core.Hazelcast;
+import com.hazelcast.core.HazelcastInstance;
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Stopping a route that consumes a Hazelcast queue in poll mode. A poll 
removes the item from the queue, so the
+ * consumer must neither interrupt the exchange it is routing nor leave a poll 
waiting on the member that takes an item
+ * nobody routes.
+ */
+class HazelcastQueueConsumerPollStopTest extends CamelTestSupport {
+
+    private final CountDownLatch routing = new CountDownLatch(1);
+    private HazelcastInstance member;
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        member = 
Hazelcast.newHazelcastInstance(memberConfig("queue-poll-stop-" + 
UUID.randomUUID()));
+        CamelContext context = super.createCamelContext();
+        context.getRegistry().bind("hazelcastInstance", member);
+        return context;
+    }
+
+    private static Config memberConfig(String clusterName) {
+        Config config = new Config();
+        config.setClusterName(clusterName);
+        config.getMetricsConfig().setEnabled(false);
+        
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
+        
config.getNetworkConfig().getJoin().getAutoDetectionConfig().setEnabled(false);
+        config.getNetworkConfig().getJoin().getTcpIpConfig().setEnabled(false);
+        return config;
+    }
+
+    @AfterEach
+    void shutdownMember() {
+        // stop the routes before the member, so the consumers do not poll a 
member that is shut down
+        context.stop();
+        if (member != null) {
+            member.shutdown();
+        }
+    }
+
+    @Test
+    void exchangeInFlightCompletesWhenTheRouteStops() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:slow");
+        mock.expectedBodiesReceived("a");
+
+        member.getQueue("slow").offer("a");
+        assertTrue(routing.await(10, TimeUnit.SECONDS), "The item was not 
polled");
+
+        // graceful stop while the exchange is being routed: the item is no 
longer in the queue, so the exchange
+        // must be allowed to complete
+        context.getRouteController().stopRoute("slow");
+
+        mock.assertIsSatisfied();
+        assertEquals(0, member.getQueue("slow").size());
+    }
+
+    @Test
+    void itemOfferedAfterTheRouteStoppedStaysInTheQueue() throws Exception {
+        // stop the route while the consumer waits in its poll (the usual 
state of an idle consumer)
+        await().atMost(10, TimeUnit.SECONDS).until(this::consumersWaitInPoll);
+        context.getRouteController().stopRoute("idle");
+
+        member.getQueue("idle").offer("b");
+
+        // nothing consumes the queue any more: the item must stay there (for 
the next start or another consumer)
+        await().during(4, TimeUnit.SECONDS).atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertEquals(1, 
member.getQueue("idle").size(),
+                        "The item offered after the route stopped was taken 
from the queue"));
+        assertEquals(0, getMockEndpoint("mock:idle").getReceivedCounter());
+
+        // and the next start routes it
+        MockEndpoint mock = getMockEndpoint("mock:idle");
+        mock.expectedBodiesReceived("b");
+        context.getRouteController().startRoute("idle");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void longPollingTimeoutWaitsForAnItemAndStillStopsPromptly() throws 
Exception {
+        await().atMost(10, TimeUnit.SECONDS).until(this::consumersWaitInPoll);
+
+        // an item offered after several seconds of waiting, within the 
pollingTimeout of a minute, is routed
+        MockEndpoint mock = getMockEndpoint("mock:long");
+        await().during(3, TimeUnit.SECONDS).atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertEquals(0, 
mock.getReceivedCounter()));
+        mock.expectedBodiesReceived("c");
+        member.getQueue("long").offer("c");
+        mock.assertIsSatisfied();
+
+        // the consumer waits for its pollingTimeout of a minute, but a stop 
does not wait for it (nor for the graceful
+        // shutdown timeout), and leaves the item offered afterwards in the 
queue
+        await().atMost(10, TimeUnit.SECONDS).until(this::consumersWaitInPoll);
+        long start = System.nanoTime();
+        context.getRouteController().stopRoute("long");
+        long stopMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
start);
+        assertTrue(stopMillis < 5000, "Stopping the route took " + stopMillis 
+ " ms");
+
+        member.getQueue("long").offer("d");
+        await().during(3, TimeUnit.SECONDS).atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertEquals(1, 
member.getQueue("long").size(),
+                        "The item offered after the route stopped was taken 
from the queue"));
+        assertEquals(1, mock.getReceivedCounter());
+    }
+
+    private boolean consumersWaitInPoll() {
+        // the consumer threads of the routes of this context that are 
started, each waiting in IQueue.poll (Camel
+        // thread names are "Camel (<context>) thread #N - QueueConsumer")
+        String contextName = "(" + context.getName() + ")";
+        return Thread.getAllStackTraces().entrySet().stream()
+                .filter(e -> e.getKey().getName().contains(contextName)
+                        && e.getKey().getName().contains("QueueConsumer"))
+                .filter(e -> e.getKey().getState() != Thread.State.RUNNABLE
+                        && Arrays.stream(e.getValue()).anyMatch(f -> 
"poll".equals(f.getMethodName())))
+                .count()
+               == 3;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("hazelcast-queue:slow?queueConsumerMode=Poll&pollingTimeout=3000&hazelcastInstance=#hazelcastInstance")
+                        .routeId("slow")
+                        .process(exchange -> {
+                            routing.countDown();
+                            // any blocking call that ends when its thread is 
interrupted
+                            new CountDownLatch(1).await(1, TimeUnit.SECONDS);
+                        })
+                        .to("mock:slow");
+
+                
from("hazelcast-queue:idle?queueConsumerMode=Poll&pollingTimeout=3000&hazelcastInstance=#hazelcastInstance")
+                        .routeId("idle")
+                        .to("mock:idle");
+
+                
from("hazelcast-queue:long?queueConsumerMode=Poll&pollingTimeout=60000&hazelcastInstance=#hazelcastInstance")
+                        .routeId("long")
+                        .to("mock:long");
+            }
+        };
+    }
+}

Reply via email to