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