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 bdec091863e4 CAMEL-25093: camel-core - Run concurrent updateRoutes 
calls one at a time (#27524)
bdec091863e4 is described below

commit bdec091863e46b89609c09a13be6562147944d29
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 8 18:36:26 2026 +0530

    CAMEL-25093: camel-core - Run concurrent updateRoutes calls one at a time 
(#27524)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/camel/spi/RoutesLoader.java    |   6 +
 .../camel/impl/engine/DefaultRoutesLoader.java     |  19 ++-
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   6 +
 .../dsl/xml/io/XmlConcurrentUpdateRoutesTest.java  | 137 +++++++++++++++++++++
 4 files changed, 166 insertions(+), 2 deletions(-)

diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/RoutesLoader.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/RoutesLoader.java
index 743a7e219bf9..9e023931409e 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/RoutesLoader.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/RoutesLoader.java
@@ -110,6 +110,8 @@ public interface RoutesLoader extends CamelContextAware {
      * If a route is loaded with a route id for an existing route, then the 
existing route is stopped and remove, so it
      * can be updated.
      *
+     * Camel's {@code DefaultRoutesLoader} runs concurrent calls one at a 
time, see {@link #updateRoutes(Collection)}.
+     *
      * @param  resources the resources to be loaded or updated.
      * @return           route ids for the routes that was loaded or updated.
      */
@@ -124,6 +126,10 @@ public interface RoutesLoader extends CamelContextAware {
      * If a route is loaded with a route id for an existing route, then the 
existing route is stopped and remove, so it
      * can be updated.
      *
+     * Camel's {@code DefaultRoutesLoader} runs concurrent calls of the {@code 
updateRoutes} methods one at a time, so
+     * they can be called from several threads. They are not serialized with 
other ways of adding routes, such as
+     * {@link #loadRoutes(Resource...)}.
+     *
      * @param  resources the resources to be loaded or updated.
      * @return           route ids for the routes that was loaded or updated.
      */
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoutesLoader.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoutesLoader.java
index fc462f70087a..2b523d3a6528 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoutesLoader.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoutesLoader.java
@@ -24,6 +24,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.CamelContextAware;
@@ -59,6 +61,9 @@ public class DefaultRoutesLoader extends ServiceSupport 
implements RoutesLoader,
     public static final String ROUTES_LOADER_KEY_PREFIX = 
"routes-builder-loader-";
 
     private final Map<String, RoutesBuilderLoader> loaders;
+    // the loaders keep state between pre-parsing and loading the resources 
(such as beans that are registered again
+    // when the routes are configured), and the model is not updated 
atomically, so updates run one at a time
+    private final Lock updateLock = new ReentrantLock();
 
     private CamelContext camelContext;
     private boolean ignoreLoadingError;
@@ -292,6 +297,18 @@ public class DefaultRoutesLoader extends ServiceSupport 
implements RoutesLoader,
             return answer;
         }
 
+        // the lock is held while the routes being replaced are stopped: an 
updateRoutes call made from an exchange of
+        // such a route therefore waits here until the shutdown timeout forces 
that route to stop (see the upgrade guide)
+        updateLock.lockInterruptibly();
+        try {
+            doUpdateRoutes(resources, answer);
+        } finally {
+            updateLock.unlock();
+        }
+        return answer;
+    }
+
+    private void doUpdateRoutes(Collection<Resource> resources, Set<String> 
answer) throws Exception {
         Collection<RoutesBuilder> builders = findRoutesBuilders(resources);
         for (RoutesBuilder builder : builders) {
             // update any existing route configurations first
@@ -312,8 +329,6 @@ public class DefaultRoutesLoader extends ServiceSupport 
implements RoutesLoader,
             }
             answer.addAll(ids);
         }
-
-        return answer;
     }
 
     protected RoutesBuilderLoader resolveRoutesBuilderLoader(Resource 
resource, boolean optional) throws Exception {
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 034bcf893831..4ed981d11a4f 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
@@ -1484,6 +1484,12 @@ A project whose routes are all in one file is covered 
too: the content that last
 broken save goes back to the version that was running, and the log says how 
many routes came from it. The file on
 disk is untouched; its next save is loaded as usual.
 
+=== camel-core - RoutesLoader.updateRoutes runs one call at a time
+
+Concurrent `RoutesLoader.updateRoutes` calls on one CamelContext now run one 
at a time instead of failing with
+`ConcurrentModificationException`. A route whose exchange calls `updateRoutes` 
while another `updateRoutes` call
+replaces that route now makes the stop wait until the shutdown timeout, after 
which the route is forced to stop.
+
 === camel-core - the recursive file watcher watches directories created while 
it runs
 
 The file watcher reload strategy (`camel run --dev`, the route watcher reload 
strategy with a recursive directory)
diff --git 
a/dsl/camel-xml-io-dsl/src/test/java/org/apache/camel/dsl/xml/io/XmlConcurrentUpdateRoutesTest.java
 
b/dsl/camel-xml-io-dsl/src/test/java/org/apache/camel/dsl/xml/io/XmlConcurrentUpdateRoutesTest.java
new file mode 100644
index 000000000000..7fd1f4408d73
--- /dev/null
+++ 
b/dsl/camel-xml-io-dsl/src/test/java/org/apache/camel/dsl/xml/io/XmlConcurrentUpdateRoutesTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.dsl.xml.io;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.spi.RoutesLoader;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResourceHelper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Calling updateRoutes from several threads on the same CamelContext 
(CAMEL-25093).
+ */
+class XmlConcurrentUpdateRoutesTest {
+
+    private static final AtomicInteger GATED_CALLS = new AtomicInteger();
+    private static final AtomicInteger OTHER_CALLS = new AtomicInteger();
+    private static volatile CountDownLatch gatedEntered;
+    private static volatile CountDownLatch gatedRelease;
+
+    @BeforeEach
+    void reset() {
+        GATED_CALLS.set(0);
+        OTHER_CALLS.set(0);
+        gatedEntered = new CountDownLatch(1);
+        gatedRelease = new CountDownLatch(1);
+    }
+
+    /**
+     * Fails the first time (so it is registered again when the routes are 
configured), and the second time waits for
+     * the test.
+     */
+    public static class GatedBean {
+        public GatedBean() throws InterruptedException {
+            int call = GATED_CALLS.incrementAndGet();
+            if (call == 1) {
+                throw new IllegalStateException("Not yet");
+            } else if (call == 2) {
+                gatedEntered.countDown();
+                gatedRelease.await(20, TimeUnit.SECONDS);
+            }
+        }
+    }
+
+    /**
+     * Fails the first time (so it is registered again when the routes are 
configured).
+     */
+    public static class OtherBean {
+        public OtherBean() {
+            if (OTHER_CALLS.incrementAndGet() == 1) {
+                throw new IllegalStateException("Not yet");
+            }
+        }
+    }
+
+    @Test
+    void testConcurrentUpdateRoutes() throws Exception {
+        Resource first = ResourceHelper.fromString("first.xml", camel("gated", 
GatedBean.class, "first"));
+        Resource second = ResourceHelper.fromString("second.xml", 
camel("other", OtherBean.class, "second"));
+
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.start();
+            RoutesLoader loader = PluginHelper.getRoutesLoader(context);
+
+            ExecutorService executor = Executors.newFixedThreadPool(2);
+            try {
+                // the first update waits while it registers the beans that 
failed when the resource was pre-parsed
+                Future<?> one = executor.submit(() -> 
loader.updateRoutes(first));
+                assertTrue(gatedEntered.await(20, TimeUnit.SECONDS));
+
+                // the second update runs meanwhile (or waits for the first)
+                AtomicReference<Thread> thread = new AtomicReference<>();
+                Future<?> two = executor.submit(() -> {
+                    thread.set(Thread.currentThread());
+                    return loader.updateRoutes(second);
+                });
+                await().atMost(20, TimeUnit.SECONDS).until(() -> two.isDone()
+                        || OTHER_CALLS.get() == 0 && thread.get() != null
+                                && thread.get().getState() == 
Thread.State.WAITING);
+                gatedRelease.countDown();
+
+                assertDoesNotThrow(() -> one.get(20, TimeUnit.SECONDS), "The 
first updateRoutes should not fail");
+                assertDoesNotThrow(() -> two.get(20, TimeUnit.SECONDS), "The 
second updateRoutes should not fail");
+            } finally {
+                gatedRelease.countDown();
+                executor.shutdownNow();
+            }
+
+            assertNotNull(context.getRoute("first"), "Route first should be 
added");
+            assertNotNull(context.getRoute("second"), "Route second should be 
added");
+            assertNotNull(context.getRegistry().lookupByName("gated"), "Bean 
gated should be registered");
+            assertNotNull(context.getRegistry().lookupByName("other"), "Bean 
other should be registered");
+        }
+    }
+
+    private static String camel(String bean, Class<?> type, String route) {
+        return """
+                <camel>
+                    <bean name="%s" type="%s"/>
+                    <route id="%s">
+                        <from uri="direct:%s"/>
+                        <to uri="mock:%s"/>
+                    </route>
+                </camel>
+                """.formatted(bean, type.getName(), route, route, route);
+    }
+}

Reply via email to