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 a4f2532b4262 CAMEL-25848: camel-quartz - a scheduled resume or start 
starts a consumer that the scheduled suspend stopped (#28321)
a4f2532b4262 is described below

commit a4f2532b426202cbd63f0eff233cd7631fb9de01
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Oct 23 22:04:13 2026 +0530

    CAMEL-25848: camel-quartz - a scheduled resume or start starts a consumer 
that the scheduled suspend stopped (#28321)
    
    * CAMEL-25848: camel-quartz - a scheduled resume or start starts a consumer 
that the scheduled suspend stopped
    
    The scheduled route policies (CronScheduledRoutePolicy and
    SimpleScheduledRoutePolicy) suspend the consumer of the route with
    suspendOrStopConsumer, which stops a consumer that is not Suspendable
    (the consumers of netty, mina, mllp, paho, paho-mqtt5, grpc,
    azure-servicebus, google-pubsub and others). The route stays Started.
    
    The scheduled resume only resumed a consumer that was suspended, and
    logged "The Consumer ... is not suspended and cannot be resumed" for
    a stopped one; the scheduled start of a Started route did nothing.
    Such a consumer therefore stayed stopped after the first scheduled
    suspend, until the route was restarted.
    
    A scheduled resume or start of a Started route now also starts a
    consumer that the scheduled suspend of the policy stopped (the policy
    remembers it per route until the consumer or the route starts again or
    the route stops), with resumeOrStartConsumer as the throttling route
    policies do. A consumer that something else stopped is left stopped:
    when a route or Camel stops, the shutdown strategy stops a consumer
    that is not Suspendable and then waits for the in-flight exchanges
    while the route status stays Started, so a trigger that fires in
    between must not start the consumer again. The policy also checks
    isResumeOrStartConsumerAllowed first, as the throttling route policies
    do.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25848: camel-quartz - the scheduled suspend records only a consumer 
that it stopped
    
    Review of #28321: suspendOrStopConsumer stops a non-Suspendable consumer 
even when it is
    already stopped, so the scheduled suspend also recorded a consumer that 
another policy or the
    shutdown strategy had stopped, and a later scheduled resume or start 
started it again. The
    SUSPEND action now checks the state before it suspends and records the 
route only when this
    suspend stopped the consumer.
    
    The record is also cleared in onResume. The route controller calls onResume 
only for a route
    whose consumer is Suspendable (which the scheduled suspend suspends and 
never records); a route
    whose consumer is not Suspendable is stopped and started instead, which 
clears the record in
    onStop and onStart.
    
    Tests (ScheduledRoutePolicyNonSuspendableConsumerTest):
    - scheduledSuspendDoesNotRecordAConsumerThatWasAlreadyStopped fails when 
the suspend records a
      consumer that was already stopped;
    - routeSuspendedAndResumedAfterTheScheduledSuspendKeepsNoRecordOfIt (route 
controller suspend
      and resume after the scheduled suspend, then another stop of the consumer 
and a scheduled
      resume) passes with and without the onResume line, as onStop/onStart 
clear the record.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../routepolicy/quartz/ScheduledRoutePolicy.java   |  76 +++++-
 ...duledRoutePolicyNonSuspendableConsumerTest.java | 271 +++++++++++++++++++++
 2 files changed, 342 insertions(+), 5 deletions(-)

diff --git 
a/components/camel-quartz/src/main/java/org/apache/camel/routepolicy/quartz/ScheduledRoutePolicy.java
 
b/components/camel-quartz/src/main/java/org/apache/camel/routepolicy/quartz/ScheduledRoutePolicy.java
index 1e8a7d1cb25c..5e6c61f8ac0c 100644
--- 
a/components/camel-quartz/src/main/java/org/apache/camel/routepolicy/quartz/ScheduledRoutePolicy.java
+++ 
b/components/camel-quartz/src/main/java/org/apache/camel/routepolicy/quartz/ScheduledRoutePolicy.java
@@ -18,8 +18,11 @@ package org.apache.camel.routepolicy.quartz;
 
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 
+import org.apache.camel.Consumer;
 import org.apache.camel.NonManagedService;
 import org.apache.camel.Route;
 import org.apache.camel.RuntimeCamelException;
@@ -58,6 +61,9 @@ public abstract class ScheduledRoutePolicy extends 
RoutePolicySupport
     @Metadata(description = "Timeout (in millis) when stopping routes.", 
defaultValue = "10000")
     private int routeStopGracePeriod;
     private TimeUnit timeUnit;
+    // the routes whose consumer the scheduled suspend stopped (it is not 
Suspendable): only such a consumer is started
+    // again by a scheduled resume or start, not one that the shutdown of the 
route, or another policy, stopped
+    private final Set<String> consumersStoppedBySuspend = 
ConcurrentHashMap.newKeySet();
 
     protected abstract Trigger createTrigger(Action action, Route route) 
throws Exception;
 
@@ -69,8 +75,8 @@ public abstract class ScheduledRoutePolicy extends 
RoutePolicySupport
             if (routeStatus == ServiceStatus.Stopped) {
                 startRoute(route);
                 // here we just check the states of the Consumer
-            } else if (ServiceHelper.isSuspended(route.getConsumer())) {
-                resumeOrStartConsumer(route.getConsumer());
+            } else if (isSuspendedOrStoppedBySuspend(route)) {
+                resumeOrStartSuspendedConsumer(route);
             }
         } else if (action == Action.STOP) {
             if (routeStatus == ServiceStatus.Started || routeStatus == 
ServiceStatus.Suspended) {
@@ -81,14 +87,21 @@ public abstract class ScheduledRoutePolicy extends 
RoutePolicySupport
             }
         } else if (action == Action.SUSPEND) {
             if (routeStatus == ServiceStatus.Started) {
-                suspendOrStopConsumer(route.getConsumer());
+                Consumer consumer = route.getConsumer();
+                // only a consumer that this suspend stopped, not one that was 
stopped already (by another policy, or
+                // by the shutdown strategy while the route is being stopped)
+                boolean alreadyStopped = ServiceHelper.isStopped(consumer);
+                suspendOrStopConsumer(consumer);
+                if (!alreadyStopped && ServiceHelper.isStopped(consumer)) {
+                    consumersStoppedBySuspend.add(route.getId());
+                }
             } else {
                 LOG.warn("Route is not in a started state and cannot be 
suspended. The current route state is {}", routeStatus);
             }
         } else if (action == Action.RESUME) {
             if (routeStatus == ServiceStatus.Started) {
-                if (ServiceHelper.isSuspended(route.getConsumer())) {
-                    resumeOrStartConsumer(route.getConsumer());
+                if (isSuspendedOrStoppedBySuspend(route)) {
+                    resumeOrStartSuspendedConsumer(route);
                 } else {
                     LOG.warn("The Consumer {} is not suspended and cannot be 
resumed.", route.getConsumer());
                 }
@@ -98,6 +111,59 @@ public abstract class ScheduledRoutePolicy extends 
RoutePolicySupport
         }
     }
 
+    /**
+     * Whether the consumer of a started route was suspended, or stopped by 
the scheduled suspend as it is not
+     * {@link org.apache.camel.Suspendable} (see {@link 
#suspendOrStopConsumer(Consumer)}), so a resume or start must
+     * resume or start it again. A consumer that something else stopped, such 
as the shutdown strategy while the route
+     * is being stopped (the route status stays Started until the in-flight 
exchanges are done), is left stopped.
+     */
+    private boolean isSuspendedOrStoppedBySuspend(Route route) {
+        Consumer consumer = route.getConsumer();
+        if (ServiceHelper.isSuspended(consumer)) {
+            return true;
+        }
+        if (ServiceHelper.isStopped(consumer)) {
+            return consumersStoppedBySuspend.contains(route.getId());
+        }
+        return false;
+    }
+
+    /**
+     * Resumes or starts the consumer of a started route, unless Camel is 
stopping: the shutdown suspends or stops the
+     * consumers of the routes before it stops the routes, so a scheduled 
resume or start that fires meanwhile must not
+     * start the consumer again (as the throttling route policies do, see
+     * {@link #isResumeOrStartConsumerAllowed(Route)}).
+     */
+    private void resumeOrStartSuspendedConsumer(Route route) throws Exception {
+        if (isResumeOrStartConsumerAllowed(route)) {
+            consumersStoppedBySuspend.remove(route.getId());
+            resumeOrStartConsumer(route.getConsumer());
+        } else {
+            LOG.debug("The consumer of route {} is not resumed or started as 
Camel or the route is stopping", route.getId());
+        }
+    }
+
+    @Override
+    public void onStart(Route route) {
+        super.onStart(route);
+        // the route (and its consumer) started again
+        consumersStoppedBySuspend.remove(route.getId());
+    }
+
+    @Override
+    public void onStop(Route route) {
+        super.onStop(route);
+        consumersStoppedBySuspend.remove(route.getId());
+    }
+
+    @Override
+    public void onResume(Route route) {
+        super.onResume(route);
+        // the route (and its consumer) resumed; a route whose consumer is not 
Suspendable is stopped and started by the
+        // route controller instead (onStop and onStart), so this only makes 
sure that no record outlives a resume
+        consumersStoppedBySuspend.remove(route.getId());
+    }
+
     @Override
     public void onRemove(Route route) {
         try {
diff --git 
a/components/camel-quartz/src/test/java/org/apache/camel/routepolicy/quartz/ScheduledRoutePolicyNonSuspendableConsumerTest.java
 
b/components/camel-quartz/src/test/java/org/apache/camel/routepolicy/quartz/ScheduledRoutePolicyNonSuspendableConsumerTest.java
new file mode 100644
index 000000000000..6cf58f4a9e48
--- /dev/null
+++ 
b/components/camel-quartz/src/test/java/org/apache/camel/routepolicy/quartz/ScheduledRoutePolicyNonSuspendableConsumerTest.java
@@ -0,0 +1,271 @@
+/*
+ * 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.routepolicy.quartz;
+
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Consumer;
+import org.apache.camel.Processor;
+import org.apache.camel.Producer;
+import org.apache.camel.Route;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.Suspendable;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.quartz.QuartzComponent;
+import org.apache.camel.impl.engine.DefaultShutdownStrategy;
+import org.apache.camel.spi.RoutePolicy;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.DefaultEndpoint;
+import org.apache.camel.support.service.ServiceHelper;
+import org.awaitility.Awaitility;
+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.assertTrue;
+
+/**
+ * A consumer that is not {@link Suspendable} is stopped when a scheduled 
route policy suspends its route. The scheduled
+ * resume (or start) must start it again, as {@link 
org.apache.camel.support.RoutePolicySupport#resumeOrStartConsumer}
+ * does.
+ */
+class ScheduledRoutePolicyNonSuspendableConsumerTest extends NoBuilderTest {
+
+    @Test
+    void scheduledResumeStartsConsumerThatWasStoppedBySuspend() throws 
Exception {
+        context.addEndpoint("listener:in", new ListenerEndpoint());
+        context.getComponent("quartz", QuartzComponent.class)
+                
.setPropertiesFile("org/apache/camel/routepolicy/quartz/myquartz.properties");
+        context.addRoutes(new RouteBuilder() {
+            public void configure() {
+                SimpleScheduledRoutePolicy policy = new 
SimpleScheduledRoutePolicy();
+                long now = System.currentTimeMillis();
+                policy.setRouteSuspendDate(new Date(now + 500));
+                policy.setRouteSuspendRepeatCount(0);
+                policy.setRouteResumeDate(new Date(now + 2000));
+                policy.setRouteResumeRepeatCount(0);
+
+                
from("listener:in").routeId("test").routePolicy(policy).to("mock:result");
+            }
+        });
+        context.start();
+
+        Consumer consumer = context.getRoute("test").getConsumer();
+
+        // the suspend stops the consumer, as it cannot be suspended
+        Awaitility.await().atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> 
assertTrue(ServiceHelper.isStopped(consumer), "consumer should be stopped"));
+        assertEquals(ServiceStatus.Started, 
context.getRouteController().getRouteStatus("test"));
+
+        // the resume must start it again
+        Awaitility.await().atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> 
assertTrue(ServiceHelper.isStarted(consumer), "consumer should be started 
again"));
+    }
+
+    @Test
+    void scheduledStartStartsConsumerThatWasStoppedBySuspend() throws 
Exception {
+        context.addEndpoint("listener:in", new ListenerEndpoint());
+        context.addRoutes(new RouteBuilder() {
+            public void configure() {
+                CronScheduledRoutePolicy policy = new 
CronScheduledRoutePolicy();
+                // never fires by itself, the actions are run below as the 
scheduler would
+                policy.setRouteSuspendTime("0 0 0 1 1 ? 2099");
+                policy.setRouteStartTime("0 0 0 1 1 ? 2099");
+
+                
from("listener:in").routeId("test").routePolicy(policy).to("mock:result");
+            }
+        });
+        context.start();
+
+        Route route = context.getRoute("test");
+        ScheduledRoutePolicy policy = scheduledRoutePolicy(route);
+        Consumer consumer = route.getConsumer();
+
+        policy.onJobExecute(ScheduledRoutePolicyConstants.Action.SUSPEND, 
route);
+        assertTrue(ServiceHelper.isStopped(consumer), "consumer should be 
stopped");
+        assertFalse(ServiceHelper.isSuspended(consumer));
+
+        policy.onJobExecute(ScheduledRoutePolicyConstants.Action.START, route);
+        assertTrue(ServiceHelper.isStarted(consumer), "consumer should be 
started again");
+    }
+
+    @Test
+    void scheduledSuspendDoesNotRecordAConsumerThatWasAlreadyStopped() throws 
Exception {
+        Route route = startRouteWithActionsRunByTheTest();
+        ScheduledRoutePolicy policy = scheduledRoutePolicy(route);
+        Consumer consumer = route.getConsumer();
+
+        // something else stopped the consumer (such as a throttling policy), 
and the route is still started
+        ServiceHelper.stopService(consumer);
+        policy.onJobExecute(ScheduledRoutePolicyConstants.Action.SUSPEND, 
route);
+
+        // the consumer was not stopped by the scheduled suspend, so the 
scheduled resume or start leaves it stopped
+        policy.onJobExecute(ScheduledRoutePolicyConstants.Action.RESUME, 
route);
+        assertTrue(ServiceHelper.isStopped(consumer), "the scheduled resume 
started a consumer it did not stop");
+        policy.onJobExecute(ScheduledRoutePolicyConstants.Action.START, route);
+        assertTrue(ServiceHelper.isStopped(consumer), "the scheduled start 
started a consumer it did not stop");
+    }
+
+    @Test
+    void routeSuspendedAndResumedAfterTheScheduledSuspendKeepsNoRecordOfIt() 
throws Exception {
+        Route route = startRouteWithActionsRunByTheTest();
+        ScheduledRoutePolicy policy = scheduledRoutePolicy(route);
+        Consumer consumer = route.getConsumer();
+
+        policy.onJobExecute(ScheduledRoutePolicyConstants.Action.SUSPEND, 
route);
+        assertTrue(ServiceHelper.isStopped(consumer), "consumer should be 
stopped");
+
+        // the route controller suspends and resumes the route: its consumer 
is not Suspendable, so the route does not
+        // support suspension, and the route controller stops and starts the 
route instead
+        context.getRouteController().suspendRoute("test");
+        context.getRouteController().resumeRoute("test");
+        assertEquals(ServiceStatus.Started, 
context.getRouteController().getRouteStatus("test"));
+        route = context.getRoute("test");
+        Consumer started = route.getConsumer();
+        assertTrue(ServiceHelper.isStarted(started), "consumer should be 
started by the route controller");
+
+        // something else stops the consumer: the scheduled suspend before the 
resume of the route no longer counts
+        ServiceHelper.stopService(started);
+        policy.onJobExecute(ScheduledRoutePolicyConstants.Action.RESUME, 
route);
+        assertTrue(ServiceHelper.isStopped(started), "the scheduled resume 
started a consumer it did not stop");
+    }
+
+    /**
+     * Starts a route with a non-Suspendable consumer and a scheduled route 
policy whose triggers never fire: the test
+     * runs the actions as the scheduler would.
+     */
+    private Route startRouteWithActionsRunByTheTest() throws Exception {
+        context.addEndpoint("listener:in", new ListenerEndpoint());
+        context.addRoutes(new RouteBuilder() {
+            public void configure() {
+                CronScheduledRoutePolicy policy = new 
CronScheduledRoutePolicy();
+                policy.setRouteSuspendTime("0 0 0 1 1 ? 2099");
+                policy.setRouteResumeTime("0 0 0 1 1 ? 2099");
+                policy.setRouteStartTime("0 0 0 1 1 ? 2099");
+
+                
from("listener:in").routeId("test").routePolicy(policy).to("mock:result");
+            }
+        });
+        context.start();
+        return context.getRoute("test");
+    }
+
+    @Test
+    void scheduledResumeOrStartDoesNotStartTheConsumerWhileCamelStops() throws 
Exception {
+        List<String> startedDuringShutdown = 
runScheduledResumeAndStartWhileStopping(false);
+
+        assertEquals(List.of(), startedDuringShutdown, "the consumer was 
started again while Camel stopped");
+    }
+
+    @Test
+    void scheduledResumeOrStartDoesNotStartTheConsumerWhileTheRouteStops() 
throws Exception {
+        // the route status stays Started while the shutdown strategy waits 
for the in-flight exchanges
+        List<String> startedDuringShutdown = 
runScheduledResumeAndStartWhileStopping(true);
+
+        assertEquals(List.of(), startedDuringShutdown, "the consumer was 
started again while the route stopped");
+        assertEquals(ServiceStatus.Stopped, 
context.getRouteController().getRouteStatus("test"));
+    }
+
+    /**
+     * Stops the route, or Camel, and runs the scheduled resume and start 
after the shutdown strategy stopped the
+     * consumer, as a trigger that fires during the shutdown would.
+     *
+     * @return the actions after which the consumer was started
+     */
+    private List<String> runScheduledResumeAndStartWhileStopping(boolean 
routeOnly) throws Exception {
+        context.addEndpoint("listener:in", new ListenerEndpoint());
+        List<String> startedDuringShutdown = new CopyOnWriteArrayList<>();
+        // the shutdown stops the consumer of the started route first (it is 
not Suspendable), and stops the route
+        // later: run the scheduled actions in between, as a trigger that 
fires during the shutdown would
+        context.setShutdownStrategy(new DefaultShutdownStrategy(context) {
+            @Override
+            protected void shutdownNow(String routeId, Consumer consumer) {
+                super.shutdownNow(routeId, consumer);
+                Route route = context.getRoute(routeId);
+                for (ScheduledRoutePolicyConstants.Action action : List.of(
+                        ScheduledRoutePolicyConstants.Action.RESUME, 
ScheduledRoutePolicyConstants.Action.START)) {
+                    try {
+                        scheduledRoutePolicy(route).onJobExecute(action, 
route);
+                    } catch (Exception e) {
+                        throw new IllegalStateException(e);
+                    }
+                    if (ServiceHelper.isStarted(consumer)) {
+                        startedDuringShutdown.add(action.name());
+                    }
+                }
+            }
+        });
+        context.addRoutes(new RouteBuilder() {
+            public void configure() {
+                CronScheduledRoutePolicy policy = new 
CronScheduledRoutePolicy();
+                // never fires by itself, the actions are run by the shutdown 
strategy above
+                policy.setRouteResumeTime("0 0 0 1 1 ? 2099");
+                policy.setRouteStartTime("0 0 0 1 1 ? 2099");
+
+                
from("listener:in").routeId("test").routePolicy(policy).to("mock:result");
+            }
+        });
+        context.start();
+        Consumer consumer = context.getRoute("test").getConsumer();
+        assertTrue(ServiceHelper.isStarted(consumer));
+
+        if (routeOnly) {
+            context.getRouteController().stopRoute("test");
+        } else {
+            context.stop();
+        }
+
+        assertTrue(ServiceHelper.isStopped(consumer), "consumer should be 
stopped");
+        return startedDuringShutdown;
+    }
+
+    private static ScheduledRoutePolicy scheduledRoutePolicy(Route route) {
+        for (RoutePolicy policy : route.getRoutePolicyList()) {
+            if (policy instanceof ScheduledRoutePolicy srp) {
+                return srp;
+            }
+        }
+        throw new IllegalStateException("No scheduled route policy on route " 
+ route.getId());
+    }
+
+    /**
+     * An endpoint whose consumer is not {@link Suspendable}, like the 
consumers of the netty, mina, mllp or paho
+     * components.
+     */
+    private static final class ListenerEndpoint extends DefaultEndpoint {
+
+        ListenerEndpoint() {
+            super("listener:in", null);
+        }
+
+        @Override
+        public Producer createProducer() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Consumer createConsumer(Processor processor) throws Exception {
+            Consumer answer = new DefaultConsumer(this, processor) {
+            };
+            configureConsumer(answer);
+            return answer;
+        }
+    }
+}

Reply via email to