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 481428816a13 CAMEL-25839: camel-kamelet - remove the route of a 
Kamelet endpoint that toD discards after use (#28260)
481428816a13 is described below

commit 481428816a13f3446ebb9052189f945fb69fd721
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Oct 23 10:54:40 2026 +0530

    CAMEL-25839: camel-kamelet - remove the route of a Kamelet endpoint that 
toD discards after use (#28260)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/component/kamelet/KameletComponent.java  | 203 ++++++++++++-
 .../camel/component/kamelet/KameletEndpoint.java   |  11 +
 .../camel/component/kamelet/KameletProducer.java   |   4 +-
 .../kamelet/KameletToDRoutesNotKeptTest.java       | 325 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  11 +
 5 files changed, 545 insertions(+), 9 deletions(-)

diff --git 
a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletComponent.java
 
b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletComponent.java
index 580b85b105b9..c871d2e3f1a5 100644
--- 
a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletComponent.java
+++ 
b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletComponent.java
@@ -26,15 +26,22 @@ import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.locks.Condition;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 
+import org.apache.camel.AsyncCallback;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
 import org.apache.camel.Processor;
+import org.apache.camel.Route;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.ServiceStatus;
 import org.apache.camel.VetoCamelContextStartException;
@@ -46,6 +53,7 @@ import org.apache.camel.spi.annotations.Component;
 import org.apache.camel.support.DefaultComponent;
 import org.apache.camel.support.LifecycleStrategySupport;
 import org.apache.camel.support.RouteTemplateHelper;
+import org.apache.camel.support.SynchronizationAdapter;
 import org.apache.camel.support.service.ServiceHelper;
 import org.apache.camel.util.StopWatch;
 import org.apache.camel.util.URISupport;
@@ -71,10 +79,18 @@ public class KameletComponent extends DefaultComponent {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(KameletComponent.class);
 
+    // how long the removal of a route waits before it checks again whether 
the thread pools of the route are idle
+    private static final long ROUTE_REMOVAL_RETRY_MILLIS = 100;
+
     private final ReentrantLock lock = new ReentrantLock();
 
     private final LifecycleHandler lifecycleHandler = new LifecycleHandler();
 
+    // stops and removes the routes of Kamelet endpoints that are shut down, 
off the threads that route the messages
+    private volatile ScheduledExecutorService routeRemover;
+    // the thread pools that the processors of each route created (such as 
threads()), by route id
+    private final Map<String, List<ThreadPoolExecutor>> routeThreadPools = new 
ConcurrentHashMap<>();
+
     // active consumers
     private final Map<String, KameletConsumer> consumers = new HashMap<>();
     private final Lock consumersLock = new ReentrantLock();
@@ -137,6 +153,8 @@ public class KameletComponent extends DefaultComponent {
         final String uuid = Kamelet.extractUuid();
         final String routeId = Kamelet.extractRouteId(getCamelContext(), 
remaining, parameters, uuid);
         final String loc = Kamelet.extractLocation(getCamelContext(), 
parameters);
+        // a route id that is not given in the uri is unique to this endpoint
+        final boolean ownRouteId = routeId.equals(templateId + "-" + uuid);
 
         parameters.remove(PARAM_TEMPLATE_ID);
         parameters.remove(PARAM_ROUTE_ID);
@@ -188,6 +206,12 @@ public class KameletComponent extends DefaultComponent {
             setProperties(endpoint, parameters);
         } else {
             endpoint = new KameletEndpoint(uri, this, templateId, routeId) {
+                // the route this endpoint created from the template while the 
context runs (such as from toD), with
+                // a route id of its own, which is removed when this endpoint 
is shut down
+                private volatile Route createdRoute;
+                // the exchange whose call of this endpoint is completing on 
the thread that runs the callback
+                private volatile Exchange completing;
+
                 @Override
                 protected void doInit() throws Exception {
                     super.doInit();
@@ -197,7 +221,42 @@ public class KameletComponent extends DefaultComponent {
                     //
                     String routeId = 
getCamelContext().getCamelContextExtension().getCreateRoute();
                     String processorId = 
getCamelContext().getCamelContextExtension().getCreateProcessor();
-                    lifecycleHandler.track(this, routeId, processorId);
+                    String id = lifecycleHandler.track(this, routeId, 
processorId);
+                    if (id != null && ownRouteId) {
+                        // a route id given in the uri is shared by all the 
endpoints that use it, and each of them
+                        // replaces the route of the previous one: such a 
route is left alone
+                        createdRoute = getCamelContext().getRoute(id);
+                    }
+                }
+
+                @Override
+                AsyncCallback completingCallback(Exchange exchange, 
AsyncCallback callback) {
+                    if (createdRoute == null) {
+                        return callback;
+                    }
+                    return doneSync -> {
+                        completing = exchange;
+                        try {
+                            callback.done(doneSync);
+                        } finally {
+                            completing = null;
+                        }
+                    };
+                }
+
+                @Override
+                protected void doShutdown() throws Exception {
+                    // An endpoint used once and discarded (toD or 
recipientList with cacheSize=-1) is shut down after
+                    // use: remove the route it created, otherwise every call 
keeps one more route for ever.
+                    // The endpoint of a route that consumes from a Kamelet 
(from("kamelet:my-source")) is shut down
+                    // as well when that route is removed: when that route was 
added while the context runs, the
+                    // Kamelet's route is removed with it.
+                    Route route = createdRoute;
+                    createdRoute = null;
+                    if (route != null) {
+                        removeCreatedRoute(route, completing);
+                    }
+                    super.doShutdown();
                 }
             };
 
@@ -484,6 +543,106 @@ public class KameletComponent extends DefaultComponent {
         super.doInit();
     }
 
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+        ScheduledExecutorService executor = routeRemover;
+        if (executor == null || executor.isShutdown()) {
+            // the thread is created on first use
+            routeRemover = 
getCamelContext().getExecutorServiceManager().newSingleThreadScheduledExecutor(this,
+                    "KameletRouteRemover");
+        }
+    }
+
+    /**
+     * Removes the route a Kamelet endpoint created from its template while 
the context runs, when that endpoint is shut
+     * down (it is not shut down while the context runs unless it was used 
once and discarded, or its route consumed
+     * from the Kamelet and was removed).
+     * <p/>
+     * The route is stopped and removed on a thread of this component: 
stopping a route holds the lock of the
+     * CamelContext while the route shuts down gracefully. The calling route 
can continue on a thread that the Kamelet's
+     * route owns (threads(), an asynchronous producer), and stopping the 
route shuts that thread pool down, which
+     * interrupts that thread. So the removal waits:
+     * <ul>
+     * <li>until the exchange whose call is completing on this thread is done, 
when the endpoint is shut down inside the
+     * producer callback (toD);</li>
+     * <li>and until no thread of the thread pools that the route's processors 
created (threads(), and the other EIPs
+     * with an executor service) runs a task. This covers an endpoint that is 
shut down where the exchange of the
+     * calling route is not known: recipientList with cacheSize=-1 (the 
endpoint is shut down when the producer is
+     * released, after the call), routingSlip with cacheSize=-1 (its copy of 
the exchange has no unit of work) and
+     * pollEnrich. The route is used only by the endpoint that created it, so 
its thread pools stay idle once the
+     * calling route has left them.</li>
+     * </ul>
+     * Not covered: a thread pool that a component of the route creates for 
itself (such as the worker pool of an
+     * asynchronous Kafka producer, which waits for its running tasks when it 
stops, up to its shutdown timeout), and an
+     * executor service that is not a {@link ThreadPoolExecutor} (virtual 
threads), which the removal cannot tell idle.
+     *
+     * @param route    the route the endpoint created
+     * @param exchange the exchange whose call of the endpoint is completing 
on this thread, or {@code null}
+     */
+    private void removeCreatedRoute(Route route, Exchange exchange) {
+        if (exchange != null && exchange.getUnitOfWork() != null) {
+            exchange.getExchangeExtension().addOnCompletion(new 
SynchronizationAdapter() {
+                @Override
+                public void onDone(Exchange done) {
+                    submitRemoveRoute(route, 0);
+                }
+            });
+        } else {
+            submitRemoveRoute(route, 0);
+        }
+    }
+
+    private void submitRemoveRoute(Route route, long delayMillis) {
+        ScheduledExecutorService executor = routeRemover;
+        if (executor == null) {
+            // the context removes all the routes when it stops
+            return;
+        }
+        try {
+            executor.schedule(() -> doRemoveRoute(route), delayMillis, 
TimeUnit.MILLISECONDS);
+        } catch (RejectedExecutionException e) {
+            // the component is shut down, and the context removes all the 
routes when it stops
+            LOG.debug("Cannot remove route with id={} as the component is shut 
down", route.getRouteId());
+        }
+    }
+
+    private void doRemoveRoute(Route route) {
+        CamelContext context = getCamelContext();
+        String routeId = route.getRouteId();
+        if (!context.isStarted() || context.isStopping() || 
context.getRoute(routeId) != route) {
+            // the context removes all the routes when it stops, and a route 
that is gone or was replaced is not ours
+            return;
+        }
+        if (isThreadPoolActive(routeId)) {
+            // the calling route may still run on a thread of the route's 
thread pools: stopping the route now would
+            // shut the pool down under it
+            LOG.trace("Route with id={} still has an active thread: removing 
it later", routeId);
+            submitRemoveRoute(route, ROUTE_REMOVAL_RETRY_MILLIS);
+            return;
+        }
+        try {
+            LOG.debug("Removing route with id={} created by a Kamelet endpoint 
that is shut down", routeId);
+            context.getRouteController().stopRoute(routeId);
+            context.removeRoute(routeId);
+        } catch (Exception e) {
+            LOG.warn("Error removing route {} created by a Kamelet endpoint 
that is shut down. This exception is ignored.",
+                    routeId, e);
+        }
+    }
+
+    private boolean isThreadPoolActive(String routeId) {
+        List<ThreadPoolExecutor> pools = routeThreadPools.get(routeId);
+        if (pools != null) {
+            for (ThreadPoolExecutor pool : pools) {
+                if (pool.getActiveCount() > 0) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
     @Override
     protected void doShutdown() throws Exception {
         getCamelContext().getLifecycleStrategies().remove(lifecycleHandler);
@@ -491,6 +650,13 @@ public class KameletComponent extends DefaultComponent {
         ServiceHelper.stopAndShutdownService(consumers);
         consumers.clear();
         kameletEips.clear();
+        ScheduledExecutorService executor = routeRemover;
+        routeRemover = null;
+        if (executor != null) {
+            // the context removes all the routes when it stops, so a removal 
that has not started is not needed
+            getCamelContext().getExecutorServiceManager().shutdown(executor);
+        }
+        routeThreadPools.clear();
         super.doShutdown();
     }
 
@@ -515,18 +681,18 @@ public class KameletComponent extends DefaultComponent {
             this.initialized = new AtomicBoolean();
         }
 
-        public void createRouteForEndpoint(KameletEndpoint endpoint, String 
parentRouteId, String parentProcessorId)
+        public String createRouteForEndpoint(KameletEndpoint endpoint, String 
parentRouteId, String parentProcessorId)
                 throws Exception {
             // creating dynamic routes from kamelets should not happen 
concurrently so we use locking
             lock.lock();
             try {
-                doCreateRouteForEndpoint(endpoint, parentRouteId, 
parentProcessorId);
+                return doCreateRouteForEndpoint(endpoint, parentRouteId, 
parentProcessorId);
             } finally {
                 lock.unlock();
             }
         }
 
-        protected void doCreateRouteForEndpoint(KameletEndpoint endpoint, 
String parentRouteId, String parentProcessorId)
+        protected String doCreateRouteForEndpoint(KameletEndpoint endpoint, 
String parentRouteId, String parentProcessorId)
                 throws Exception {
             final String templateId = endpoint.getTemplateId();
             final String loc = endpoint.getLocation() != null ? 
endpoint.getLocation() : getLocation();
@@ -548,7 +714,7 @@ public class KameletComponent extends DefaultComponent {
             }
             creating.push(templateId);
             try {
-                doCreateRouteForEndpoint(endpoint, parentRouteId, 
parentProcessorId, templateId, loc);
+                return doCreateRouteForEndpoint(endpoint, parentRouteId, 
parentProcessorId, templateId, loc);
             } finally {
                 creating.pop();
                 if (creating.isEmpty()) {
@@ -557,7 +723,7 @@ public class KameletComponent extends DefaultComponent {
             }
         }
 
-        private void doCreateRouteForEndpoint(
+        private String doCreateRouteForEndpoint(
                 KameletEndpoint endpoint, String parentRouteId, String 
parentProcessorId, String templateId, String loc)
                 throws Exception {
 
@@ -584,6 +750,7 @@ public class KameletComponent extends DefaultComponent {
                 }
 
                 LOG.debug("Route with id={} created from template={}", id, 
templateId);
+                return id;
             } catch (Exception e) {
                 throw new FailedToCreateKameletException(templateId, loc, e);
             }
@@ -605,20 +772,40 @@ public class KameletComponent extends DefaultComponent {
             }
         }
 
+        @Override
+        public void onThreadPoolAdd(
+                CamelContext camelContext, ThreadPoolExecutor threadPool, 
String id, String sourceId, String routeId,
+                String threadPoolProfileId) {
+            if (routeId != null) {
+                routeThreadPools.computeIfAbsent(routeId, k -> new 
CopyOnWriteArrayList<>()).add(threadPool);
+            }
+        }
+
+        @Override
+        public void onThreadPoolRemove(CamelContext camelContext, 
ThreadPoolExecutor threadPool) {
+            routeThreadPools.values().removeIf(pools -> 
pools.remove(threadPool) && pools.isEmpty());
+        }
+
         public void setInitialized(boolean initialized) {
             this.initialized.set(initialized);
         }
 
-        public void track(KameletEndpoint endpoint, String parentRouteId, 
String parentProcessorId) {
+        /**
+         * Creates the route of the endpoint now if the context is 
initialized, or when it is.
+         *
+         * @return the id of the route created now, or {@code null} if it is 
created later
+         */
+        public String track(KameletEndpoint endpoint, String parentRouteId, 
String parentProcessorId) {
             if (this.initialized.get()) {
                 try {
-                    createRouteForEndpoint(endpoint, parentRouteId, 
parentProcessorId);
+                    return createRouteForEndpoint(endpoint, parentRouteId, 
parentProcessorId);
                 } catch (Exception e) {
                     throw RuntimeCamelException.wrapRuntimeException(e);
                 }
             } else {
                 LOG.debug("Tracking route template={} and id={}", 
endpoint.getTemplateId(), endpoint.getRouteId());
                 this.endpoints.add(new Tuple(endpoint, parentRouteId, 
parentProcessorId));
+                return null;
             }
         }
     }
diff --git 
a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletEndpoint.java
 
b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletEndpoint.java
index 879ad17ef9ca..e1cd6110630c 100644
--- 
a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletEndpoint.java
+++ 
b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletEndpoint.java
@@ -21,9 +21,11 @@ import java.util.HashMap;
 import java.util.Map;
 
 import org.apache.camel.AfterPropertiesConfigured;
+import org.apache.camel.AsyncCallback;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Category;
 import org.apache.camel.Consumer;
+import org.apache.camel.Exchange;
 import org.apache.camel.Processor;
 import org.apache.camel.Producer;
 import org.apache.camel.spi.BodyType;
@@ -201,6 +203,15 @@ public class KameletEndpoint extends DefaultEndpoint 
implements AfterPropertiesC
         }
     }
 
+    /**
+     * The callback the producer calls when the Kamelet is done with the 
exchange, and the calling route continues. The
+     * default is the given callback. An endpoint that removes the route it 
created when it is shut down uses it to know
+     * the exchange whose call is completing when it is shut down (as toD does 
inside this callback).
+     */
+    AsyncCallback completingCallback(Exchange exchange, AsyncCallback 
callback) {
+        return callback;
+    }
+
     @Override
     public Producer createProducer() throws Exception {
         return new KameletProducer(this, key);
diff --git 
a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProducer.java
 
b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProducer.java
index 3715297bc09f..ee9296a87284 100644
--- 
a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProducer.java
+++ 
b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProducer.java
@@ -55,7 +55,9 @@ final class KameletProducer extends DefaultAsyncProducer 
implements RouteIdAware
     }
 
     @Override
-    public boolean process(Exchange exchange, AsyncCallback callback) {
+    public boolean process(Exchange exchange, AsyncCallback originalCallback) {
+        // the endpoint may want to know the exchange whose call is completing 
when the calling route continues
+        final AsyncCallback callback = endpoint.completingCallback(exchange, 
originalCallback);
         try {
             final KameletConsumer consumer = getConsumer();
             if (consumer == null) {
diff --git 
a/components/camel-kamelet/src/test/java/org/apache/camel/component/kamelet/KameletToDRoutesNotKeptTest.java
 
b/components/camel-kamelet/src/test/java/org/apache/camel/component/kamelet/KameletToDRoutesNotKeptTest.java
new file mode 100644
index 000000000000..418141e9e92e
--- /dev/null
+++ 
b/components/camel-kamelet/src/test/java/org/apache/camel/component/kamelet/KameletToDRoutesNotKeptTest.java
@@ -0,0 +1,325 @@
+/*
+ * 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.kamelet;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.Route;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.awaitility.core.ConditionTimeoutException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * A Kamelet called with toD and cacheSize=-1 (the documented setting for a 
dynamic endpoint that is different for every
+ * message) gets a new endpoint for every call, which creates a route from the 
template. The endpoint is discarded after
+ * the call, so its route must be removed as well, otherwise every call keeps 
one more route.
+ */
+class KameletToDRoutesNotKeptTest extends CamelTestSupport {
+
+    private static final int MESSAGES = 50;
+
+    @Test
+    void routesOfDiscardedKameletEndpointsAreRemoved() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(MESSAGES);
+        int endpoints = context.getEndpointRegistry().size();
+
+        for (int i = 0; i < MESSAGES; i++) {
+            template.sendBodyAndHeader("direct:prototype", "body", "id", "id" 
+ i);
+        }
+        mock.assertIsSatisfied();
+        for (int i = 0; i < MESSAGES; i++) {
+            
assertThat(mock.getReceivedExchanges().get(i).getMessage().getBody()).isEqualTo("id"
 + i + "-body");
+        }
+
+        // the routes are removed on a thread of the component, once the 
exchange is done, and their endpoints
+        // (kamelet:source) with them: removeRoute takes the route out of 
getRoutes() before it removes its endpoints
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+            assertThat(kameletRoutes()).isZero();
+            
assertThat(context.getEndpointRegistry().values()).hasSize(endpoints);
+        });
+    }
+
+    @Test
+    void sameValueEveryTimeDoesNotKeepRoutes() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(MESSAGES);
+
+        for (int i = 0; i < MESSAGES; i++) {
+            template.sendBodyAndHeader("direct:prototype", "body" + i, "id", 
"same");
+        }
+        mock.assertIsSatisfied();
+        for (int i = 0; i < MESSAGES; i++) {
+            
assertThat(mock.getReceivedExchanges().get(i).getMessage().getBody()).isEqualTo("same-body"
 + i);
+        }
+
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(kameletRoutes()).isZero());
+    }
+
+    @Test
+    void cachedKameletEndpointsKeepTheirRoutes() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a-1", "b-2", "a-3", "b-4");
+
+        String[] ids = { "a", "b", "a", "b" };
+        for (int i = 0; i < ids.length; i++) {
+            template.sendBodyAndHeader("direct:cached", String.valueOf(i + 1), 
"id", ids[i]);
+        }
+        mock.assertIsSatisfied();
+
+        // the default toD keeps the endpoints and so their routes for the 
next messages
+        assertThat(kameletRoutes()).isEqualTo(2);
+    }
+
+    @Test
+    void templateWithThreadsDoesNotInterruptTheCallingRoute() throws Exception 
{
+        MockEndpoint mock = getMockEndpoint("mock:threads");
+        mock.expectedMessageCount(MESSAGES);
+
+        for (int i = 0; i < MESSAGES; i++) {
+            template.sendBodyAndHeader("direct:threads", "body", "id", "id" + 
i);
+        }
+        mock.assertIsSatisfied();
+        for (int i = 0; i < MESSAGES; i++) {
+            Exchange exchange = mock.getReceivedExchanges().get(i);
+            assertThat(exchange.getMessage().getBody()).isEqualTo("id" + i + 
"-body");
+            // the calling route continues on a thread of the pool of the 
Kamelet's route
+            assertThat(exchange.getMessage().getHeader("thread", 
String.class)).contains("Threads");
+            assertThat(exchange.getMessage().getHeader("interrupted", 
Boolean.class)).isFalse();
+            // the Kamelet's route is removed only after the exchange is done
+            assertThat(exchange.getMessage().getHeader("kameletRouteKept", 
Boolean.class)).isTrue();
+        }
+
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(kameletRoutes()).isZero());
+    }
+
+    @Test
+    void kameletRouteIsKeptWhileTheCallingRouteRunsOnItsThread() throws 
Exception {
+        MockEndpoint mock = getMockEndpoint("mock:slow");
+        mock.expectedBodiesReceived("x-body");
+
+        template.sendBodyAndHeader("direct:slow", "body", "id", "x");
+        mock.assertIsSatisfied();
+
+        Exchange exchange = mock.getReceivedExchanges().get(0);
+        assertThat(exchange.getMessage().getHeader("thread", 
String.class)).contains("Threads");
+        assertThat(exchange.getMessage().getHeader("kameletRouteKept", 
Boolean.class)).isTrue();
+        assertThat(exchange.getMessage().getHeader("interrupted", 
Boolean.class)).isFalse();
+
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(kameletRoutes()).isZero());
+    }
+
+    @Test
+    void 
kameletRouteIsKeptWhileTheCallingRouteRunsOnItsThreadAfterARecipientList() 
throws Exception {
+        // recipientList with cacheSize=-1 shuts the endpoint down when it 
releases the producer, after the call has
+        // completed: the exchange of the calling route is not known there
+        
assertKameletRouteIsKeptWhileTheCallingRouteRunsOnItsThread("direct:recipientList");
+    }
+
+    @Test
+    void 
kameletRouteIsKeptWhileTheCallingRouteRunsOnItsThreadAfterARoutingSlip() throws 
Exception {
+        // routingSlip with cacheSize=-1 shuts the endpoint down in its 
callback, with a copy of the exchange that has
+        // no unit of work
+        
assertKameletRouteIsKeptWhileTheCallingRouteRunsOnItsThread("direct:routingSlip");
+    }
+
+    private void 
assertKameletRouteIsKeptWhileTheCallingRouteRunsOnItsThread(String caller) 
throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:slow");
+        mock.expectedBodiesReceived("x-body");
+
+        template.sendBodyAndHeader(caller, "body", "id", "x");
+        mock.assertIsSatisfied();
+
+        Exchange exchange = mock.getReceivedExchanges().get(0);
+        assertThat(exchange.getMessage().getHeader("thread", 
String.class)).contains("Threads");
+        assertThat(exchange.getMessage().getHeader("kameletRouteKept", 
Boolean.class)).isTrue();
+        assertThat(exchange.getMessage().getHeader("interrupted", 
Boolean.class)).isFalse();
+
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(kameletRoutes()).isZero());
+    }
+
+    @Test
+    void sendsFromSeveralThreadsAtOnce() throws Exception {
+        int threads = 8;
+        int perThread = 25;
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(threads * perThread);
+
+        ExecutorService executor = Executors.newFixedThreadPool(threads);
+        try {
+            List<Future<Object>> replies = new ArrayList<>();
+            for (int t = 0; t < threads; t++) {
+                for (int i = 0; i < perThread; i++) {
+                    String id = "t" + t + "-" + i;
+                    replies.add(executor.submit(() -> 
template.requestBodyAndHeader("direct:prototype", "body", "id", id)));
+                }
+            }
+            List<Object> bodies = new ArrayList<>();
+            for (Future<Object> reply : replies) {
+                bodies.add(reply.get(30, TimeUnit.SECONDS));
+            }
+            List<String> expected = new ArrayList<>();
+            for (int t = 0; t < threads; t++) {
+                for (int i = 0; i < perThread; i++) {
+                    expected.add("t" + t + "-" + i + "-body");
+                }
+            }
+            assertThat(bodies).containsExactlyElementsOf(expected);
+        } finally {
+            executor.shutdownNow();
+            assertThat(executor.awaitTermination(10, 
TimeUnit.SECONDS)).isTrue();
+        }
+        mock.assertIsSatisfied();
+
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(kameletRoutes()).isZero());
+    }
+
+    @Test
+    void routeIdGivenInTheUriIsLeftAlone() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(MESSAGES);
+
+        for (int i = 0; i < MESSAGES; i++) {
+            template.sendBodyAndHeader("direct:myRoute", "body", "id", "id" + 
i);
+        }
+        mock.assertIsSatisfied();
+        for (int i = 0; i < MESSAGES; i++) {
+            
assertThat(mock.getReceivedExchanges().get(i).getMessage().getBody()).isEqualTo("id"
 + i + "-body");
+        }
+
+        // every endpoint replaces the route of the previous one, as they 
share the route id: the last one is kept
+        
assertThat(context.getRoutes()).filteredOn(Route::isCreatedByKamelet).extracting(Route::getRouteId)
+                .containsExactly("myRoute");
+    }
+
+    @Test
+    void routeOfKameletIsRemovedWithTheRouteAddedAtRuntimeThatConsumesFromIt() 
throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("kamelet:source-of?name=a").routeId("consumer")
+                        .to("mock:consumer");
+            }
+        });
+        assertThat(kameletRoutes()).isEqualTo(1);
+
+        MockEndpoint mock = getMockEndpoint("mock:consumer");
+        mock.expectedBodiesReceived("from a");
+        template.sendBody("direct:source-a", "from a");
+        mock.assertIsSatisfied();
+
+        // removing the route shuts down its input endpoint, which removes the 
route it created from the template
+        context.getRouteController().stopRoute("consumer");
+        assertThat(context.removeRoute("consumer")).isTrue();
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(kameletRoutes()).isZero());
+    }
+
+    private long kameletRoutes() {
+        return 
context.getRoutes().stream().filter(Route::isCreatedByKamelet).count();
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                routeTemplate("prefix")
+                        .templateParameter("value")
+                        .from("kamelet:source")
+                        .setBody().simple("{{value}}-${body}");
+
+                routeTemplate("pooled")
+                        .templateParameter("value")
+                        .from("kamelet:source")
+                        .threads(2)
+                        .setHeader("kameletRouteId").simple("${routeId}")
+                        .setBody().simple("{{value}}-${body}");
+
+                routeTemplate("source-of")
+                        .templateParameter("name")
+                        .from("direct:source-{{name}}")
+                        .to("kamelet:sink");
+
+                from("direct:prototype").routeId("prototype")
+                        .toD("kamelet:prefix?value=${header.id}", -1)
+                        .to("mock:result");
+
+                from("direct:cached").routeId("cached")
+                        .toD("kamelet:prefix?value=${header.id}")
+                        .to("mock:result");
+
+                from("direct:myRoute").routeId("myRouteCaller")
+                        .toD("kamelet:prefix/myRoute?value=${header.id}", -1)
+                        .to("mock:result");
+
+                from("direct:threads").routeId("threadsCaller")
+                        .toD("kamelet:pooled?value=${header.id}", -1)
+                        .process(e -> {
+                            e.getMessage().setHeader("thread", 
Thread.currentThread().getName());
+                            e.getMessage().setHeader("interrupted", 
Thread.currentThread().isInterrupted());
+                            String id = 
e.getMessage().getHeader("kameletRouteId", String.class);
+                            e.getMessage().setHeader("kameletRouteKept", 
context.getRoute(id) != null);
+                        })
+                        .to("mock:threads");
+
+                from("direct:slow").routeId("slowCaller")
+                        .toD("kamelet:pooled?value=${header.id}", -1)
+                        .process(e -> recordWhetherTheKameletRouteIsKept(e))
+                        .to("mock:slow");
+
+                from("direct:recipientList").routeId("recipientListCaller")
+                        
.recipientList(simple("kamelet:pooled?value=${header.id}")).cacheSize(-1)
+                        .process(e -> recordWhetherTheKameletRouteIsKept(e))
+                        .to("mock:slow");
+
+                from("direct:routingSlip").routeId("routingSlipCaller")
+                        
.routingSlip(simple("kamelet:pooled?value=${header.id}")).cacheSize(-1)
+                        .process(e -> recordWhetherTheKameletRouteIsKept(e))
+                        .to("mock:slow");
+            }
+        };
+    }
+
+    private void recordWhetherTheKameletRouteIsKept(Exchange e) {
+        e.getMessage().setHeader("thread", Thread.currentThread().getName());
+        String id = e.getMessage().getHeader("kameletRouteId", String.class);
+        boolean kept;
+        try {
+            // the rest of the calling route runs on a thread of the Kamelet's 
route, which must not be stopped
+            // (shutting down its thread pool) before the calling route has 
left that thread
+            await().during(500, TimeUnit.MILLISECONDS).atMost(2, 
TimeUnit.SECONDS)
+                    .until(() -> context.getRoute(id) != null);
+            kept = true;
+        } catch (ConditionTimeoutException ex) {
+            kept = false;
+        }
+        e.getMessage().setHeader("kameletRouteKept", kept);
+        e.getMessage().setHeader("interrupted", 
Thread.currentThread().isInterrupted());
+    }
+}
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 fedf1ea33b06..78e56e371433 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
@@ -1560,6 +1560,17 @@ placeholder or a route template parameter in them never 
matched (Switch) or did
 They are now resolved at startup like the other EIP options; a placeholder of 
a property that does not exist now
 fails the route at startup.
 
+=== camel-kamelet - the route of a Kamelet endpoint is removed with the 
endpoint
+
+A Kamelet endpoint creates a route from the Kamelet's template. When the 
endpoint is created while the CamelContext
+runs and is shut down later, its route is now stopped and removed as well; 
before, it stayed behind and kept running.
+This applies to an endpoint that is used once and discarded (`toD`, 
`recipientList` or `routingSlip` with
+`cacheSize=-1`), which kept one more route for every message. It also applies 
to a route added at runtime (such as a
+dev-mode reload or `CamelContext.addRoutes`) that consumes from a Kamelet 
(`from("kamelet:my-source")`): removing that
+route now also removes the route of the Kamelet. A route id given in the uri 
(`kamelet:my-source/myRoute` or
+`routeId=`) is shared by the endpoints that use it, so such a route is not 
removed. Routes created when the
+CamelContext starts are not changed.
+
 === camel-undertow - the producer refuses a request target or header value 
with control characters
 
 The undertow producer writes the path, query and header values of the outgoing 
request to the socket one character at

Reply via email to