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 eef3ea98e65d CAMEL-25312: camel-sjms - a suspended consumer must stop 
receiving, and a restarted consumer must receive again (#27388)
eef3ea98e65d is described below

commit eef3ea98e65ddd9b39acf8ed6d5c32aaa74ede0b
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 6 14:52:06 2026 +0530

    CAMEL-25312: camel-sjms - a suspended consumer must stop receiving, and a 
restarted consumer must receive again (#27388)
    
    * CAMEL-25312: camel-sjms - a suspended consumer must stop receiving, and a 
restarted consumer must receive again
    
    SjmsConsumer implements Suspendable but has no doSuspend/doResume, so
    suspending it was a no-op: the listener container kept receiving and routing
    messages while the route was reported as suspended. This affects a graceful
    shutdown (the shutdown strategy suspends a Suspendable consumer and then 
waits
    for the inflight exchanges, while the consumer keeps taking new messages 
until
    the shutdown timeout), suspendRoute (route controller, JMX) and the route
    policies (ThrottlingInflightRoutePolicy, ThrottlingExceptionRoutePolicy, 
...).
    CAMEL-9567 fixed this for the old consumer; the consumer rewritten by
    CAMEL-15995 (3.8) lost it.
    
    SimpleMessageListenerContainer.stopConsumers closes the consumers and 
sessions
    but keeps them in their fields, and initConsumers only creates consumers 
when
    the field is null. A container started again (the same consumer instance
    stopped and started, e.g. with the JMX operations of the consumer) kept the
    closed consumers and never received a message again.
    
    The consumer now suspends and resumes its listener container, which stops 
and
    starts its connection: the delivery stops (after the listeners in progress
    returned), and the consumers and sessions stay open for the exchanges in
    flight (an async consumer sends its reply with the session when the exchange
    is done). The container forgets its consumers and sessions when it closes 
them,
    and on shutdown after closing the connection, as the connection recovery may
    have created new ones in between.
    
    Connection.stop() waits for the message listeners in progress, and a 
message listener must not call it
    (the JMS provider throws an IllegalStateException, which stopConnection() 
ignored). A route policy
    suspends the consumer when an exchange is done: for a synchronous consumer 
on the thread of the message
    listener, so the suspend did nothing, or on a thread the listener waits for 
(route continued with
    threads()), and a graceful shutdown or JMX suspend that waited in 
Connection.stop() held the lock of the
    consumer that the listener's route policy then needed. Both waited until 
Artemis gave up after its
    onMessageCloseTimeout (10 s).
    
    The container now stops and starts its connection on a single thread, in 
the order of the suspend and
    resume calls, and suspend/resume do not wait. The upgrade note is next to 
the other camel-sjms note and
    says which connection factories ignore Connection.stop().
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25312: camel-sjms - suspend and resume only a started listener 
container, and shorter upgrade note
    
    A listener container that is not started yet (autoStartup=false, or 
asyncStartListener=true before the start task
    runs) was suspended and resumed anyway: a resume then marked it started 
without a connection, so it could never be
    started (a regression of the previous commit), and a container started 
after the suspend received messages while
    the consumer was suspended. Suspend and resume now only act on a started 
(or suspended) container, and
    startListenerContainer suspends the container it starts when the consumer 
is suspended.
    
    Review nits: the upgrade note is shorter, the control test that passes on 
main is dropped, and the test-only methods
    say so in their javadoc.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25312: camel-sjms - Give the suspend note its own upgrade-guide 
heading
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/component/sjms/SjmsConsumer.java  |  34 +++
 .../consumer/SimpleMessageListenerContainer.java   |  68 +++++
 .../sjms/consumer/SjmsConsumerRestartTest.java     | 313 +++++++++++++++++++++
 .../sjms/consumer/SjmsConsumerRoutePolicyTest.java | 162 +++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   9 +
 5 files changed, 586 insertions(+)

diff --git 
a/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/SjmsConsumer.java
 
b/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/SjmsConsumer.java
index e567232cfaa7..badfe1f93e86 100644
--- 
a/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/SjmsConsumer.java
+++ 
b/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/SjmsConsumer.java
@@ -21,6 +21,7 @@ import jakarta.jms.Connection;
 import org.apache.camel.Endpoint;
 import org.apache.camel.FailedToCreateConsumerException;
 import org.apache.camel.Processor;
+import org.apache.camel.StatefulService;
 import org.apache.camel.Suspendable;
 import org.apache.camel.support.DefaultConsumer;
 import org.apache.camel.support.service.ServiceHelper;
@@ -104,6 +105,27 @@ public class SjmsConsumer extends DefaultConsumer 
implements Suspendable {
         super.doStop();
     }
 
+    @Override
+    protected void doSuspend() throws Exception {
+        // stop receiving messages while suspended. A listener container that 
is not started yet (autoStartup=false,
+        // or asyncStartListener=true and the start task did not run yet) is 
left alone: startListenerContainer
+        // suspends it when it starts it
+        if (listenerContainer instanceof StatefulService container && 
container.isStarted()) {
+            container.suspend();
+        }
+    }
+
+    @Override
+    protected void doResume() throws Exception {
+        if (listenerContainer instanceof StatefulService container && 
container.isSuspended()) {
+            container.resume();
+        }
+    }
+
+    public MessageListenerContainer getListenerContainer() {
+        return listenerContainer;
+    }
+
     protected void prepareAndStartListenerContainer() {
         
listenerContainer.afterPropertiesConfigured(getEndpoint().getCamelContext());
 
@@ -149,6 +171,18 @@ public class SjmsConsumer extends DefaultConsumer 
implements Suspendable {
         LOG.trace("Starting listener container {} on destination {}", 
listenerContainer, getDestinationName());
         ServiceHelper.startService(listenerContainer);
         LOG.debug("Started listener container {} on destination {}", 
listenerContainer, getDestinationName());
+        // the consumer may have been suspended while its listener container 
was not started (doSuspend left it alone)
+        lock.lock();
+        try {
+            if (isSuspendingOrSuspended() && listenerContainer instanceof 
StatefulService container
+                    && container.isStarted()) {
+                LOG.debug("Suspending listener container {} on destination {}, 
as the consumer is suspended",
+                        listenerContainer, getDestinationName());
+                container.suspend();
+            }
+        } finally {
+            lock.unlock();
+        }
     }
 
     protected void stopAndDestroyListenerContainer() {
diff --git 
a/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/consumer/SimpleMessageListenerContainer.java
 
b/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/consumer/SimpleMessageListenerContainer.java
index e21f401359b8..982e67948741 100644
--- 
a/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/consumer/SimpleMessageListenerContainer.java
+++ 
b/components/camel-sjms/src/main/java/org/apache/camel/component/sjms/consumer/SimpleMessageListenerContainer.java
@@ -19,6 +19,7 @@ package org.apache.camel.component.sjms.consumer;
 import java.time.Duration;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.locks.Lock;
@@ -71,6 +72,10 @@ public class SimpleMessageListenerContainer extends 
ServiceSupport
     private ScheduledExecutorService recoverPool;
     private BackgroundTask recoverTask;
     private Future<?> recoverFuture;
+    // stops and starts the connection when the container is suspended and 
resumed, in that order (guarded by the
+    // lifecycle lock of the container: suspend, resume and stop)
+    private ExecutorService suspendExecutor;
+    private volatile Future<?> suspendTask;
 
     public SimpleMessageListenerContainer(SjmsEndpoint endpoint) {
         this.endpoint = endpoint;
@@ -265,6 +270,12 @@ public class SimpleMessageListenerContainer extends 
ServiceSupport
 
     @Override
     protected void doStop() throws Exception {
+        if (suspendExecutor != null) {
+            // let a pending stop or start of the connection finish first
+            
endpoint.getCamelContext().getExecutorServiceManager().shutdownGraceful(suspendExecutor);
+            suspendExecutor = null;
+            suspendTask = null;
+        }
         stopConnection();
         stopConsumers();
         if (recoverPool != null) {
@@ -281,10 +292,64 @@ public class SimpleMessageListenerContainer extends 
ServiceSupport
         }
     }
 
+    @Override
+    protected void doSuspend() throws Exception {
+        // stop the delivery of messages; the consumers and sessions stay 
open, as the exchanges in flight may still
+        // use them (e.g. to send the reply of an async consumer). 
Connection.stop() waits for the message listeners
+        // in progress, and must not be called by a message listener (the JMS 
provider throws an
+        // IllegalStateException): a route policy suspends the consumer when 
an exchange is done, on the thread of
+        // the message listener or on a thread that the listener waits for, 
and the suspend must not wait for the
+        // listeners while it holds the lock of the consumer. So another 
thread stops the connection
+        submitSuspendTask(this::stopConnection);
+    }
+
+    @Override
+    protected void doResume() throws Exception {
+        // after a pending stop of the connection
+        submitSuspendTask(() -> {
+            try {
+                startConnection();
+            } catch (Exception e) {
+                LOG.warn("Error starting the JMS connection on resume. This 
exception is ignored.", e);
+            }
+        });
+    }
+
+    private void submitSuspendTask(Runnable task) {
+        if (suspendExecutor == null) {
+            suspendExecutor = 
endpoint.getCamelContext().getExecutorServiceManager().newSingleThreadExecutor(this,
+                    "SjmsSuspendResume");
+        }
+        suspendTask = suspendExecutor.submit(task);
+    }
+
+    /**
+     * Whether a stop or start of the connection requested by a suspend or 
resume is still pending. Only for tests.
+     */
+    boolean isSuspendResumePending() {
+        Future<?> task = suspendTask;
+        return task != null && !task.isDone();
+    }
+
+    /**
+     * Whether the connection is started (false as soon as a stop of the 
connection began). Only for tests.
+     */
+    boolean isConnectionStarted() {
+        return connectionStarted;
+    }
+
     @Override
     protected void doShutdown() throws Exception {
         closeConnection(connection);
         this.connection = null;
+        // the connection recovery may have created consumers after 
stopConsumers, they are closed with the connection
+        consumerLock.lock();
+        try {
+            this.consumers = null;
+            this.sessions = null;
+        } finally {
+            consumerLock.unlock();
+        }
     }
 
     protected void initConsumers() throws Exception {
@@ -330,6 +395,9 @@ public class SimpleMessageListenerContainer extends 
ServiceSupport
                         closeSession(session);
                     }
                 }
+                // so that a restart creates new consumers
+                this.consumers = null;
+                this.sessions = null;
             }
         } finally {
             consumerLock.unlock();
diff --git 
a/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/consumer/SjmsConsumerRestartTest.java
 
b/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/consumer/SjmsConsumerRestartTest.java
new file mode 100644
index 000000000000..8fb7f60756f3
--- /dev/null
+++ 
b/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/consumer/SjmsConsumerRestartTest.java
@@ -0,0 +1,313 @@
+/*
+ * 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.sjms.consumer;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import jakarta.jms.Connection;
+import jakarta.jms.ConnectionFactory;
+import jakarta.jms.JMSContext;
+import jakarta.jms.JMSException;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Consumer;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.sjms.SjmsConsumer;
+import org.apache.camel.component.sjms.support.JmsTestSupport;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.support.service.ServiceSupport;
+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;
+
+/**
+ * A suspended sjms route must not consume, and must consume again when 
resumed. The route policies (throttling inflight
+ * / exception, master, scheduled) suspend and resume the consumer, and JMX 
can stop and start the same consumer
+ * instance. The listener container may start after the suspend 
(asyncStartListener, autoStartup=false).
+ */
+class SjmsConsumerRestartTest extends JmsTestSupport {
+
+    private static final String QUEUE = "sjms:queue:SjmsConsumerRestartTest.";
+
+    private final CountDownLatch entered = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+    private final AtomicInteger processed = new AtomicInteger();
+    private final CountDownLatch connectionEntered = new CountDownLatch(1);
+    // holds the next connection created with the gated connection factory
+    private volatile CountDownLatch connectionGate;
+
+    @BindToRegistry("gatedConnectionFactory")
+    private final ConnectionFactory gatedConnectionFactory = new 
ConnectionFactory() {
+        @Override
+        public Connection createConnection() throws JMSException {
+            CountDownLatch gate = connectionGate;
+            connectionGate = null;
+            if (gate != null) {
+                connectionEntered.countDown();
+                try {
+                    assertTrue(gate.await(20, TimeUnit.SECONDS));
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+            return connectionFactory.createConnection();
+        }
+
+        @Override
+        public Connection createConnection(String userName, String password) 
throws JMSException {
+            return connectionFactory.createConnection(userName, password);
+        }
+
+        @Override
+        public JMSContext createContext() {
+            return connectionFactory.createContext();
+        }
+
+        @Override
+        public JMSContext createContext(String userName, String password) {
+            return connectionFactory.createContext(userName, password);
+        }
+
+        @Override
+        public JMSContext createContext(String userName, String password, int 
sessionMode) {
+            return connectionFactory.createContext(userName, password, 
sessionMode);
+        }
+
+        @Override
+        public JMSContext createContext(int sessionMode) {
+            return connectionFactory.createContext(sessionMode);
+        }
+    };
+
+    @Test
+    void testSuspendResumeRoute() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:suspend");
+        mock.expectedBodiesReceived("Hello World");
+        template.sendBody(QUEUE + "suspend", "Hello World");
+        mock.assertIsSatisfied();
+
+        context.getRouteController().suspendRoute("suspend");
+        assertEquals(ServiceStatus.Suspended, 
context.getRouteController().getRouteStatus("suspend"));
+        // the connection is stopped by another thread
+        awaitSuspendResume("suspend");
+
+        mock.reset();
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(1000);
+        template.sendBody(QUEUE + "suspend", "Bye World");
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("Bye World");
+        context.getRouteController().resumeRoute("suspend");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testSuspendResumeConsumer() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:policy");
+
+        // what RoutePolicySupport.suspendOrStopConsumer and 
resumeOrStartConsumer do
+        Consumer consumer = context.getRoute("policy").getConsumer();
+        ServiceHelper.suspendService(consumer);
+        awaitSuspendResume("policy");
+
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(1000);
+        template.sendBody(QUEUE + "policy", "Hello World");
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("Hello World", "Bye World");
+        ServiceHelper.resumeService(consumer);
+        template.sendBody(QUEUE + "policy", "Bye World");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testStopStartConsumer() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:restart");
+
+        // the same consumer instance, as with the stop and start operations 
of the managed consumer (JMX)
+        Consumer consumer = context.getRoute("restart").getConsumer();
+        for (int i = 0; i < 2; i++) {
+            ServiceHelper.stopService(consumer);
+            ServiceHelper.startService(consumer);
+        }
+
+        mock.expectedBodiesReceived("Hello World", "Bye World");
+        template.sendBody(QUEUE + "restart", "Hello World");
+        template.sendBody(QUEUE + "restart", "Bye World");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testGracefulStopTakesNoNewMessage() throws Exception {
+        // the graceful shutdown suspends the consumer, then waits for the 
inflight exchange
+        template.sendBody(QUEUE + "shutdown", "A");
+        assertTrue(entered.await(20, TimeUnit.SECONDS));
+
+        CompletableFuture<Void> stop = CompletableFuture.runAsync(() -> {
+            try {
+                context.getRouteController().stopRoute("shutdown", 20, 
TimeUnit.SECONDS);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        });
+        ServiceSupport consumer = (ServiceSupport) 
context.getRoute("shutdown").getConsumer();
+        SimpleMessageListenerContainer container = container("shutdown");
+        // the stop of the connection begins (and then waits for the listener 
of A)
+        await().atMost(20, TimeUnit.SECONDS)
+                .until(() -> consumer.isSuspendingOrSuspended() && 
!container.isConnectionStarted());
+        template.sendBody(QUEUE + "shutdown", "B");
+        release.countDown();
+        stop.get(30, TimeUnit.SECONDS);
+
+        assertEquals(ServiceStatus.Stopped, 
context.getRouteController().getRouteStatus("shutdown"));
+        assertEquals(1, processed.get(), "A message sent after the route began 
to stop must not be consumed");
+    }
+
+    @Test
+    void testGracefulStopAsyncReply() throws Exception {
+        // with asyncConsumer the message listener returns before the exchange 
is done, and the reply is sent with the
+        // session of the consumer when it is done: suspending the consumer 
must keep that session open
+        CompletableFuture<Object> reply = template.asyncRequestBody(QUEUE + 
"async?requestTimeout=10000", "A");
+        assertTrue(entered.await(20, TimeUnit.SECONDS));
+
+        CompletableFuture<Void> stop = CompletableFuture.runAsync(() -> {
+            try {
+                context.getRouteController().stopRoute("async", 20, 
TimeUnit.SECONDS);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        });
+        ServiceSupport consumer = (ServiceSupport) 
context.getRoute("async").getConsumer();
+        await().atMost(20, 
TimeUnit.SECONDS).until(consumer::isSuspendingOrSuspended);
+        release.countDown();
+
+        assertEquals("Bye A", reply.get(30, TimeUnit.SECONDS));
+        stop.get(30, TimeUnit.SECONDS);
+    }
+
+    @Test
+    void testSuspendedBeforeAsyncStart() throws Exception {
+        // the asynchronous start of the listener container is held while it 
tests the connection
+        CountDownLatch proceed = new CountDownLatch(1);
+        connectionGate = proceed;
+        context.addRoutes(new RouteBuilder() {
+            public void configure() {
+                from(QUEUE + 
"asyncstart?asyncStartListener=true&testConnectionOnStartup=true"
+                     + "&connectionFactory=#gatedConnectionFactory")
+                        .routeId("asyncstart").to("mock:asyncstart");
+            }
+        });
+        assertTrue(connectionEntered.await(20, TimeUnit.SECONDS));
+        context.getRouteController().suspendRoute("asyncstart");
+        proceed.countDown();
+
+        // the container starts after the suspend, and must then be suspended 
too
+        SimpleMessageListenerContainer container = container("asyncstart");
+        await().atMost(20, TimeUnit.SECONDS)
+                .until(() -> container.isSuspended() && 
!container.isSuspendResumePending());
+        MockEndpoint mock = getMockEndpoint("mock:asyncstart");
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(1000);
+        template.sendBody(QUEUE + "asyncstart", "Hello World");
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("Hello World");
+        context.getRouteController().resumeRoute("asyncstart");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testSuspendResumeBeforeListenerStarted() throws Exception {
+        // autoStartup=false: the consumer starts without its listener 
container, which is started later
+        context.getRouteController().suspendRoute("manual");
+        context.getRouteController().resumeRoute("manual");
+        ((SjmsConsumer) 
context.getRoute("manual").getConsumer()).startListenerContainer();
+
+        MockEndpoint mock = getMockEndpoint("mock:manual");
+        mock.expectedBodiesReceived("Hello World");
+        template.sendBody(QUEUE + "manual", "Hello World");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testListenerStartedWhileSuspended() throws Exception {
+        // autoStartup=false, and the listener container is started while the 
route is suspended
+        context.getRouteController().suspendRoute("manualsuspended");
+        ((SjmsConsumer) 
context.getRoute("manualsuspended").getConsumer()).startListenerContainer();
+        awaitSuspendResume("manualsuspended");
+
+        MockEndpoint mock = getMockEndpoint("mock:manualsuspended");
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(1000);
+        template.sendBody(QUEUE + "manualsuspended", "Hello World");
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("Hello World");
+        context.getRouteController().resumeRoute("manualsuspended");
+        mock.assertIsSatisfied();
+    }
+
+    private SimpleMessageListenerContainer container(String routeId) {
+        return (SimpleMessageListenerContainer) ((SjmsConsumer) 
context.getRoute(routeId).getConsumer())
+                .getListenerContainer();
+    }
+
+    private void awaitSuspendResume(String routeId) {
+        SimpleMessageListenerContainer container = container(routeId);
+        await().atMost(20, TimeUnit.SECONDS).until(() -> 
!container.isSuspendResumePending());
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            public void configure() {
+                from(QUEUE + "suspend").routeId("suspend").to("mock:suspend");
+                from(QUEUE + "policy").routeId("policy").to("mock:policy");
+                from(QUEUE + "restart").routeId("restart").to("mock:restart");
+                from(QUEUE + 
"manual?autoStartup=false").routeId("manual").to("mock:manual");
+                from(QUEUE + 
"manualsuspended?autoStartup=false").routeId("manualsuspended").to("mock:manualsuspended");
+                from(QUEUE + "async?asyncConsumer=true").routeId("async")
+                        .threads(1)
+                        .process(e -> {
+                            entered.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .transform(body().prepend("Bye "));
+                from(QUEUE + "shutdown").routeId("shutdown")
+                        .process(e -> {
+                            processed.incrementAndGet();
+                            entered.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        });
+            }
+        };
+    }
+
+}
diff --git 
a/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/consumer/SjmsConsumerRoutePolicyTest.java
 
b/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/consumer/SjmsConsumerRoutePolicyTest.java
new file mode 100644
index 000000000000..b05e678c5470
--- /dev/null
+++ 
b/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/consumer/SjmsConsumerRoutePolicyTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.sjms.consumer;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Consumer;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.sjms.SjmsConsumer;
+import org.apache.camel.component.sjms.support.JmsTestSupport;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.throttling.ThrottlingExceptionRoutePolicy;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A route policy suspends the consumer when an exchange is done, which for a 
synchronous sjms consumer runs on the
+ * thread of the JMS message listener, or on a thread the listener waits for. 
Connection.stop() waits for the message
+ * listeners in progress and a message listener must not call it (the JMS 
provider throws an IllegalStateException), so
+ * the consumer must stop receiving without the suspend waiting for the 
listener.
+ */
+class SjmsConsumerRoutePolicyTest extends JmsTestSupport {
+
+    private static final String QUEUE = 
"sjms:queue:SjmsConsumerRoutePolicyTest";
+
+    private final ThrottlingExceptionRoutePolicy policy = new 
ThrottlingExceptionRoutePolicy(1, 60000, 60000, null, false);
+    private final ThrottlingExceptionRoutePolicy blockingPolicy
+            = new ThrottlingExceptionRoutePolicy(1, 60000, 60000, null, false);
+    private final ThrottlingExceptionRoutePolicy threadsPolicy
+            = new ThrottlingExceptionRoutePolicy(1, 60000, 60000, null, false);
+    private final CountDownLatch entered = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @Test
+    void testCircuitBreakerSuspendsConsumer() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        Consumer consumer = context.getRoute("breaker").getConsumer();
+        SimpleMessageListenerContainer container
+                = (SimpleMessageListenerContainer) ((SjmsConsumer) 
consumer).getListenerContainer();
+
+        // the failure opens the circuit on the thread of the message listener
+        template.sendBody(QUEUE, "Kaboom");
+        await().atMost(20, TimeUnit.SECONDS)
+                .until(() -> ((ServiceSupport) consumer).isSuspended() && 
!container.isSuspendResumePending());
+
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(1000);
+        template.sendBody(QUEUE, "Hello World");
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("Hello World");
+        ServiceHelper.resumeService(consumer);
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testSuspendWhileThePolicySuspends() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:blocking");
+        Consumer consumer = context.getRoute("blocking").getConsumer();
+        SimpleMessageListenerContainer container
+                = (SimpleMessageListenerContainer) ((SjmsConsumer) 
consumer).getListenerContainer();
+
+        template.sendBody(QUEUE + "Blocking", "Kaboom");
+        assertTrue(entered.await(20, TimeUnit.SECONDS));
+
+        // the consumer is suspended (graceful shutdown, JMX) while the 
exchange is in flight, then the exchange fails
+        // and the policy opens the circuit, which suspends the consumer on 
the thread of the message listener
+        CompletableFuture<Boolean> suspend = CompletableFuture.supplyAsync(() 
-> ServiceHelper.suspendService(consumer));
+        await().atMost(20, TimeUnit.SECONDS).until(((ServiceSupport) 
consumer)::isSuspendingOrSuspended);
+        release.countDown();
+        // must not wait for the message listener, which waits for the lock of 
the consumer
+        assertTrue(suspend.get(5, TimeUnit.SECONDS));
+        await().atMost(20, TimeUnit.SECONDS).until(() -> 
!container.isSuspendResumePending());
+
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(1000);
+        template.sendBody(QUEUE + "Blocking", "Hello World");
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("Hello World");
+        ServiceHelper.resumeService(consumer);
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testCircuitBreakerOnAnotherThread() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:threads");
+        Consumer consumer = context.getRoute("threads").getConsumer();
+        SimpleMessageListenerContainer container
+                = (SimpleMessageListenerContainer) ((SjmsConsumer) 
consumer).getListenerContainer();
+
+        // the route continues on another thread, which opens the circuit 
while the message listener waits for it
+        template.sendBody(QUEUE + "Threads", "Kaboom");
+        await().atMost(5, TimeUnit.SECONDS)
+                .until(() -> ((ServiceSupport) consumer).isSuspended() && 
!container.isSuspendResumePending());
+
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(1000);
+        template.sendBody(QUEUE + "Threads", "Hello World");
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("Hello World");
+        ServiceHelper.resumeService(consumer);
+        mock.assertIsSatisfied();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            public void configure() {
+                from(QUEUE).routeId("breaker").routePolicy(policy)
+                        .process(e -> {
+                            if 
("Kaboom".equals(e.getMessage().getBody(String.class))) {
+                                throw new IllegalArgumentException("Forced");
+                            }
+                        })
+                        .to("mock:result");
+                from(QUEUE + 
"Blocking").routeId("blocking").routePolicy(blockingPolicy)
+                        .process(e -> {
+                            if 
("Kaboom".equals(e.getMessage().getBody(String.class))) {
+                                entered.countDown();
+                                release.await(20, TimeUnit.SECONDS);
+                                throw new IllegalArgumentException("Forced");
+                            }
+                        })
+                        .to("mock:blocking");
+                from(QUEUE + 
"Threads").routeId("threads").routePolicy(threadsPolicy)
+                        .threads(1)
+                        .process(e -> {
+                            if 
("Kaboom".equals(e.getMessage().getBody(String.class))) {
+                                throw new IllegalArgumentException("Forced");
+                            }
+                        })
+                        .to("mock:threads");
+            }
+        };
+    }
+
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 03ddbbe59e7c..dce46b5c5d5b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -1491,6 +1491,15 @@ timeout or the reply, and the send failure is logged at 
WARN level.
 which `ReplyManagerSupport` implements. A custom `ReplyManager` implementation 
that does not override it keeps the
 previous behaviour.
 
+=== camel-sjms - suspending a route stops the consumer
+
+A suspended sjms (or sjms2) consumer used to keep receiving and routing 
messages, also during a graceful shutdown and
+under route policies such as `ThrottlingInflightRoutePolicy`. It now stops the 
JMS connection of its listener container
+shortly after the suspend (the connection is stopped by another thread), and 
starts it again on resume. Messages that
+the JMS client has already prefetched are delivered after the resume. A 
connection factory that shares one connection
+and does not stop it on `Connection.stop()` (such as the pooled-jms 
`JmsPoolConnectionFactory`) keeps delivering to a
+suspended consumer, as before.
+
 === camel-tika
 
 The Tika dependency has been upgraded from 3.x to 4.x. Tika 4 removed the 
`TikaConfig` class and XML

Reply via email to