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 8f2fdff9bfc4 CAMEL-25427: camel-support - on-demand route reload no
longer fails after a route file is removed (#27565)
8f2fdff9bfc4 is described below
commit 8f2fdff9bfc478993d5471a304009309577500c1
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 14:56:53 2026 +0200
CAMEL-25427: camel-support - on-demand route reload no longer fails after a
route file is removed (#27565)
On-demand reload only passes the route files that exist, so the source of a
route whose file was removed (or renamed) was collected from the current
routes and loaded again, failing with FileNotFoundException. The restore of
the previous routes then failed on the same file, leaving no routes, and
every later reload failed the same way.
Sources whose file no longer exists are now skipped when collecting the
current routes, the previously attempted sources, and the routes to restore.
The check looks at the file location, so a source restored from its last
loaded content (kept in memory) is also dropped once its file is gone.
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../camel/support/RouteWatcherReloadStrategy.java | 24 ++-
.../yaml/RouteReloadOnDemandRemovedFileTest.java | 183 +++++++++++++++++++++
2 files changed, 203 insertions(+), 4 deletions(-)
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/RouteWatcherReloadStrategy.java
b/core/camel-support/src/main/java/org/apache/camel/support/RouteWatcherReloadStrategy.java
index 8d4254d492bd..3699148eb873 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/RouteWatcherReloadStrategy.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/RouteWatcherReloadStrategy.java
@@ -360,8 +360,9 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
// last update failed, so we need to update all previous sources
to ensure we go back
// to the last working set
previousSources.forEach(rs -> {
- // remember all the sources of the current routes (except the
updated)
- if (rs != null && !equalResourceLocation(resources, rs) &&
!equalResourceLocation(sources, rs)) {
+ // remember all the sources of the current routes (except the
updated, and those whose file is gone)
+ if (rs != null && !isDeletedFile(rs) &&
!equalResourceLocation(resources, rs)
+ && !equalResourceLocation(sources, rs)) {
sources.add(rs);
}
});
@@ -373,9 +374,12 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
// remember all the sources of the current routes (except the
updated)
// (a file with several routes is one source: adding it once
per route would load it several
// times and fail on a duplicate route id, CAMEL-24866)
+ // (the file of a route may have been removed since it loaded:
on-demand reload only passes the files
+ // that exist, so its routes are removed like those of a
changed file, CAMEL-25427)
getCamelContext().getRoutes().forEach(r -> {
Resource rs = r.getSourceResource();
- if (rs != null && !equalResourceLocation(resources, rs) &&
!equalResourceLocation(sources, rs)) {
+ if (rs != null && !isDeletedFile(rs) &&
!equalResourceLocation(resources, rs)
+ && !equalResourceLocation(sources, rs)) {
sources.add(rs);
}
});
@@ -556,7 +560,7 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
}
List<Resource> restore = new ArrayList<>();
for (Resource rs : previousSources) {
- if (rs != null && (failed == null ||
!equalResourceLocation(failed, rs))) {
+ if (rs != null && !isDeletedFile(rs) && (failed == null ||
!equalResourceLocation(failed, rs))) {
restore.add(rs);
}
}
@@ -596,6 +600,18 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
}
}
+ /**
+ * Whether the resource is a file that no longer exists. Only the location
is looked at: a source restored from its
+ * last loaded content is kept in memory and always exists, but its file
may have been removed since (CAMEL-25427).
+ */
+ private static boolean isDeletedFile(Resource resource) {
+ String loc = resource.getLocation();
+ if (loc == null || !loc.startsWith("file:")) {
+ return false;
+ }
+ return !Files.exists(Paths.get(loc.substring(5)));
+ }
+
/**
* Whether the target is loading any of the given sources
*/
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadOnDemandRemovedFileTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadOnDemandRemovedFileTest.java
new file mode 100644
index 000000000000..50456d46c58e
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadOnDemandRemovedFileTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.yaml;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.dsl.yaml.support.YamlTestSupport;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.support.RouteOnDemandReloadStrategy;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25427: an on-demand reload after a route file was removed (or
renamed) removes the routes of that file and
+ * reloads the others, instead of failing on the missing file and leaving the
application without routes.
+ */
+class RouteReloadOnDemandRemovedFileTest extends YamlTestSupport {
+
+ /** Subclass to expose protected lifecycle methods for testing. */
+ static class TestableStrategy extends RouteOnDemandReloadStrategy {
+ TestableStrategy(String dir) {
+ super(dir);
+ }
+
+ @Override
+ public void doStart() throws Exception {
+ super.doStart();
+ }
+
+ @Override
+ public void doStop() throws Exception {
+ super.doStop();
+ }
+ }
+
+ private Path dir;
+ private Path a;
+ private Path b;
+
+ @Override
+ public void doSetup() throws Exception {
+ dir = Files.createTempDirectory("camel-reload-removed");
+ a = dir.resolve("a.camel.yaml");
+ b = dir.resolve("b.camel.yaml");
+ Files.writeString(a, route("a", "mock:a"));
+ Files.writeString(b, route("b", "mock:b"));
+ context.start();
+ loadRoutes(ResourceHelper.resolveResource(context, "file:" + a),
+ ResourceHelper.resolveResource(context, "file:" + b));
+ }
+
+ @Override
+ public void doCleanup() throws Exception {
+ deleteRecursively(dir);
+ }
+
+ @Test
+ void aRemovedFileRemovesItsRoutesAndTheOthersReload() throws Exception {
+ TestableStrategy strategy = newStrategy();
+ try {
+ assertThat(context.getRoutes()).hasSize(2);
+
+ Files.delete(b);
+ strategy.onReload("test");
+
+ assertOnlyRouteARuns(strategy);
+
+ // a later reload works too
+ strategy.onReload("test");
+ assertOnlyRouteARuns(strategy);
+ } finally {
+ strategy.doStop();
+ }
+ }
+
+ @Test
+ void aFileRestoredFromMemoryCanBeRemoved() throws Exception {
+ TestableStrategy strategy = newStrategy();
+ try {
+ // one successful reload, so the content that runs is remembered
+ strategy.onReload("test");
+ assertThat(strategy.getFailedCounter()).isZero();
+
+ // b is saved with a mistake (pollEnrich takes an expression, not
a uri): the reload fails and the routes
+ // are restored from the content that last loaded
+ Files.writeString(b, """
+ - route:
+ id: b
+ from:
+ uri: direct:b
+ steps:
+ - pollEnrich:
+ uri: file:./order.json
+ """);
+ strategy.onReload("test");
+ assertThat(strategy.getFailedCounter()).isEqualTo(1);
+
assertThat(context.getRouteController().getRouteStatus("b")).isEqualTo(ServiceStatus.Started);
+
+ // the broken file is removed instead of fixed: route b runs from
memory, but its file is gone
+ Files.delete(b);
+ strategy.onReload("test");
+
+ assertThat(strategy.getFailedCounter()).isEqualTo(1);
+ assertOnlyRouteARuns(strategy);
+ } finally {
+ strategy.doStop();
+ }
+ }
+
+ private TestableStrategy newStrategy() throws Exception {
+ TestableStrategy strategy = new TestableStrategy(dir.toString());
+ strategy.setCamelContext(context);
+ strategy.setPattern("*.yaml");
+ strategy.doStart();
+ return strategy;
+ }
+
+ private void assertOnlyRouteARuns(TestableStrategy strategy) throws
Exception {
+ assertThat(strategy.getLastError()).isNull();
+ assertThat(context.getRoutes()).hasSize(1);
+
assertThat(context.getRouteController().getRouteStatus("a")).isEqualTo(ServiceStatus.Started);
+ assertThat(context.getRoute("b")).isNull();
+
+ MockEndpoint mock = context.getEndpoint("mock:a", MockEndpoint.class);
+ mock.reset();
+ mock.expectedMessageCount(1);
+ context.createProducerTemplate().sendBody("direct:a", "x");
+ mock.assertIsSatisfied();
+ }
+
+ private static String route(String id, String to) {
+ return """
+ - route:
+ id: %s
+ from:
+ uri: direct:%s
+ steps:
+ - to:
+ uri: %s
+ """.formatted(id, id, to);
+ }
+
+ private static void deleteRecursively(Path path) throws IOException {
+ if (path == null || !Files.exists(path)) {
+ return;
+ }
+ Files.walkFileTree(path, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes
attrs) throws IOException {
+ Files.delete(file);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path dir, IOException
exc) throws IOException {
+ Files.delete(dir);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+}