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 df4fcf2cb0a1 CAMEL-26334: camel-once - the consumer releases its 
thread when its route stops, and fires again after a restart (#28757)
df4fcf2cb0a1 is described below

commit df4fcf2cb0a1bdef8c5deb92ab129ad50091ea32
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 31 14:59:04 2026 +0530

    CAMEL-26334: camel-once - the consumer releases its thread when its route 
stops, and fires again after a restart (#28757)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/component/once/OnceConsumer.java  |  43 ++++--
 .../component/once/OnceConsumerLifecycleTest.java  | 167 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  12 ++
 3 files changed, 209 insertions(+), 13 deletions(-)

diff --git 
a/components/camel-once/src/main/java/org/apache/camel/component/once/OnceConsumer.java
 
b/components/camel-once/src/main/java/org/apache/camel/component/once/OnceConsumer.java
index c4d29d7d8eaf..87440e5fdec6 100644
--- 
a/components/camel-once/src/main/java/org/apache/camel/component/once/OnceConsumer.java
+++ 
b/components/camel-once/src/main/java/org/apache/camel/component/once/OnceConsumer.java
@@ -17,8 +17,9 @@
 package org.apache.camel.component.once;
 
 import java.io.InputStream;
-import java.util.Timer;
-import java.util.TimerTask;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
@@ -38,30 +39,41 @@ public class OnceConsumer extends DefaultConsumer 
implements StartupListener {
 
     private final CamelContext camelContext;
     private final OnceEndpoint endpoint;
-    private final Timer timer;
-    private final TimerTask task;
+    private final Runnable task;
+    // the thread that fires the route is created when the consumer starts and 
released when it stops: it must not
+    // outlive the route (one thread per route that was ever created) nor keep 
the JVM running
+    private volatile ScheduledExecutorService executor;
+    private volatile ScheduledFuture<?> future;
     private volatile boolean scheduled;
 
     public OnceConsumer(OnceEndpoint endpoint, Processor processor) {
         super(endpoint, processor);
         this.camelContext = endpoint.getCamelContext();
         this.endpoint = endpoint;
-        this.timer = new Timer(endpoint.getName());
-        this.task = new OnceTimerTask();
+        this.task = new OnceTask();
     }
 
     @Override
     protected void doStart() throws Exception {
         super.doStart();
+        executor = 
camelContext.getExecutorServiceManager().newSingleThreadScheduledExecutor(this, 
endpoint.getName());
         if (!scheduled && endpoint.getCamelContext().getStatus().isStarted()) {
-            scheduleTask(task, timer);
+            scheduleTask();
         }
     }
 
     @Override
     protected void doStop() throws Exception {
-        if (task != null) {
-            task.cancel();
+        ScheduledFuture<?> f = future;
+        if (f != null) {
+            // an exchange that is being routed is not interrupted
+            f.cancel(false);
+            future = null;
+        }
+        ScheduledExecutorService e = executor;
+        if (e != null) {
+            camelContext.getExecutorServiceManager().shutdown(e);
+            executor = null;
         }
         scheduled = false;
         super.doStop();
@@ -70,18 +82,23 @@ public class OnceConsumer extends DefaultConsumer 
implements StartupListener {
     @Override
     public void onCamelContextStarted(CamelContext context, boolean 
alreadyStarted) throws Exception {
         if (!scheduled) {
-            scheduleTask(task, timer);
+            scheduleTask();
         }
     }
 
-    protected void scheduleTask(TimerTask task, Timer timer) {
+    protected void scheduleTask() {
+        ScheduledExecutorService e = executor;
+        if (e == null) {
+            // not started
+            return;
+        }
         long delay = Math.max(0, endpoint.getDelay());
         LOG.debug("Scheduled once after: {} mills for task: {} ", delay, task);
-        timer.schedule(task, delay);
+        future = e.schedule(task, delay, TimeUnit.MILLISECONDS);
         scheduled = true;
     }
 
-    private class OnceTimerTask extends TimerTask {
+    private class OnceTask implements Runnable {
 
         @Override
         public void run() {
diff --git 
a/components/camel-once/src/test/java/org/apache/camel/component/once/OnceConsumerLifecycleTest.java
 
b/components/camel-once/src/test/java/org/apache/camel/component/once/OnceConsumerLifecycleTest.java
new file mode 100644
index 000000000000..0e5872786da8
--- /dev/null
+++ 
b/components/camel-once/src/test/java/org/apache/camel/component/once/OnceConsumerLifecycleTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.once;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The once consumer releases the thread that fires it when its route stops, 
and fires again when the route is started
+ * again.
+ */
+class OnceConsumerLifecycleTest {
+
+    @Test
+    void theThreadEndsWhenTheCamelContextStops() throws Exception {
+        String name = "onceContextStop" + System.nanoTime();
+        CountDownLatch fired = new CountDownLatch(1);
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("once:" + name + "?delay=0").process(e -> 
fired.countDown());
+                }
+            });
+            context.start();
+            assertTrue(fired.await(10, TimeUnit.SECONDS), "the route did not 
fire");
+
+            context.stop();
+
+            assertEquals(List.of(), threadsStillAliveOf(name), "threads of the 
stopped once consumer");
+        }
+    }
+
+    @Test
+    void theThreadEndsWhenTheRouteIsRemovedBeforeItFired() throws Exception {
+        String name = "onceRemoved" + System.nanoTime();
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("once:" + name + 
"?delay=3600000").routeId("r").to("log:never");
+                }
+            });
+            context.start();
+
+            context.getRouteController().stopRoute("r");
+            context.removeRoute("r");
+
+            assertEquals(List.of(), threadsStillAliveOf(name), "threads of the 
removed once consumer");
+        }
+    }
+
+    @Test
+    void noThreadIsLeftPerRouteThatWasAddedAndRemoved() throws Exception {
+        String prefix = "onceMany" + System.nanoTime() + "x";
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.start();
+            for (int i = 0; i < 20; i++) {
+                String routeId = "r" + i;
+                CountDownLatch fired = new CountDownLatch(1);
+                int n = i;
+                context.addRoutes(new RouteBuilder() {
+                    @Override
+                    public void configure() {
+                        from("once:" + prefix + n + 
"?delay=0").routeId(routeId).process(e -> fired.countDown());
+                    }
+                });
+                assertTrue(fired.await(10, TimeUnit.SECONDS), "route " + 
routeId + " did not fire");
+                context.getRouteController().stopRoute(routeId);
+                context.removeRoute(routeId);
+            }
+
+            assertEquals(List.of(), threadsStillAliveOf(prefix), "threads of 
the removed once consumers");
+        }
+    }
+
+    @Test
+    void aRouteThatIsStartedAgainFiresAgain() throws Exception {
+        String name = "onceRestart" + System.nanoTime();
+        AtomicInteger count = new AtomicInteger();
+        Semaphore fired = new Semaphore(0);
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("once:" + name + "?delay=0").routeId("r").process(e 
-> {
+                        count.incrementAndGet();
+                        fired.release();
+                    });
+                }
+            });
+            context.start();
+            assertTrue(fired.tryAcquire(10, TimeUnit.SECONDS), "the route did 
not fire");
+
+            context.getRouteController().stopRoute("r");
+            context.getRouteController().startRoute("r");
+
+            assertTrue(fired.tryAcquire(10, TimeUnit.SECONDS), "the started 
again route did not fire");
+            assertEquals(2, count.get());
+        }
+    }
+
+    @Test
+    void aRouteFiresAgainWhenTheCamelContextIsStartedAgain() throws Exception {
+        String name = "onceContextRestart" + System.nanoTime();
+        Semaphore fired = new Semaphore(0);
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("once:" + name + "?delay=0").process(e -> 
fired.release());
+                }
+            });
+            context.start();
+            assertTrue(fired.tryAcquire(10, TimeUnit.SECONDS), "the route did 
not fire");
+
+            context.stop();
+            context.start();
+
+            assertTrue(fired.tryAcquire(10, TimeUnit.SECONDS), "the route did 
not fire after the CamelContext restart");
+        }
+    }
+
+    /**
+     * The threads whose name contains the given text and that are still alive 
once they had up to 10 seconds in all to
+     * end (a thread that is shut down ends at once, a leaked timer thread 
never does).
+     */
+    private static List<String> threadsStillAliveOf(String text) throws 
InterruptedException {
+        List<String> alive = new ArrayList<>();
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+        for (Thread t : Thread.getAllStackTraces().keySet()) {
+            if (t.getName().contains(text)) {
+                t.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(deadline - 
System.nanoTime())));
+                if (t.isAlive()) {
+                    alive.add(t.getName() + (t.isDaemon() ? " (daemon)" : " 
(non-daemon)"));
+                }
+            }
+        }
+        return alive;
+    }
+}
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 bd8443700b2d..d298864d2e54 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
@@ -4258,3 +4258,15 @@ To find the dependencies, `camel export` boots the 
integration silently, as befo
   exported project: such an export can now have more (or other) dependencies. 
The security policy of the profile (such
   as `prod`, which fails on an insecure option) does not fail the export, nor 
does `camel.security.policy=fail`: the
   export only warns. Without `--profile` the dependencies are the same as 
before.
+
+=== camel-once - the consumer fires on a Camel thread pool
+
+The once consumer now fires its route on a single thread scheduled executor 
that it creates through the
+`ExecutorServiceManager` when the route starts and shuts down when the route 
stops, so the thread is named
+`Camel (<context>) thread #N - <name>` instead of `<name>`. Before, each once 
consumer created a non-daemon
+`java.util.Timer` thread when it was created, and that thread stayed alive 
after its route was stopped and after the
+CamelContext was stopped (after its route was removed, until the consumer was 
garbage collected). A once route that is
+stopped and started again now fires again, and so does a once route after the 
CamelContext is stopped and started
+again; before, the start failed with `IllegalStateException: Task already 
scheduled or cancelled`.
+
+The protected method `OnceConsumer.scheduleTask(TimerTask, Timer)` is replaced 
by `scheduleTask()`.

Reply via email to