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 c8eb7a83cae9 CAMEL-26643: camel-sjms - a consumer stopped and started
again with asyncStopListener=true must keep its listener container running
(#29373)
c8eb7a83cae9 is described below
commit c8eb7a83cae967b52088f935eebff71d06f5c15d
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Nov 7 15:05:30 2026 +0530
CAMEL-26643: camel-sjms - a consumer stopped and started again with
asyncStopListener=true must keep its listener container running (#29373)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/component/sjms/SjmsConsumer.java | 63 ++-
.../SjmsAsyncStartStopListenerRestartTest.java | 424 +++++++++++++++++++++
2 files changed, 484 insertions(+), 3 deletions(-)
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 badfe1f93e86..419339348a64 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
@@ -16,6 +16,9 @@
*/
package org.apache.camel.component.sjms;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
import jakarta.jms.Connection;
import org.apache.camel.Endpoint;
@@ -35,6 +38,10 @@ public class SjmsConsumer extends DefaultConsumer implements
Suspendable {
private static final Logger LOG =
LoggerFactory.getLogger(SjmsConsumer.class);
private volatile boolean initialized;
+ // serializes the start and the stop of the listener container by doStart
and the asynchronous start and stop tasks.
+ // A synchronous start (doStart, under the lock of the consumer) may
therefore wait for an asynchronous stop task
+ // that is still in a slow container.stop()
+ private final Lock listenerContainerLock = new ReentrantLock();
private final MessageListenerContainer listenerContainer;
public SjmsConsumer(Endpoint endpoint, Processor processor,
MessageListenerContainer listenerContainer) {
@@ -56,7 +63,7 @@ public class SjmsConsumer extends DefaultConsumer implements
Suspendable {
@Override
public void run() {
try {
- prepareAndStartListenerContainer();
+ startListenerContainerAsync();
} catch (Exception e) {
LOG.warn("Error starting listener container on
destination: {}. This exception will be ignored.",
getDestinationName(), e);
@@ -69,7 +76,13 @@ public class SjmsConsumer extends DefaultConsumer implements
Suspendable {
}
});
} else {
- prepareAndStartListenerContainer();
+ // after an asynchronous stop task that is still stopping the
listener container
+ listenerContainerLock.lock();
+ try {
+ prepareAndStartListenerContainer();
+ } finally {
+ listenerContainerLock.unlock();
+ }
}
// mark as initialized for the first time
@@ -85,7 +98,7 @@ public class SjmsConsumer extends DefaultConsumer implements
Suspendable {
@Override
public void run() {
try {
- stopAndDestroyListenerContainer();
+ stopListenerContainerAsync();
} catch (Exception e) {
LOG.warn("Error stopping listener container on
destination: {}. This exception will be ignored.",
getDestinationName(), e);
@@ -105,6 +118,50 @@ public class SjmsConsumer extends DefaultConsumer
implements Suspendable {
super.doStop();
}
+ /**
+ * Run by the asynchronous start task, which may run after this consumer
was stopped (and started again).
+ */
+ private void startListenerContainerAsync() {
+ // startListenerContainer takes the lock of the consumer while this
holds listenerContainerLock. doStart takes
+ // them in the other order, but only without asyncStartListener, so
never next to this task
+ listenerContainerLock.lock();
+ try {
+ if (!isRunAllowed()) {
+ LOG.debug("Consumer stopped before its listener container on
destination: {} was started. Not starting it.",
+ getDestinationName());
+ return;
+ }
+ prepareAndStartListenerContainer();
+ if (!isRunAllowed()) {
+ // stopped while the listener container was starting (the stop
found nothing to stop yet)
+ LOG.debug("Consumer stopped while its listener container on
destination: {} was starting. Stopping it.",
+ getDestinationName());
+ stopAndDestroyListenerContainer();
+ }
+ } finally {
+ listenerContainerLock.unlock();
+ }
+ }
+
+ /**
+ * Run by the asynchronous stop task, which may run after this consumer
was started again.
+ */
+ private void stopListenerContainerAsync() {
+ // not under the lock of the consumer, so that a route stop does not
wait for this task
+ listenerContainerLock.lock();
+ try {
+ if (isRunAllowed()) {
+ LOG.debug("Consumer started again before its listener
container on destination: {} was stopped."
+ + " Keeping it.",
+ getDestinationName());
+ return;
+ }
+ stopAndDestroyListenerContainer();
+ } finally {
+ listenerContainerLock.unlock();
+ }
+ }
+
@Override
protected void doSuspend() throws Exception {
// stop receiving messages while suspended. A listener container that
is not started yet (autoStartup=false,
diff --git
a/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/SjmsAsyncStartStopListenerRestartTest.java
b/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/SjmsAsyncStartStopListenerRestartTest.java
new file mode 100644
index 000000000000..6eebca136bbe
--- /dev/null
+++
b/components/camel-sjms/src/test/java/org/apache/camel/component/sjms/SjmsAsyncStartStopListenerRestartTest.java
@@ -0,0 +1,424 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.StatefulService;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.sjms.consumer.SimpleMessageListenerContainer;
+import org.apache.camel.component.sjms.support.JmsTestSupport;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Stopping and starting a consumer (as route policies and JMX do) while the
asynchronous stop or start of its listener
+ * container is still pending must leave a started consumer consuming, and a
stopped consumer not consuming.
+ * <p/>
+ * The tasks the consumers submit to the component's async start/stop thread
pool are held, and the test runs them
+ * itself in a chosen order (or on a thread of their own, with the stop of the
listener container held), so the
+ * interleaving is deterministic.
+ */
+@Timeout(120)
+public class SjmsAsyncStartStopListenerRestartTest extends JmsTestSupport {
+
+ private static final String QUEUE =
"SjmsAsyncStartStopListenerRestartTest.";
+ private static final String STOP = "?asyncStopListener=true";
+ private static final String START = "?asyncStartListener=true";
+ private static final String BOTH =
"?asyncStartListener=true&asyncStopListener=true";
+
+ private final HeldExecutorService held = new HeldExecutorService();
+ // run (once) when a listener container is configured, just before it is
started
+ private volatile Runnable configureHook;
+ // when set, the next stop of a listener container counts down stopEntered
and then waits for it
+ private volatile CountDownLatch stopGate;
+ private final CountDownLatch stopEntered = new CountDownLatch(1);
+
+ public SjmsAsyncStartStopListenerRestartTest() {
+ addSjmsComponent = false;
+ }
+
+ @Test
+ public void testRestartWhileAsyncStopIsPending() throws Exception {
+ String id = "restartStopPending";
+ context.getRouteController().startRoute(id);
+ assertConsumes(id, "Hello World");
+
+ consumer(id).stop();
+ consumer(id).start();
+ assertEquals(1, held.pending(), "the asynchronous stop of the listener
container is pending");
+
+ // the stop task of the first run runs after the consumer was started
again
+ held.runPending();
+
+ assertConsumes(id, "Bye World");
+ assertListening(id);
+ }
+
+ @Test
+ public void testRestartWhileAsyncStopAndStartArePendingStartFirst() throws
Exception {
+ String id = "restartBothPendingStartFirst";
+ startRouteWithAsyncStart(id);
+
+ consumer(id).stop();
+ consumer(id).start();
+ assertEquals(2, held.pending(), "the stop task and the start task are
pending");
+
+ held.runPendingInReverse();
+
+ assertConsumes(id, "Bye World");
+ assertListening(id);
+ }
+
+ @Test
+ public void testRestartWhileAsyncStopAndStartArePendingStopFirst() throws
Exception {
+ // control: on main the start task starts the container again after
the stop task shut it down
+ String id = "restartBothPendingStopFirst";
+ startRouteWithAsyncStart(id);
+
+ consumer(id).stop();
+ consumer(id).start();
+ assertEquals(2, held.pending(), "the stop task and the start task are
pending");
+
+ held.runPending();
+
+ assertConsumes(id, "Bye World");
+ assertListening(id);
+ }
+
+ @Test
+ public void testStopWhileAsyncStartIsPending() throws Exception {
+ String id = "stopStartPending";
+ startRouteWithAsyncStart(id);
+
+ consumer(id).stop();
+ consumer(id).start();
+ consumer(id).stop();
+ assertEquals(1, held.pending(), "the start task of the second start is
pending");
+
+ // the start task runs after the consumer was stopped
+ held.runPending();
+
+ assertTrue(consumer(id).isStopped(), "the consumer was stopped");
+ assertFalse(container(id).isStarted(), "the stopped consumer must not
keep its listener container started");
+ }
+
+ @Test
+ public void testStopWhileAsyncStartIsStartingTheListenerContainer() throws
Exception {
+ String id = "stopStartRunning";
+ startRouteWithAsyncStart(id);
+
+ consumer(id).stop();
+ consumer(id).start();
+ // the consumer is stopped after the start task checked that the
consumer runs, and before it starts the
+ // listener container
+ configureHook = () -> consumer(id).stop();
+ held.runPending();
+
+ assertTrue(consumer(id).isStopped(), "the consumer was stopped");
+ assertFalse(container(id).isStarted(), "the stopped consumer must not
keep its listener container started");
+ }
+
+ @Test
+ public void testStartWhileAsyncStopIsStoppingTheListenerContainer() throws
Exception {
+ String id = "startStopRunning";
+ context.getRouteController().startRoute(id);
+ assertConsumes(id, "Hello World");
+
+ CountDownLatch gate = new CountDownLatch(1);
+ stopGate = gate;
+ held.hold = false;
+ held.ownThread = true;
+ Thread starter = new Thread(() -> consumer(id).start(), "starter");
+ try {
+ consumer(id).stop();
+ assertTrue(stopEntered.await(20, TimeUnit.SECONDS), "the stop task
stopped the listener container");
+
+ // the consumer is started again after the stop task stopped the
listener container and before it shuts
+ // it down
+ starter.start();
+ // the start either waits for the stop task, or is done
+ await().atMost(20, TimeUnit.SECONDS).until(
+ () -> consumer(id).isStarting() && starter.getState() ==
Thread.State.WAITING
+ || starter.getState() == Thread.State.TERMINATED);
+ } finally {
+ // the stop task took the gate, so the cleanup cannot open it: a
failed wait must not leave the starter and
+ // the stop task blocking each other
+ gate.countDown();
+ }
+ starter.join(TimeUnit.SECONDS.toMillis(20));
+ assertFalse(starter.isAlive(), "the start of the consumer completed");
+
+ assertConsumes(id, "Bye World");
+ assertListening(id);
+ }
+
+ @Test
+ public void testRouteStopDoesNotWaitForAsyncStop() throws Exception {
+ // guards that the stop task does not hold the lock of the consumer
(passes on main too)
+ String id = "routeStopStopRunning";
+ context.getRouteController().startRoute(id);
+ assertConsumes(id, "Hello World");
+
+ CountDownLatch gate = new CountDownLatch(1);
+ stopGate = gate;
+ held.hold = false;
+ held.ownThread = true;
+ try {
+ CompletableFuture<Void> stopRoute = CompletableFuture.runAsync(()
-> {
+ try {
+ context.getRouteController().stopRoute(id);
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertTrue(stopEntered.await(20, TimeUnit.SECONDS), "the stop task
is stopping the listener container");
+
+ // asyncStopListener=true: the route stop does not wait for the
listener container to stop
+ stopRoute.get(20, TimeUnit.SECONDS);
+ assertTrue(consumer(id).isStopped(), "the consumer was stopped");
+ } finally {
+ gate.countDown();
+ }
+ // the stop task shuts the listener container down once the gate is
open
+ await().atMost(20, TimeUnit.SECONDS).until(() -> ((ServiceSupport)
container(id)).isShutdown());
+ }
+
+ @Test
+ public void testRestartAfterAsyncTasksRanInOrder() throws Exception {
+ // control: the tasks run before the next lifecycle change
+ String id = "restartInOrder";
+ startRouteWithAsyncStart(id);
+
+ consumer(id).stop();
+ held.runPending();
+ assertFalse(container(id).isStarted(), "the stopped consumer stopped
its listener container");
+ consumer(id).start();
+ held.runPending();
+
+ assertConsumes(id, "Bye World");
+ assertListening(id);
+ }
+
+ private void startRouteWithAsyncStart(String routeId) throws Exception {
+ context.getRouteController().startRoute(routeId);
+ held.runPending();
+ assertConsumes(routeId, "Hello World");
+ }
+
+ private SjmsConsumer consumer(String routeId) {
+ return (SjmsConsumer) context.getRoute(routeId).getConsumer();
+ }
+
+ private StatefulService container(String routeId) {
+ return (StatefulService) consumer(routeId).getListenerContainer();
+ }
+
+ private void assertListening(String routeId) {
+ assertTrue(consumer(routeId).isStarted(), "the consumer was started");
+ assertTrue(container(routeId).isStarted(), "the started consumer must
have a started listener container");
+ }
+
+ private void assertConsumes(String routeId, String body) throws Exception {
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.reset();
+ result.expectedBodiesReceived(body);
+ template.sendBody("sjms:queue:" + QUEUE + routeId, body);
+ result.assertIsSatisfied();
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext camelContext = super.createCamelContext();
+ SjmsComponent component = new SjmsComponent() {
+ @Override
+ protected ExecutorService getAsyncStartStopExecutorService() {
+ return held;
+ }
+
+ @Override
+ protected SjmsEndpoint createSjmsEndpoint(String uri, String
remaining) {
+ return new SjmsEndpoint(uri, this, remaining) {
+ @Override
+ public MessageListenerContainer
createMessageListenerContainer(SjmsEndpoint endpoint) {
+ return new TestListenerContainer(endpoint);
+ }
+ };
+ }
+ };
+ component.setConnectionFactory(connectionFactory);
+ camelContext.addComponent("sjms", component);
+ return camelContext;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+ route("restartStopPending", STOP);
+ route("restartBothPendingStartFirst", BOTH);
+ route("restartBothPendingStopFirst", BOTH);
+ route("stopStartPending", START);
+ route("stopStartRunning", START);
+ route("startStopRunning", STOP);
+ route("routeStopStopRunning", STOP);
+ route("restartInOrder", BOTH);
+ }
+
+ private void route(String routeId, String options) {
+ from("sjms:queue:" + QUEUE + routeId +
options).routeId(routeId).autoStartup(false).to("mock:result");
+ }
+ };
+ }
+
+ @Override
+ protected void cleanupResources() throws Exception {
+ // release and run what a failed test left
+ CountDownLatch gate = stopGate;
+ if (gate != null) {
+ gate.countDown();
+ }
+ held.hold = false;
+ held.runPending();
+ super.cleanupResources();
+ }
+
+ private final class TestListenerContainer extends
SimpleMessageListenerContainer {
+
+ TestListenerContainer(SjmsEndpoint endpoint) {
+ super(endpoint);
+ }
+
+ @Override
+ public void afterPropertiesConfigured(CamelContext camelContext) {
+ Runnable hook = configureHook;
+ configureHook = null;
+ if (hook != null) {
+ hook.run();
+ }
+ super.afterPropertiesConfigured(camelContext);
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ // after the stop, before the shutdown of an asynchronous stop task
+ CountDownLatch gate = stopGate;
+ stopGate = null;
+ if (gate != null) {
+ stopEntered.countDown();
+ try {
+ if (!gate.await(30, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("the stop gate was not
opened");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+ }
+ }
+
+ /**
+ * An executor that keeps the submitted tasks until the test runs them (in
submission order or in reverse order), or
+ * runs them inline or on a thread of their own.
+ */
+ static final class HeldExecutorService extends AbstractExecutorService {
+ private final List<Runnable> tasks = new ArrayList<>();
+ volatile boolean hold = true;
+ volatile boolean ownThread;
+
+ @Override
+ public void execute(Runnable command) {
+ if (hold) {
+ synchronized (tasks) {
+ tasks.add(command);
+ }
+ } else if (ownThread) {
+ new Thread(command, "AsyncStartStopListenerTask").start();
+ } else {
+ command.run();
+ }
+ }
+
+ int pending() {
+ synchronized (tasks) {
+ return tasks.size();
+ }
+ }
+
+ void runPending() {
+ runPending(false);
+ }
+
+ void runPendingInReverse() {
+ runPending(true);
+ }
+
+ private void runPending(boolean reverse) {
+ List<Runnable> copy;
+ synchronized (tasks) {
+ copy = new ArrayList<>(tasks);
+ tasks.clear();
+ }
+ if (reverse) {
+ Collections.reverse(copy);
+ }
+ copy.forEach(Runnable::run);
+ }
+
+ @Override
+ public void shutdown() {
+ // noop
+ }
+
+ @Override
+ public List<Runnable> shutdownNow() {
+ return List.of();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return false;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return false;
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) {
+ return true;
+ }
+ }
+}