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 6b74e81a1b60 CAMEL-26602: camel-core - Suspending a route that is not 
started marks it suspended, and starting it again does not start its consumer 
(#29366)
6b74e81a1b60 is described below

commit 6b74e81a1b6048cb274fe0c463c994158c867c10
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Nov 7 15:05:12 2026 +0530

    CAMEL-26602: camel-core - Suspending a route that is not started marks it 
suspended, and starting it again does not start its consumer (#29366)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/camel/spi/RouteController.java |   4 +
 .../camel/impl/engine/AbstractCamelContext.java    |  10 +-
 .../camel/impl/RouteSuspendStoppedRouteTest.java   | 166 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   9 ++
 4 files changed, 188 insertions(+), 1 deletion(-)

diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/RouteController.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/RouteController.java
index d0ebb733241a..e8bccf41d849 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/RouteController.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/RouteController.java
@@ -202,6 +202,8 @@ public interface RouteController extends CamelContextAware, 
StaticService {
      * By suspending the route services will be kept running (if possible) and 
therefore its faster to resume the route.
      * <p/>
      * If the route does <b>not</b> support suspension the route will be 
stopped instead
+     * <p/>
+     * A route that is not started (such as a stopped route) is left as is.
      *
      * @param  routeId   the route id
      * @throws Exception is thrown if the route could not be suspended for 
whatever reason
@@ -217,6 +219,8 @@ public interface RouteController extends CamelContextAware, 
StaticService {
      * By suspending the route services will be kept running (if possible) and 
therefore its faster to resume the route.
      * <p/>
      * If the route does <b>not</b> support suspension the route will be 
stopped instead
+     * <p/>
+     * A route that is not started (such as a stopped route) is left as is.
      *
      * @param  routeId   the route id
      * @param  timeout   timeout
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
index 09dee6760730..0bcdf44f1888 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
@@ -1723,6 +1723,15 @@ public abstract class AbstractCamelContext extends 
BaseService
     public void suspendRoute(String routeId, long timeout, TimeUnit timeUnit) 
throws Exception {
         lock.lock();
         try {
+            RouteService routeService = routeServices.get(routeId);
+            if (routeService != null && !routeService.getStatus().isStarted()) 
{
+                // only a started route is suspended: a route that is stopped 
(or not auto started) must stay
+                // stopped, as starting or resuming a suspended route only 
resumes its consumer. This is checked
+                // before the route error is reset, so the last error of a 
route that failed to start is kept
+                LOG.debug("Route: {} is not suspended as its status is: {}", 
routeId, routeService.getStatus());
+                return;
+            }
+
             DefaultRouteError.reset(this, routeId);
 
             try {
@@ -1731,7 +1740,6 @@ public abstract class AbstractCamelContext extends 
BaseService
                     return;
                 }
 
-                RouteService routeService = routeServices.get(routeId);
                 if (routeService != null) {
                     List<RouteStartupOrder> routeList = new ArrayList<>(1);
                     Route route = routeService.getRoute();
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/RouteSuspendStoppedRouteTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/RouteSuspendStoppedRouteTest.java
new file mode 100644
index 000000000000..d0885724192e
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/RouteSuspendStoppedRouteTest.java
@@ -0,0 +1,166 @@
+/*
+ * 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.impl;
+
+import org.apache.camel.Component;
+import org.apache.camel.Consumer;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Processor;
+import org.apache.camel.Producer;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.RouteController;
+import org.apache.camel.spi.RouteError;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.DefaultEndpoint;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Suspending a route that is not started (stopped, or not auto started) must 
leave it stopped, so that starting or
+ * resuming it later starts its consumer, and must keep the last error of the 
route.
+ */
+public class RouteSuspendStoppedRouteTest extends ContextTestSupport {
+
+    @Test
+    public void testSuspendStoppedRouteThenStart() throws Exception {
+        RouteController controller = context.getRouteController();
+        controller.stopRoute("foo");
+        controller.suspendRoute("foo");
+        assertEquals(ServiceStatus.Stopped, controller.getRouteStatus("foo"));
+
+        controller.startRoute("foo");
+        assertEquals(ServiceStatus.Started, controller.getRouteStatus("foo"));
+        assertConsumes("A");
+    }
+
+    @Test
+    public void testSuspendStoppedRouteThenResume() throws Exception {
+        RouteController controller = context.getRouteController();
+        controller.stopRoute("foo");
+        controller.suspendRoute("foo");
+
+        // resuming a route that is not suspended starts it
+        controller.resumeRoute("foo");
+        assertEquals(ServiceStatus.Started, controller.getRouteStatus("foo"));
+        assertConsumes("B");
+    }
+
+    @Test
+    public void testSuspendNotAutoStartedRouteThenResume() throws Exception {
+        RouteController controller = context.getRouteController();
+        assertEquals(ServiceStatus.Stopped, controller.getRouteStatus("bar"));
+        controller.suspendRoute("bar");
+        assertEquals(ServiceStatus.Stopped, controller.getRouteStatus("bar"));
+
+        controller.resumeRoute("bar");
+        assertEquals(ServiceStatus.Started, controller.getRouteStatus("bar"));
+
+        MockEndpoint mock = getMockEndpoint("mock:bar");
+        mock.expectedBodiesReceived("C");
+        template.sendBody("seda:bar", "C");
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void testSuspendStartedRouteThenResume() throws Exception {
+        RouteController controller = context.getRouteController();
+        controller.suspendRoute("foo");
+        assertEquals(ServiceStatus.Suspended, 
controller.getRouteStatus("foo"));
+
+        controller.resumeRoute("foo");
+        assertEquals(ServiceStatus.Started, controller.getRouteStatus("foo"));
+        assertConsumes("D");
+    }
+
+    @Test
+    public void testSuspendRouteThatFailedToStartKeepsItsError() throws 
Exception {
+        RouteController controller = context.getRouteController();
+        assertThrows(Exception.class, () -> controller.startRoute("baz"));
+        RouteError error = context.getRoute("baz").getLastError();
+        assertNotNull(error);
+        assertEquals(RouteError.Phase.START, error.getPhase());
+        ServiceStatus status = controller.getRouteStatus("baz");
+        assertFalse(status.isStarted());
+
+        // the suspend leaves the route as it is, so the error of the failed 
start is still shown
+        controller.suspendRoute("baz");
+        assertEquals(status, controller.getRouteStatus("baz"));
+        assertSame(error, context.getRoute("baz").getLastError());
+    }
+
+    @Test
+    public void testSuspendFailedRouteKeepsItsError() throws Exception {
+        RouteController controller = context.getRouteController();
+        controller.stopRoute("foo", new IllegalArgumentException("Forced"));
+        RouteError error = context.getRoute("foo").getLastError();
+        assertNotNull(error);
+        assertEquals(RouteError.Phase.STOP, error.getPhase());
+
+        controller.suspendRoute("foo");
+        assertEquals(ServiceStatus.Stopped, controller.getRouteStatus("foo"));
+        assertSame(error, context.getRoute("foo").getLastError());
+    }
+
+    private void assertConsumes(String body) throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived(body);
+        template.sendBody("seda:foo", body);
+        assertMockEndpointsSatisfied();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("seda:foo").routeId("foo").to("mock:result");
+                
from("seda:bar").routeId("bar").autoStartup(false).to("mock:bar");
+                from(new FailToStartEndpoint("direct:baz", 
getContext().getComponent("direct"))).routeId("baz")
+                        .autoStartup(false).to("mock:baz");
+            }
+        };
+    }
+
+    private static final class FailToStartEndpoint extends DefaultEndpoint {
+
+        FailToStartEndpoint(String uri, Component component) {
+            super(uri, component);
+        }
+
+        @Override
+        public Producer createProducer() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Consumer createConsumer(Processor processor) {
+            return new DefaultConsumer(this, processor) {
+                @Override
+                protected void doStart() throws Exception {
+                    throw new IllegalStateException("Forced start failure");
+                }
+            };
+        }
+    }
+}
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 f0a3e7e0ec09..f3b79ce4f4d6 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
@@ -257,6 +257,15 @@ rejected policy. Before, it could wait for a permit it 
held itself, until `keepA
 than `maxQueueSize` allows, and with `maxQueueSize` 1 it was rejected after 
`keepAliveTime`. A submitter outside the
 pool waits, and is rejected with `Abort`, as before.
 
+=== camel-core - suspendRoute on a route that is not started leaves it as it is
+
+`suspendRoute` on a route that is not started (stopped, or with 
`autoStartup=false` and never started), from the
+`RouteController`, JMX, the control bus, the CLI (`camel cmd suspend-route`) 
or a route policy such as
+`DurationRoutePolicy`, now leaves the route in its current state, and route 
policies no longer get `onSuspend` for it.
+Before, the route became `Suspended`, and a later `startRoute` or 
`resumeRoute` resumed it without starting its
+consumer, so it consumed nothing. Code that checks the status of a route after 
`suspendRoute` now sees `Stopped` for
+such a route. The last error of such a route, for example from a failed start, 
is kept.
+
 === camel-core - Routing Slip and Dynamic Router fire the sent event of a step 
before the next step
 
 The `ExchangeSentEvent` of a step of a `routingSlip` or `dynamicRouter` is now 
fired when that step is done, before the

Reply via email to