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 438dd1b1f922 CAMEL-25503: camel-reactive-streams - Make the consumer 
Suspendable
438dd1b1f922 is described below

commit 438dd1b1f922ed1c81ca2da2c3d11ac29388af9e
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 10 12:20:07 2026 +0530

    CAMEL-25503: camel-reactive-streams - Make the consumer Suspendable
    
    Suspending a route with a reactive-streams consumer stopped the consumer,
    which waited for its queued items to drain, and a route policy that
    suspended it from one of its own exchanges made those items fail. The
    consumer is now Suspendable: a suspend requests no more items from the
    publisher and keeps the items already received queued, and routes them in
    order when the consumer is resumed or stopped. A request sent with
    toStream to a suspended route now waits for the resume instead of failing.
    
    The consumer is also ShutdownAware, so a graceful shutdown (stopping the
    route, all routes or the CamelContext) still routes the queued items, and
    waits for them within the shutdown timeout, before it stops the downstream
    direct or seda routes they go to. No more items are requested while
    draining.
    
    Closes #27636
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../catalog/docs/reactive-streams-component.adoc   |  16 ++
 .../src/main/docs/reactive-streams-component.adoc  |  16 ++
 .../streams/ReactiveStreamsCamelSubscriber.java    |   4 +-
 .../reactive/streams/ReactiveStreamsConsumer.java  | 192 +++++++++++++++---
 .../streams/ConsumerGracefulShutdownTest.java      | 135 +++++++++++++
 .../reactive/streams/ConsumerStopTest.java         |   4 +-
 .../reactive/streams/ConsumerSuspendTest.java      | 216 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  11 ++
 8 files changed, 570 insertions(+), 24 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/reactive-streams-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/reactive-streams-component.adoc
index 8ce07c72b29d..4d78cf3dea88 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/reactive-streams-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/reactive-streams-component.adoc
@@ -591,3 +591,19 @@ is always lower than `10`. Messages are processed by a 
single thread in the Came
 The number of concurrent consumers (threads) can also be set as endpoint 
option (`concurrentConsumers`).
 When using 1 consumer (the default), the order of items in the source stream 
is maintained.
 When this value is increased, items will be processed concurrently by multiple 
threads (so not preserving the order).
+
+==== Suspending the consumer
+
+The consumer can be suspended: by suspending its route (route controller, 
JMX), by a route policy such as
+`ThrottlingInflightRoutePolicy`, and during a graceful shutdown. A suspended 
consumer requests no more items from the
+publisher and does not route the items it has already received (the items 
queued for its threads, and the items the
+publisher still sends for the demand requested before the suspend). The 
exchanges being routed complete normally, and
+the suspend does not wait for the queued items: they are routed in order when 
the consumer is resumed, or when it is
+stopped.
+
+A graceful shutdown (stopping the route, all the routes or the CamelContext) 
also suspends the consumer first, but then
+routes its queued items, and waits for them up to the shutdown timeout, before 
it stops the routes the items go to, such
+as a `direct` or `seda` route. No more items are requested from the publisher.
+
+When backpressure is disabled (`maxInflightExchanges` not positive), a 
suspended consumer cannot limit what the
+publisher sends: the items it receives while suspended are kept in memory 
until it is resumed or stopped.
diff --git 
a/components/camel-reactive-streams/src/main/docs/reactive-streams-component.adoc
 
b/components/camel-reactive-streams/src/main/docs/reactive-streams-component.adoc
index 8ce07c72b29d..4d78cf3dea88 100644
--- 
a/components/camel-reactive-streams/src/main/docs/reactive-streams-component.adoc
+++ 
b/components/camel-reactive-streams/src/main/docs/reactive-streams-component.adoc
@@ -591,3 +591,19 @@ is always lower than `10`. Messages are processed by a 
single thread in the Came
 The number of concurrent consumers (threads) can also be set as endpoint 
option (`concurrentConsumers`).
 When using 1 consumer (the default), the order of items in the source stream 
