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 8388b0ae4dd3 CAMEL-26756: camel-cluster - ClusteredRouteController: 
stop the cluster service once the routes have stopped (#29531)
8388b0ae4dd3 is described below

commit 8388b0ae4dd3c7cbeecbd713117ac6365e6558dc
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Nov 10 18:22:38 2026 +0530

    CAMEL-26756: camel-cluster - ClusteredRouteController: stop the cluster 
service once the routes have stopped (#29531)
    
    When the CamelContext stops, it stops its route controller before the 
routes.
    ClusteredRouteController.doStop stopped the cluster service there, giving up
    the leadership while the routes were still draining their in-flight
    exchanges, so a follower could take over and run the same routes during a
    graceful stop. Stop the cluster service once the routes have stopped, and
    unregister the controller's listeners.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../impl/cluster/ClusteredRouteController.java     |  86 ++++-
 .../cluster/ClusteredRouteControllerStopTest.java  | 350 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  10 +
 3 files changed, 445 insertions(+), 1 deletion(-)

diff --git 
a/core/camel-cluster/src/main/java/org/apache/camel/impl/cluster/ClusteredRouteController.java
 
b/core/camel-cluster/src/main/java/org/apache/camel/impl/cluster/ClusteredRouteController.java
index 87bf5f0a6af6..62b158581cc8 100644
--- 
a/core/camel-cluster/src/main/java/org/apache/camel/impl/cluster/ClusteredRouteController.java
+++ 
b/core/camel-cluster/src/main/java/org/apache/camel/impl/cluster/ClusteredRouteController.java
@@ -30,13 +30,19 @@ import java.util.concurrent.CopyOnWriteArraySet;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.NamedNode;
+import org.apache.camel.NonManagedService;
 import org.apache.camel.Route;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.cluster.CamelClusterService;
 import org.apache.camel.impl.engine.DefaultRouteController;
 import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CamelEvent.CamelContextRoutesStoppedEvent;
+import org.apache.camel.spi.LifecycleStrategy;
 import org.apache.camel.spi.RoutePolicy;
 import org.apache.camel.spi.RoutePolicyFactory;
+import org.apache.camel.support.LifecycleStrategySupport;
+import org.apache.camel.support.SimpleEventNotifierSupport;
 import org.apache.camel.support.cluster.ClusterServiceHelper;
 import org.apache.camel.support.cluster.ClusterServiceSelectors;
 import org.apache.camel.support.service.ServiceHelper;
@@ -55,6 +61,8 @@ public class ClusteredRouteController extends 
DefaultRouteController {
     private final List<ClusteredRouteFilter> filters;
     private final PolicyFactory policyFactory;
     private final ClusteredRouteConfiguration defaultConfiguration;
+    private final RoutesStoppedListener routesStoppedListener;
+    private final LifecycleStrategy contextStoppedStrategy;
     private CamelClusterService clusterService;
     private CamelClusterService.Selector clusterServiceSelector;
 
@@ -64,6 +72,8 @@ public class ClusteredRouteController extends 
DefaultRouteController {
         this.filters = new ArrayList<>();
         this.clusterServiceSelector = ClusterServiceSelectors.DEFAULT_SELECTOR;
         this.policyFactory = new PolicyFactory();
+        this.routesStoppedListener = new RoutesStoppedListener();
+        this.contextStoppedStrategy = new ContextStoppedStrategy();
 
         this.defaultConfiguration = new ClusteredRouteConfiguration();
         this.defaultConfiguration.setInitialDelay(Duration.ofMillis(0));
@@ -141,7 +151,8 @@ public class ClusteredRouteController extends 
DefaultRouteController {
     }
 
     /**
-     * Set the cluster service to use.
+     * Set the cluster service to use. The route controller starts it if it is 
not started yet, and stops it when the
+     * CamelContext stops, once the routes have stopped.
      */
     public void setClusterService(CamelClusterService clusterService) {
         ObjectHelper.notNull(clusterService, "CamelClusterService");
@@ -193,11 +204,43 @@ public class ClusteredRouteController extends 
DefaultRouteController {
             clusterService.start();
         }
 
+        if 
(!context.getManagementStrategy().getEventNotifiers().contains(routesStoppedListener))
 {
+            
context.getManagementStrategy().addEventNotifier(routesStoppedListener);
+        }
+        // a fallback for an EventFactory that does not create the 
CamelContextRoutesStoppedEvent
+        context.addLifecycleStrategy(contextStoppedStrategy);
+
         super.doStart();
     }
 
     @Override
     public void doStop() throws Exception {
+        if (getCamelContext() != null && getCamelContext().isStopping()) {
+            // The CamelContext stops the route controller before the routes. 
The routes must keep the leadership until
+            // their consumers have stopped and their in-flight exchanges are 
done, or another node would start them in
+            // the meantime, so the cluster service is stopped once the routes 
have stopped.
+            LOGGER.debug("CamelContext is stopping: the cluster service is 
stopped once the routes have stopped");
+            return;
+        }
+
+        stopClusterService();
+    }
+
+    @Override
+    protected void doShutdown() throws Exception {
+        final CamelContext context = getCamelContext();
+        if (context != null && !context.isStopping()) {
+            // The route controller is shut down while the CamelContext runs, 
so it must no longer stop the cluster
+            // service when the CamelContext stops. While the CamelContext 
stops, the listeners stay registered until it
+            // has stopped, as they stop the cluster service once the routes 
have stopped.
+            
context.getManagementStrategy().removeEventNotifier(routesStoppedListener);
+            context.getLifecycleStrategies().remove(contextStoppedStrategy);
+        }
+
+        super.doShutdown();
+    }
+
+    private void stopClusterService() {
         if (ServiceHelper.isStarted(clusterService)) {
             // Stop the cluster service.
             clusterService.stop();
@@ -213,6 +256,47 @@ public class ClusteredRouteController extends 
DefaultRouteController {
         super.setCamelContext(camelContext);
     }
 
+    /**
+     * Stops the cluster service once the routes have stopped, when the 
CamelContext stops.
+     */
+    private final class RoutesStoppedListener extends 
SimpleEventNotifierSupport implements NonManagedService {
+        RoutesStoppedListener() {
+            // only the CamelContext events are needed (the exchange and step 
events are ignored by default)
+            setIgnoreRouteEvents(true);
+            setIgnoreServiceEvents(true);
+        }
+
+        @Override
+        public boolean isEnabled(CamelEvent event) {
+            return event instanceof CamelContextRoutesStoppedEvent;
+        }
+
+        @Override
+        public void notify(CamelEvent event) throws Exception {
+            stopClusterService();
+        }
+    }
+
+    /**
+     * Stops the cluster service when the CamelContext has stopped, if it is 
still started. The cluster service is
+     * normally stopped earlier, once the routes have stopped, but a custom 
EventFactory may not create the
+     * CamelContextRoutesStoppedEvent. Then unregisters the listeners of the 
route controller, which registers them
+     * again when it is started again, so a route controller replaced while 
the CamelContext was stopped leaves none
+     * behind.
+     */
+    private final class ContextStoppedStrategy extends 
LifecycleStrategySupport {
+        @Override
+        public void onContextStopped(CamelContext context) {
+            try {
+                stopClusterService();
+            } catch (Exception e) {
+                LOGGER.warn("Error stopping the cluster service {}. This 
exception is ignored.", clusterService, e);
+            }
+            
context.getManagementStrategy().removeEventNotifier(routesStoppedListener);
+            context.getLifecycleStrategies().remove(this);
+        }
+    }
+
     // *******************************
     // Factories
     // *******************************
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/cluster/ClusteredRouteControllerStopTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/cluster/ClusteredRouteControllerStopTest.java
new file mode 100644
index 000000000000..d41457612527
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/cluster/ClusteredRouteControllerStopTest.java
@@ -0,0 +1,350 @@
+/*
+ * 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.cluster;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.impl.cluster.ClusteredRouteController;
+import org.apache.camel.impl.event.DefaultEventFactory;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.support.LifecycleStrategySupport;
+import org.apache.camel.support.cluster.AbstractCamelClusterService;
+import org.apache.camel.support.cluster.AbstractCamelClusterView;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The CamelContext stops its route controller before the routes, and the 
ClusteredRouteController must not stop the
+ * cluster service (giving up the leadership) before the consumers of the 
routes have stopped.
+ */
+public class ClusteredRouteControllerStopTest {
+
+    @Test
+    public void testCamelContextClusterServiceStoppedAfterRoutes() throws 
Exception {
+        TestClusterService cs = new TestClusterService("cs");
+        CamelContext context = new DefaultCamelContext();
+        context.addService(cs);
+        ClusteredRouteController controller = new ClusteredRouteController();
+
+        doTestStopWithInflightExchange(context, controller, cs);
+    }
+
+    @Test
+    public void testControllerClusterServiceStoppedAfterRoutes() throws 
Exception {
+        TestClusterService cs = new TestClusterService("cs");
+        CamelContext context = new DefaultCamelContext();
+        ClusteredRouteController controller = new ClusteredRouteController();
+        // a cluster service that is not a service of the CamelContext, so the 
route controller starts and stops it
+        controller.setClusterService(cs);
+
+        doTestStopWithInflightExchange(context, controller, cs);
+    }
+
+    @Test
+    public void testControllerStoppedOnItsOwnStopsClusterService() throws 
Exception {
+        TestClusterService cs = new TestClusterService("cs");
+        CamelContext context = new DefaultCamelContext();
+        ClusteredRouteController controller = new ClusteredRouteController();
+        controller.setClusterService(cs);
+        controller.setNamespace("my-ns");
+        context.setRouteController(controller);
+        context.start();
+        try {
+            assertTrue(cs.isStarted());
+
+            // the CamelContext keeps running, so the cluster service is 
stopped right away
+            controller.stop();
+            assertFalse(cs.isStarted());
+        } finally {
+            context.stop();
+        }
+    }
+
+    @Test
+    public void testControllerClusterServiceStoppedOnEachCamelContextStop() 
throws Exception {
+        TestClusterService cs = new TestClusterService("cs");
+        CamelContext context = new DefaultCamelContext();
+        ClusteredRouteController controller = new ClusteredRouteController();
+        controller.setClusterService(cs);
+        controller.setNamespace("my-ns");
+        context.setRouteController(controller);
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("seda:foo").routeId("foo").to("mock:foo");
+            }
+        });
+
+        AtomicReference<Boolean> startedWhenContextStopped = 
probeClusterServiceWhenContextStopped(context, cs);
+
+        try {
+            for (int i = 0; i < 2; i++) {
+                context.start();
+                assertTrue(cs.isStarted());
+                // the route controller registers its listeners once, also 
when the CamelContext is started again
+                assertEquals(1, countControllerListeners(context));
+                startedWhenContextStopped.set(null);
+                context.stop();
+                assertFalse(cs.isStarted());
+                // stopped once the routes have stopped, not by the fallback 
when the CamelContext has stopped
+                assertEquals(Boolean.FALSE, startedWhenContextStopped.get());
+                // and unregistered when the CamelContext has stopped
+                assertEquals(0, countControllerListeners(context));
+            }
+        } finally {
+            context.stop();
+        }
+    }
+
+    @Test
+    public void testControllerClusterServiceStoppedWithoutRoutesStoppedEvent() 
throws Exception {
+        TestClusterService cs = new TestClusterService("cs");
+        CamelContext context = new DefaultCamelContext();
+        // a custom EventFactory that does not create the 
CamelContextRoutesStoppedEvent
+        context.getManagementStrategy().setEventFactory(new 
DefaultEventFactory() {
+            @Override
+            public CamelEvent 
createCamelContextRoutesStoppedEvent(CamelContext context) {
+                return null;
+            }
+        });
+        ClusteredRouteController controller = new ClusteredRouteController();
+        // a cluster service that is not a service of the CamelContext, so 
only the route controller stops it
+        controller.setClusterService(cs);
+        controller.setNamespace("my-ns");
+        context.setRouteController(controller);
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("seda:foo").routeId("foo").to("mock:foo");
+            }
+        });
+
+        try {
+            context.start();
+            assertTrue(cs.isStarted());
+            context.stop();
+            // the cluster service is stopped when the CamelContext has stopped
+            assertFalse(cs.isStarted());
+        } finally {
+            context.stop();
+        }
+    }
+
+    @Test
+    public void testControllerShutdownOnItsOwnUnregistersListeners() throws 
Exception {
+        TestClusterService cs = new TestClusterService("cs");
+        CamelContext context = new DefaultCamelContext();
+        ClusteredRouteController controller = new ClusteredRouteController();
+        controller.setClusterService(cs);
+        controller.setNamespace("my-ns");
+        context.setRouteController(controller);
+
+        try {
+            context.start();
+            assertEquals(1, countControllerListeners(context));
+
+            // the route controller is shut down while the CamelContext keeps 
running
+            controller.shutdown();
+            assertFalse(cs.isStarted());
+            assertEquals(0, countControllerListeners(context));
+
+            // the cluster service is used elsewhere: stopping the 
CamelContext must not stop it through this controller
+            cs.start();
+            context.stop();
+            assertTrue(cs.isStarted());
+        } finally {
+            cs.stop();
+            context.stop();
+        }
+    }
+
+    /**
+     * Records whether the cluster service is still started when the 
CamelContext has stopped, before the fallback of
+     * the route controller (whose lifecycle strategy is added after this one, 
when the route controller starts).
+     */
+    private static AtomicReference<Boolean> 
probeClusterServiceWhenContextStopped(
+            CamelContext context, TestClusterService cs) {
+        AtomicReference<Boolean> started = new AtomicReference<>();
+        context.addLifecycleStrategy(new LifecycleStrategySupport() {
+            @Override
+            public void onContextStopped(CamelContext context) {
+                started.set(cs.isStarted());
+            }
+        });
+        return started;
+    }
+
+    private static long countControllerListeners(CamelContext context) {
+        long notifiers = 
context.getManagementStrategy().getEventNotifiers().stream()
+                .filter(n -> n.getClass().getEnclosingClass() == 
ClusteredRouteController.class)
+                .count();
+        long strategies = context.getLifecycleStrategies().stream()
+                .filter(s -> s.getClass().getEnclosingClass() == 
ClusteredRouteController.class)
+                .count();
+        assertEquals(notifiers, strategies);
+        return notifiers;
+    }
+
+    private void doTestStopWithInflightExchange(
+            CamelContext context, ClusteredRouteController controller, 
TestClusterService cs)
+            throws Exception {
+        CountDownLatch processing = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+
+        controller.setNamespace("my-ns");
+        context.setRouteController(controller);
+        context.getShutdownStrategy().setTimeout(30);
+        AtomicReference<Boolean> startedWhenContextStopped = 
probeClusterServiceWhenContextStopped(context, cs);
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("seda:foo").routeId("foo")
+                        .process(e -> {
+                            processing.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .to("mock:foo");
+            }
+        });
+        context.start();
+
+        Thread stopper = new Thread(context::stop, "CamelContextStopper");
+        try {
+            cs.getView().setLeader(true);
+            await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+                    () -> assertEquals(ServiceStatus.Started, 
context.getRouteController().getRouteStatus("foo")));
+
+            context.createProducerTemplate().sendBody("seda:foo", "Hello");
+            assertTrue(processing.await(10, TimeUnit.SECONDS));
+
+            stopper.start();
+            // the CamelContext stops the route controller first, and then 
waits for the in-flight exchange
+            await().atMost(10, TimeUnit.SECONDS).until(controller::isStopped);
+
+            // the route still processes its exchange, so this node must still 
be the leader
+            assertTrue(cs.isStarted());
+            assertTrue(cs.getView().isRunAllowed());
+            assertTrue(cs.getView().getLocalMember().isLeader());
+        } finally {
+            release.countDown();
+            if (!stopper.isAlive()) {
+                // the test failed before the stop
+                context.stop();
+            }
+            stopper.join(TimeUnit.SECONDS.toMillis(30));
+        }
+
+        assertFalse(stopper.isAlive());
+        assertEquals(ServiceStatus.Stopped, 
context.getRouteController().getRouteStatus("foo"));
+        // the cluster service is stopped once the routes have stopped
+        assertFalse(cs.isStarted());
+        assertFalse(cs.getView().isRunAllowed());
+        assertEquals(Boolean.FALSE, startedWhenContextStopped.get());
+    }
+
+    // *********************************
+    // Helpers
+    // *********************************
+
+    private static class TestClusterView extends AbstractCamelClusterView {
+        private volatile boolean leader;
+
+        TestClusterView(CamelClusterService cluster, String namespace) {
+            super(cluster, namespace);
+        }
+
+        @Override
+        public Optional<CamelClusterMember> getLeader() {
+            return leader ? Optional.of(getLocalMember()) : Optional.empty();
+        }
+
+        @Override
+        public CamelClusterMember getLocalMember() {
+            return new CamelClusterMember() {
+                @Override
+                public boolean isLeader() {
+                    return leader;
+                }
+
+                @Override
+                public boolean isLocal() {
+                    return true;
+                }
+
+                @Override
+                public String getId() {
+                    return getClusterService().getId();
+                }
+            };
+        }
+
+        @Override
+        public List<CamelClusterMember> getMembers() {
+            return Collections.emptyList();
+        }
+
+        @Override
+        protected void doStart() {
+        }
+
+        @Override
+        protected void doStop() {
+            // a view gives up the leadership when it stops
+            leader = false;
+        }
+
+        void setLeader(boolean leader) {
+            this.leader = leader;
+            if (isRunAllowed()) {
+                fireLeadershipChangedEvent(getLeader().orElse(null));
+            }
+        }
+    }
+
+    private static class TestClusterService extends 
AbstractCamelClusterService<TestClusterView> {
+        private TestClusterView view;
+
+        TestClusterService(String id) {
+            super(id);
+        }
+
+        @Override
+        protected TestClusterView createView(String namespace) {
+            if (view == null) {
+                view = new TestClusterView(this, namespace);
+            }
+            return view;
+        }
+
+        TestClusterView getView() {
+            return view;
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 6b8d57e16367..bc04938d7a7a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -7307,6 +7307,16 @@ a route. `ErrorHandlerRedeliveryCustomizer` has a new 
default method `useStepHoo
 need no change. A custom error handler that wants a failure from the hook 
handled like a failure of the step's
 processor (with `onException` and redelivery) can override it.
 
+=== camel-cluster - ClusteredRouteController stops the cluster service once 
the routes have stopped
+
+When the CamelContext stops, the `ClusteredRouteController` no longer stops 
the cluster service when the route
+controller is stopped (which the CamelContext does before it stops the 
routes), but once the routes have stopped. A node
+being stopped can keep (or still take) the leadership of its namespaces until 
its routes have finished their in-flight
+exchanges, so another node no longer starts the clustered routes at the same 
time. A graceful stop can therefore hand
+over the leadership later than before, by up to the shutdown timeout of the 
CamelContext (45 seconds by default). To
+hand over sooner, lower the shutdown timeout (`camel.main.shutdownTimeout`, or
+`CamelContext.getShutdownStrategy().setTimeout`).
+
 === camel-quartz - a route that keeps its trigger fires again when it is added 
again or after a restart
 
 With `deleteJob=false` (as with `pauseJob=true`), a route pauses its trigger 
when it stops, and only the same endpoint

Reply via email to