is maintained.
 When this value is increased, items will be processed concurrently by multiple 
threads (so not preserving the order).
+
+==== Suspending the consumer
+
+The consumer can be suspended: by suspending its route (route controller, 
JMX), by a route policy such as
+`ThrottlingInflightRoutePolicy`, and during a graceful shutdown. A suspended 
consumer requests no more items from the
+publisher and does not route the items it has already received (the items 
queued for its threads, and the items the
+publisher still sends for the demand requested before the suspend). The 
exchanges being routed complete normally, and
+the suspend does not wait for the queued items: they are routed in order when 
the consumer is resumed, or when it is
+stopped.
+
+A graceful shutdown (stopping the route, all the routes or the CamelContext) 
also suspends the consumer first, but then
+routes its queued items, and waits for them up to the shutdown timeout, before 
it stops the routes the items go to, such
+as a `direct` or `seda` route. No more items are requested from the publisher.
+
+When backpressure is disabled (`maxInflightExchanges` not positive), a 
suspended consumer cannot limit what the
+publisher sends: the items it receives while suspended are kept in memory 
until it is resumed or stopped.
diff --git 
a/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsCamelSubscriber.java
 
b/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsCamelSubscriber.java
index 444fb42ef675..c2ae81ea2afb 100644
--- 
a/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsCamelSubscriber.java
+++ 
b/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsCamelSubscriber.java
@@ -154,7 +154,9 @@ public class ReactiveStreamsCamelSubscriber implements 
Subscriber<Exchange>, Clo
         Subscription subs = null;
         lock.lock();
         try {
-            if (consumer != null && this.subscription != null) {
+            // a suspended consumer requests nothing until it is resumed, nor 
does a consumer drained by a shutdown
+            if (consumer != null && this.subscription != null && 
!consumer.isSuspendingOrSuspended()
+                    && !consumer.isDraining()) {
                 Integer consMax = 
consumer.getEndpoint().getMaxInflightExchanges();
                 long max = (consMax != null && consMax > 0) ? 
consMax.longValue() : UNBOUNDED_REQUESTS;
                 if (requested < UNBOUNDED_REQUESTS) {
diff --git 
a/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsConsumer.java
 
b/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsConsumer.java
index f91c68e2ed09..86e9f052ba9f 100644
--- 
a/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsConsumer.java
+++ 
b/components/camel-reactive-streams/src/main/java/org/apache/camel/component/reactive/streams/ReactiveStreamsConsumer.java
@@ -16,13 +16,19 @@
  */
 package org.apache.camel.component.reactive.streams;
 
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.Exchange;
 import org.apache.camel.Processor;
+import org.apache.camel.ShutdownRunningTask;
+import org.apache.camel.Suspendable;
 import 
org.apache.camel.component.reactive.streams.api.CamelReactiveStreamsService;
+import org.apache.camel.spi.ShutdownAware;
 import org.apache.camel.support.DefaultConsumer;
 import org.apache.camel.util.ObjectHelper;
 import org.slf4j.Logger;
@@ -30,8 +36,17 @@ import org.slf4j.LoggerFactory;
 
 /**
  * The Camel reactive-streams consumer.
+ * <p/>
+ * The items received from the stream are queued and routed by the threads of 
the consumer. A suspended consumer
+ * requests no more items from the stream and routes none of the queued ones 
(nor the items the publisher still sends
+ * for the demand requested before the suspend): they stay queued until the 
consumer is resumed or stopped. The
+ * exchanges being routed when the consumer is suspended complete normally, 
and the suspend does not wait for them.
+ * <p/>
+ * A graceful shutdown also suspends the consumer first, but then routes the 
queued items while the other routes are
+ * still running (see {@link #getPendingExchangesSize(boolean)}), so that they 
can reach a downstream route such as a
+ * direct or seda one, and the shutdown strategy waits for them within its 
timeout.
  */
-public class ReactiveStreamsConsumer extends DefaultConsumer {
+public class ReactiveStreamsConsumer extends DefaultConsumer implements 
Suspendable, ShutdownAware {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(ReactiveStreamsConsumer.class);
 
@@ -43,7 +58,22 @@ public class ReactiveStreamsConsumer extends DefaultConsumer 
{
 
     private final ReactiveStreamsEndpoint endpoint;
     private final CamelReactiveStreamsService service;
+    /**
+     * The items received from the stream that are not routed yet, in the 
order they were received. Each one has a task
+     * in the thread pool that routes the oldest queued item, unless the 
consumer is suspended.
+     */
+    private final Queue<QueuedItem> queued = new ConcurrentLinkedQueue<>();
+    /**
+     * The number of items taken from {@link #queued} whose exchange is not 
done yet.
+     */
+    private final AtomicInteger routing = new AtomicInteger();
+    /**
+     * Whether a graceful shutdown is draining the queued items: they are 
routed even though the consumer is suspended,
+     * while no more items are requested from the stream.
+     */
+    private volatile boolean draining;
     private ExecutorService executor;
+    private ReactiveStreamsCamelSubscriber subscriber;
 
     public ReactiveStreamsConsumer(ReactiveStreamsEndpoint endpoint, Processor 
processor, CamelReactiveStreamsService service) {
         super(endpoint, processor);
@@ -54,6 +84,7 @@ public class ReactiveStreamsConsumer extends DefaultConsumer {
     @Override
     protected void doStart() throws Exception {
         super.doStart();
+        draining = false;
 
         int poolSize = endpoint.getConcurrentConsumers();
         if (executor == null) {
@@ -61,17 +92,48 @@ public class ReactiveStreamsConsumer extends 
DefaultConsumer {
                     getEndpoint().getEndpointUri(), poolSize);
         }
 
-        this.service.attachCamelConsumer(endpoint.getStream(), this);
+        // the items left queued by a stop that timed out are routed now
+        scheduleQueuedItems();
+
+        this.subscriber = 
this.service.attachCamelConsumer(endpoint.getStream(), this);
+    }
+
+    @Override
+    protected void doSuspend() throws Exception {
+        // nothing to wait for: while the consumer is suspended, its 
subscriber requests no more items from the stream
+        // and the queued items are not routed (see routeQueuedItem); the 
exchanges being routed complete normally
+    }
+
+    @Override
+    protected void doResume() throws Exception {
+        draining = false;
+        if (executor == null) {
+            // suspended while it was not started (before its start or after a 
stop). Call doStart() directly, not
+            // start(): resume() has already set the status to STARTING, so 
start() would return without starting
+            // the consumer. resume() sets the status to STARTED once this 
returns.
+            doStart();
+            return;
+        }
+
+        // route the items queued while suspended, then request more items 
from the stream
+        scheduleQueuedItems();
+        ReactiveStreamsCamelSubscriber current = this.subscriber;
+        if (current != null) {
+            current.refill();
+        }
     }
 
     @Override
     protected void doStop() throws Exception {
         this.service.detachCamelConsumer(endpoint.getStream());
+        this.subscriber = null;
 
         if (executor != null) {
             // the queued exchanges were already taken from the stream (they 
cannot be requested again),
             // so let them complete before the processor is stopped; dropping 
them would also leave
             // them counted as inflight by the subscriber, which would then 
request less or nothing
+            // (the tasks of the items queued while the consumer was suspended 
did not route them)
+            scheduleQueuedItems();
             if (ROUTING.get() == this) {
                 // stopped by an exchange of this consumer (for example a 
route policy): the pool cannot be
                 // awaited from one of its own threads, the queued exchanges 
run after this one
@@ -81,10 +143,62 @@ public class ReactiveStreamsConsumer extends 
DefaultConsumer {
             }
             executor = null;
         }
+        draining = false;
 
         super.doStop();
     }
 
+    @Override
+    public boolean deferShutdown(ShutdownRunningTask shutdownRunningTask) {
+        // the shutdown strategy suspends this consumer in its first pass, and 
waits for the queued items to be routed
+        // (see getPendingExchangesSize) while the routes they go to are still 
running
+        return false;
+    }
+
+    @Override
+    public int getPendingExchangesSize() {
+        return executor != null ? queued.size() + routing.get() : 0;
+    }
+
+    /**
+     * Returns the number of items the shutdown strategy must wait for.
+     * <p/>
+     * When the route is being shutdown, this starts to route the queued 
items, even though the consumer is suspended,
+     * and returns the number of items queued or being routed: the strategy 
waits for them (up to its timeout) before it
+     * stops the deferred consumers, such as the direct or seda consumers of 
the routes the items go to. No more items
+     * are requested from the stream. When the route is only being suspended, 
the queued items are kept until the
+     * consumer is resumed, so there is nothing to wait for.
+     */
+    @Override
+    public int getPendingExchangesSize(boolean suspendOnly) {
+        if (suspendOnly) {
+            return 0;
+        }
+        startDraining();
+        return getPendingExchangesSize();
+    }
+
+    @Override
+    public void prepareShutdown(boolean suspendOnly, boolean forced) {
+        if (!suspendOnly && !forced) {
+            // the queued items are routed before the consumer is stopped (a 
no-op when the shutdown strategy already
+            // started it while it waited for the pending exchanges)
+            startDraining();
+        }
+    }
+
+    boolean isDraining() {
+        return draining;
+    }
+
+    private void startDraining() {
+        if (!draining && executor != null) {
+            draining = true;
+            // the tasks of the items queued while the consumer was suspended 
did not route them
+            scheduleQueuedItems();
+        }
+    }
+
     public boolean process(Exchange exchange, AsyncCallback callback) {
         
exchange.getIn().setHeader(ReactiveStreamsConstants.REACTIVE_STREAMS_EVENT_TYPE,
 "onNext");
         return doSend(exchange, callback);
@@ -115,26 +229,8 @@ public class ReactiveStreamsConsumer extends 
DefaultConsumer {
         ExecutorService executorService = this.executor;
         if (executorService != null && this.isRunAllowed()) {
 
-            executorService.execute(() -> {
-                ROUTING.set(this);
-                try {
-                    this.getAsyncProcessor().process(exchange, doneSync -> {
-                        Exception cause = exchange.getException();
-                        if (cause instanceof RejectedExecutionException && 
!isRunAllowed()) {
-                            // an item queued when the consumer was stopped 
from one of its own exchanges: one line
-                            // per item, as there can be up to 
maxInflightExchanges of them
-                            LOG.warn("Item {} of stream {} not routed as the 
consumer is stopped",
-                                    exchange.getExchangeId(), 
endpoint.getStream());
-                        } else if (cause != null) {
-                            getExceptionHandler().handleException("Error 
processing exchange", exchange, cause);
-                        }
-
-                        callback.done(doneSync);
-                    });
-                } finally {
-                    ROUTING.remove();
-                }
-            });
+            queued.add(new QueuedItem(exchange, callback));
+            executorService.execute(this::routeQueuedItem);
             return false;
 
         } else {
@@ -144,9 +240,61 @@ public class ReactiveStreamsConsumer extends 
DefaultConsumer {
         }
     }
 
+    /**
+     * Adds a task per queued item to the thread pool: the tasks of the items 
queued while the consumer was suspended
+     * did not route them. A task that finds no item left does nothing.
+     */
+    private void scheduleQueuedItems() {
+        ExecutorService executorService = this.executor;
+        if (executorService == null) {
+            return;
+        }
+        for (int i = queued.size(); i > 0; i--) {
+            executorService.execute(this::routeQueuedItem);
+        }
+    }
+
+    private void routeQueuedItem() {
+        if (isSuspendingOrSuspended() && !draining) {
+            // the item stays queued until the consumer is resumed, stopped or 
drained by a graceful shutdown
+            return;
+        }
+        // counted before it is taken from the queue, so that the shutdown 
strategy always sees it as pending
+        routing.incrementAndGet();
+        QueuedItem item = queued.poll();
+        if (item == null) {
+            routing.decrementAndGet();
+            return;
+        }
+
+        Exchange exchange = item.exchange();
+        ROUTING.set(this);
+        try {
+            this.getAsyncProcessor().process(exchange, doneSync -> {
+                Exception cause = exchange.getException();
+                if (cause instanceof RejectedExecutionException && 
!isRunAllowed()) {
+                    // an item queued when the consumer was stopped from one 
of its own exchanges: one line
+                    // per item, as there can be up to maxInflightExchanges of 
them
+                    LOG.warn("Item {} of stream {} not routed as the consumer 
is stopped",
+                            exchange.getExchangeId(), endpoint.getStream());
+                } else if (cause != null) {
+                    getExceptionHandler().handleException("Error processing 
exchange", exchange, cause);
+                }
+
+                routing.decrementAndGet();
+                item.callback().done(doneSync);
+            });
+        } finally {
+            ROUTING.remove();
+        }
+    }
+
     @Override
     public ReactiveStreamsEndpoint getEndpoint() {
         return endpoint;
     }
 
+    private record QueuedItem(Exchange exchange, AsyncCallback callback) {
+    }
+
 }
diff --git 
a/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerGracefulShutdownTest.java
 
b/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerGracefulShutdownTest.java
new file mode 100644
index 000000000000..2e0a7c03e8a7
--- /dev/null
+++ 
b/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerGracefulShutdownTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.reactive.streams;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.IntStream;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.reactive.streams.api.CamelReactiveStreams;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A graceful shutdown of several routes suspends the consumer first, and must 
route the items it has queued while the
+ * downstream direct consumer is still running: the routes are shutdown in 
reverse startup order, so the direct consumer
+ * is stopped before the reactive-streams one.
+ */
+class ConsumerGracefulShutdownTest extends BaseReactiveTest {
+
+    private final CountDownLatch gateReached = new CountDownLatch(1);
+    private final CountDownLatch gate = new CountDownLatch(1);
+    private final CountDownLatch blockSecond = new CountDownLatch(1);
+    private final List<Integer> received = new CopyOnWriteArrayList<>();
+    private volatile boolean blockSecondItem;
+
+    @AfterEach
+    void openGates() {
+        gate.countDown();
+        blockSecond.countDown();
+    }
+
+    @Test
+    void testContextStopRoutesTheQueuedItemsToTheDirectRoute() throws 
Exception {
+        startWithQueuedItems();
+
+        stopContextWhileTheFirstItemWaits();
+
+        assertEquals(IntStream.rangeClosed(1, 10).boxed().toList(), received,
+                "The items queued in the consumer must reach the direct route 
before it is stopped");
+    }
+
+    @Test
+    void testShutdownTimeoutAppliesToTheQueuedItems() throws Exception {
+        // the second item blocks in the direct route: the shutdown strategy 
waits for the queued items up to its
+        // timeout, then forces the routes to stop
+        context.getShutdownStrategy().setTimeout(2);
+        context.getExecutorServiceManager().setShutdownAwaitTermination(1000);
+        blockSecondItem = true;
+        startWithQueuedItems();
+
+        stopContextWhileTheFirstItemWaits();
+
+        assertTrue(context.getShutdownStrategy().hasTimeoutOccurred(),
+                "The shutdown strategy must wait for the queued items until 
its timeout");
+        assertTrue(received.contains(2), "The queued items must be routed 
during the graceful shutdown");
+    }
+
+    private void startWithQueuedItems() {
+        Flux.range(1, 
1000).subscribe(CamelReactiveStreams.get(context).streamSubscriber("in", 
Integer.class));
+
+        // the publisher sent the 10 requested numbers: the first one waits in 
the direct route, the others are queued
+        assertTrue(awaitGate(), "The first item must reach the direct route");
+        ReactiveStreamsCamelSubscriber subscriber
+                = (ReactiveStreamsCamelSubscriber) 
CamelReactiveStreams.get(context).streamSubscriber("in");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
subscriber.getInflightCount() == 10);
+    }
+
+    private boolean awaitGate() {
+        try {
+            return gateReached.await(10, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+        }
+    }
+
+    private void stopContextWhileTheFirstItemWaits() throws Exception {
+        CompletableFuture<Void> stop = 
CompletableFuture.runAsync(context::stop);
+        ServiceSupport consumer = (ServiceSupport) 
context.getRoute("rs").getConsumer();
+        // the graceful shutdown suspends the consumer first
+        await().atMost(10, TimeUnit.SECONDS).until(consumer::isSuspended);
+        gate.countDown();
+        stop.get(30, TimeUnit.SECONDS);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // defined (and started) before the direct route, so it is 
shutdown after it
+                
from("reactive-streams:in?maxInflightExchanges=10").routeId("rs")
+                        .to("direct:sub");
+
+                from("direct:sub").routeId("sub")
+                        .process(e -> {
+                            int item = e.getMessage().getBody(Integer.class);
+                            if (item == 1) {
+                                gateReached.countDown();
+                                gate.await();
+                            } else if (item == 2 && blockSecondItem) {
+                                received.add(item);
+                                blockSecond.await();
+                                return;
+                            }
+                            received.add(item);
+                        });
+            }
+        };
+    }
+}
diff --git 
a/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerStopTest.java
 
b/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerStopTest.java
index ce42028f8e13..b57277df57d6 100644
--- 
a/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerStopTest.java
+++ 
b/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerStopTest.java
@@ -104,7 +104,9 @@ class ConsumerStopTest extends BaseReactiveTest {
             }
         });
         ServiceSupport consumer = (ServiceSupport) 
context.getRoute(routeId).getConsumer();
-        await().atMost(10, TimeUnit.SECONDS).until(() -> consumer.isStopping() 
|| consumer.isStopped());
+        // the graceful stop suspends the consumer first, and stops it once 
the exchange in flight is done
+        await().atMost(10, TimeUnit.SECONDS)
+                .until(() -> consumer.isSuspended() || consumer.isStopping() 
|| consumer.isStopped());
         gate.countDown();
         stop.get(20, TimeUnit.SECONDS);
     }
diff --git 
a/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerSuspendTest.java
 
b/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerSuspendTest.java
new file mode 100644
index 000000000000..28b2c5a31fc1
--- /dev/null
+++ 
b/components/camel-reactive-streams/src/test/java/org/apache/camel/component/reactive/streams/ConsumerSuspendTest.java
@@ -0,0 +1,216 @@
+/*
+ * 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.reactive.streams;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.IntStream;
+
+import org.apache.camel.Consumer;
+import org.apache.camel.Exchange;
+import org.apache.camel.Route;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.reactive.streams.api.CamelReactiveStreams;
+import org.apache.camel.support.RoutePolicySupport;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+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;
+
+/**
+ * A suspended consumer (suspended route, route policies, graceful shutdown) 
routes no new items and does not wait for
+ * the items it already took from the stream; they are routed when it is 
resumed or stopped.
+ */
+class ConsumerSuspendTest extends BaseReactiveTest {
+
+    private final CountDownLatch gateReached = new CountDownLatch(1);
+    private final CountDownLatch gate = new CountDownLatch(1);
+    private final CountDownLatch policySuspended = new CountDownLatch(1);
+
+    private final RoutePolicySupport suspendingPolicy = new 
RoutePolicySupport() {
+        @Override
+        public void onExchangeDone(Route route, Exchange exchange) {
+            // on the thread of the consumer, as ThrottlingInflightRoutePolicy 
does
+            if (exchange.getMessage().getBody(Integer.class) == 3) {
+                try {
+                    suspendOrStopConsumer(route.getConsumer());
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+                policySuspended.countDown();
+            }
+        }
+    };
+
+    @AfterEach
+    void openGate() {
+        // a suspend that waits for the exchange behind the gate must not 
block the context stop
+        gate.countDown();
+    }
+
+    @Test
+    void testSuspendDoesNotWaitForTheQueuedExchanges() throws Exception {
+        // a suspend that drained the queued exchanges would wait for the gate 
up to this timeout
+        context.getExecutorServiceManager().setShutdownAwaitTermination(60000);
+        Flux.range(1, 
1000).subscribe(CamelReactiveStreams.get(context).streamSubscriber("queued", 
Integer.class));
+        // the publisher sent the 10 requested numbers: the first one waits in 
the route, the others are queued
+        assertTrue(gateReached.await(10, TimeUnit.SECONDS));
+        ReactiveStreamsCamelSubscriber subscriber
+                = (ReactiveStreamsCamelSubscriber) 
CamelReactiveStreams.get(context).streamSubscriber("queued");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
subscriber.getInflightCount() == 10);
+
+        Consumer consumer = context.getRoute("queued").getConsumer();
+        CompletableFuture.runAsync(() -> 
ServiceHelper.suspendService(consumer)).get(10, TimeUnit.SECONDS);
+        assertTrue(((ServiceSupport) consumer).isSuspended(), "The consumer 
must be suspended");
+
+        // the exchange in flight completes, the queued ones are not routed 
while the consumer is suspended
+        MockEndpoint mock = getMockEndpoint("mock:queued");
+        mock.expectedBodiesReceived(1);
+        mock.setAssertPeriod(500);
+        gate.countDown();
+        mock.assertIsSatisfied();
+        // and no more items are requested from the stream (the refill 
watermark would request one per exchange done)
+        assertEquals(9, subscriber.getInflightCount(), "The queued items must 
be held");
+        assertEquals(0, subscriber.getRequested(), "A suspended consumer must 
not request items");
+
+        // a stop routes the items the suspended consumer took from the stream
+        mock.reset();
+        mock.expectedBodiesReceived(IntStream.rangeClosed(2, 
10).boxed().toList());
+        context.getRouteController().stopRoute("queued");
+        mock.assertIsSatisfied();
+        assertEquals(0, subscriber.getInflightCount());
+    }
+
+    @Test
+    void testSuspendAndResumeRoute() throws Exception {
+        Sinks.Many<Integer> sink = 
Sinks.many().unicast().onBackpressureBuffer();
+        
sink.asFlux().subscribe(CamelReactiveStreams.get(context).streamSubscriber("controlled",
 Integer.class));
+        MockEndpoint mock = getMockEndpoint("mock:controlled");
+        mock.expectedBodiesReceived(1, 2, 3, 4, 5);
+        IntStream.rangeClosed(1, 5).forEach(sink::tryEmitNext);
+        mock.assertIsSatisfied();
+
+        context.getRouteController().suspendRoute("controlled");
+        assertEquals(ServiceStatus.Suspended, 
context.getRouteController().getRouteStatus("controlled"));
+        assertTrue(((ServiceSupport) 
context.getRoute("controlled").getConsumer()).isSuspended(),
+                "The consumer must be suspended");
+
+        // the items sent for the outstanding demand are held, the others stay 
with the publisher
+        mock.reset();
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(500);
+        IntStream.rangeClosed(6, 30).forEach(sink::tryEmitNext);
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.setAssertPeriod(0);
+        mock.expectedBodiesReceived(IntStream.rangeClosed(6, 
30).boxed().toList());
+        context.getRouteController().resumeRoute("controlled");
+        assertEquals(ServiceStatus.Started, 
context.getRouteController().getRouteStatus("controlled"));
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testRequestToASuspendedRouteWaitsForTheResume() throws Exception {
+        context.getRouteController().suspendRoute("controlled");
+        MockEndpoint mock = getMockEndpoint("mock:controlled");
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(500);
+
+        CompletableFuture<Exchange> reply = 
Mono.from(CamelReactiveStreams.get(context).toStream("controlled", 
7)).toFuture();
+        mock.assertIsSatisfied();
+        assertFalse(reply.isDone(), "A suspended route must not process the 
request");
+
+        mock.reset();
+        mock.setAssertPeriod(0);
+        mock.expectedBodiesReceived(7);
+        context.getRouteController().resumeRoute("controlled");
+        mock.assertIsSatisfied();
+        assertEquals(7, reply.get(10, 
TimeUnit.SECONDS).getMessage().getBody(Integer.class));
+    }
+
+    @Test
+    void testRoutePolicySuspendsAndResumesTheConsumer() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:policy");
+        mock.expectedBodiesReceived(1, 2, 3);
+        mock.setAssertPeriod(500);
+        Flux.range(1, 
50).subscribe(CamelReactiveStreams.get(context).streamSubscriber("policy", 
Integer.class));
+        assertTrue(policySuspended.await(10, TimeUnit.SECONDS), "The route 
policy must suspend the consumer");
+        Consumer consumer = context.getRoute("policy").getConsumer();
+        assertTrue(((ServiceSupport) consumer).isSuspended(), "The consumer 
must be suspended");
+        mock.assertIsSatisfied();
+
+        // the items taken from the stream before the suspend are routed after 
the resume, in order
+        mock.reset();
+        mock.setAssertPeriod(0);
+        mock.expectedBodiesReceived(IntStream.rangeClosed(4, 
50).boxed().toList());
+        suspendingPolicy.resumeOrStartConsumer(consumer);
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void testResumeStartsAConsumerThatWasNotStarted() throws Exception {
+        Consumer consumer = context.getRoute("notStarted").getConsumer();
+        Flux.range(1, 
5).subscribe(CamelReactiveStreams.get(context).streamSubscriber("notStarted", 
Integer.class));
+        MockEndpoint mock = getMockEndpoint("mock:notStarted");
+        mock.expectedBodiesReceived(1, 2, 3, 4, 5);
+
+        ServiceHelper.suspendService(consumer);
+        ServiceHelper.resumeService(consumer);
+
+        mock.assertIsSatisfied();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> ((ServiceSupport) 
consumer).isStarted());
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("reactive-streams:queued?maxInflightExchanges=10&exchangesRefillLowWatermark=1").routeId("queued")
+                        .process(e -> {
+                            if (e.getMessage().getBody(Integer.class) == 1) {
+                                gateReached.countDown();
+                                gate.await();
+                            }
+                        })
+                        .to("mock:queued");
+
+                
from("reactive-streams:controlled?maxInflightExchanges=10").routeId("controlled")
+                        .to("mock:controlled");
+
+                
from("reactive-streams:policy?maxInflightExchanges=10").routeId("policy")
+                        .routePolicy(suspendingPolicy)
+                        .to("mock:policy");
+
+                
from("reactive-streams:notStarted").routeId("notStarted").autoStartup(false)
+                        .to("mock:notStarted");
+            }
+        };
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index ff5a398357c5..bd76cca82786 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -1119,6 +1119,17 @@ these items were dropped, and after a restart the 
consumer could stop requesting
 stops itself from one of its own exchanges now waits for that timeout; stop it 
asynchronously instead (for example with
 the Control Bus `async=true` option).
 
+=== camel-reactive-streams - suspending a consumer
+
+The `reactive-streams` consumer is now suspendable: suspending its route 
(route controller, JMX) and a route policy
+such as `ThrottlingInflightRoutePolicy` suspend it instead of stopping it, and 
a graceful shutdown suspends it before
+stopping it. It then requests no more items and holds the items it has already 
received, without waiting for them, and
+routes them in order when it is resumed (or stopped). Before, the consumer was 
stopped (the route status was `Stopped` instead of `Suspended`), which
+waited for those items, and a route policy that suspended it from one of its 
exchanges made them fail. A request sent
+with `toStream` to a suspended route now waits for the resume instead of 
failing.
+A graceful shutdown still routes the queued items before it stops the other 
routes, such as a `direct` route they go
+to.
+
 === camel-vertx - request/reply over the event bus
 
 When a `vertx` consumer receives a message that expects a reply and the route 
fails, the sender now gets a failure

Reply via email to