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 3cdd9d1ee46f CAMEL-24899: camel-core - a failed reload of the only
route file keeps the previous version running
3cdd9d1ee46f is described below
commit 3cdd9d1ee46f98f57207cf012fec9725b669392e
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 22 14:54:25 2026 +0200
CAMEL-24899: camel-core - a failed reload of the only route file keeps the
previous version running
Fixes https://issues.apache.org/jira/browse/CAMEL-24899 (part of
CAMEL-24886)
CAMEL-24860 restores the previous routes after a failed reload by loading
the other files again from disk. When the failing file is the only route file,
there is nothing to restore: the good version is gone from disk, and the app
runs without routes until the next successful save. Every HTTP example of the
benchmark is one file, so one bad save took the whole app down for the rest of
the step and the REST endpoints answered 404.
`RouteWatcherReloadStrategy` now keeps the content of the sources that
loaded, by location, bounded to the current set (`lastGoodContent`, filled
after each successful `updateRoutes`). On a failed reload the failed file is
added back as an in-memory resource with that content and its original
location, so:
```
WARN RouteWatcherReloadStrategy : Reload failed due to: Error constructing
YAML node id: pollEnrich: unsupported field: uri
... The previous routes were restored (1 route(s) running, 1 from the
last loaded content of the changed file);
the changed file loads on its next save
```
The file on disk is untouched, and its next save is loaded as usual (the
in-memory resource carries the same location, so the fixed file replaces it).
Test: `RouteReloadRollbackTest."the only route file keeps its previous version
when the save is broken"`, which fails without the change. Verified with `camel
run --dev` on a single-file project: the timer keeps ticking through the broken
save, and the fixed save takes over. Upgrade note extended.
---
.../camel/support/RouteWatcherReloadStrategy.java | 63 +++++++++-
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 4 +
.../camel/dsl/yaml/RouteReloadRollbackTest.groovy | 131 ++++++++++++++++++++-
3 files changed, 190 insertions(+), 8 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 300bb65eab77..40f5ee7088e3 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
@@ -23,10 +23,13 @@ import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.StringJoiner;
+import java.util.concurrent.ConcurrentHashMap;
import org.apache.camel.Route;
import org.apache.camel.RuntimeCamelException;
@@ -68,6 +71,8 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
private String pattern;
private boolean removeAllRoutes = true;
private final List<Resource> previousSources = new ArrayList<>();
+ /** The content of the sources of the last successful reload, by location:
what a failed reload goes back to. */
+ private final Map<String, byte[]> lastGoodContent = new
ConcurrentHashMap<>();
public RouteWatcherReloadStrategy() {
}
@@ -322,6 +327,13 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
// update okay, so clear as we do not need to remember those
anymore
previousSources.clear();
+ if (removeEverything) {
+ // the route files are gone and nothing runs: the remembered
content goes with them, so a later
+ // failed reload cannot put a deleted file back (CAMEL-24899)
+ lastGoodContent.clear();
+ } else {
+ rememberContent(sources);
+ }
if (!ids.isEmpty()) {
List<String> lines = new ArrayList<>();
@@ -396,10 +408,36 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
}
/**
- * Reloads the sources of the routes that ran before a failed reload,
without the resources that failed, so a
- * mistake in one file does not leave the application without routes.
After a successful restore the remembered set
- * is cleared: the running routes are the last working set again, and the
next reload collects their sources itself.
- * The failed file is loaded again on its next save.
+ * Remembers the content of the sources that loaded, so a later failed
reload of one of them can go back to the
+ * version that ran (the file on disk is then the broken one). Only the
sources of the current set are kept; the
+ * caller drops the lot when everything is removed.
+ */
+ private void rememberContent(Collection<Resource> sources) {
+ if (!removeAllRoutes || sources == null) {
+ return;
+ }
+ Set<String> current = new HashSet<>();
+ for (Resource rs : sources) {
+ if (rs == null || !rs.exists()) {
+ continue;
+ }
+ current.add(rs.getLocation());
+ try (InputStream is = rs.getInputStream()) {
+ lastGoodContent.put(rs.getLocation(), is.readAllBytes());
+ } catch (Exception e) {
+ LOG.debug("Cannot remember the content of {} for a later
failed reload: {}", rs.getLocation(),
+ e.getMessage());
+ }
+ }
+ lastGoodContent.keySet().retainAll(current);
+ }
+
+ /**
+ * Reloads the sources of the routes that ran before a failed reload, so a
mistake in one file does not leave the
+ * application without routes: the other files from disk, and the failed
file from the content that last loaded,
+ * which is kept in memory for a project whose routes are all in one file
(CAMEL-24899). After a successful restore
+ * the remembered set is cleared: the running routes are the last working
set again, and the next reload collects
+ * their sources itself. The file on disk is untouched and its next save
is loaded as usual.
*/
protected void restorePreviousRoutes(Collection<Resource> failed,
Exception cause) {
if (!removeAllRoutes || previousSources.isEmpty()) {
@@ -411,6 +449,18 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
restore.add(rs);
}
}
+ // the failed file itself: its last loaded content, kept in memory,
because the file on disk is the broken
+ // version (a project with a single route file has nothing else to go
back to, CAMEL-24899)
+ int fromMemory = 0;
+ if (failed != null) {
+ for (Resource rs : failed) {
+ byte[] content = rs != null ?
lastGoodContent.get(rs.getLocation()) : null;
+ if (content != null && !equalResourceLocation(restore, rs)) {
+ restore.add(ResourceHelper.fromBytes(rs.getLocation(),
content));
+ fromMemory++;
+ }
+ }
+ }
if (restore.isEmpty()) {
LOG.warn("Reload failed and there are no previous routes to
restore: the application runs without routes"
+ " until the file is fixed");
@@ -424,9 +474,10 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
Set<String> ids =
PluginHelper.getRoutesLoader(getCamelContext()).updateRoutes(restore);
// the running routes are the last working set again: the next
reload collects their sources itself
previousSources.clear();
- LOG.warn("Reload failed due to: {}. The previous routes were
restored ({} route(s) running); the changed"
+ LOG.warn("Reload failed due to: {}. The previous routes were
restored ({} route(s) running{}); the changed"
+ " file loads on its next save",
- cause.getMessage(), ids.size());
+ cause.getMessage(), ids.size(),
+ fromMemory > 0 ? ", " + fromMemory + " from the last
loaded content of the changed file" : "");
} catch (Exception e) {
LOG.warn("Reload failed and the previous routes could not be
restored due to: {}. The application runs"
+ " without routes until the file is fixed",
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 7b790bd43873..62262c658fd4 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
@@ -503,6 +503,10 @@ file, and a WARN says so. Before, the previous routes
stayed stopped until the n
in one file left the application without routes. The failed file loads again
on its next save. The
`CamelContextReloadFailure` event and the reload error log line are unchanged.
+A project whose routes are all in one file is covered too: the content that
last loaded is kept in memory, so the
+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 - 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-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/RouteReloadRollbackTest.groovy
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/RouteReloadRollbackTest.groovy
index f28491c5eb10..20e84f5ea3e0 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/RouteReloadRollbackTest.groovy
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/RouteReloadRollbackTest.groovy
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.yaml
import org.apache.camel.ServiceStatus
+import org.apache.camel.component.mock.MockEndpoint
import org.apache.camel.dsl.yaml.support.YamlTestSupport
import org.apache.camel.spi.Resource
import org.apache.camel.support.ResourceHelper
@@ -27,7 +28,14 @@ import java.nio.file.Path
/**
* CAMEL-24860: a reload that fails (a route file saved with a mistake)
restores the routes that ran before, instead
- * of leaving the application without routes until the next successful save.
+ * of leaving the application without routes until the next successful save.
CAMEL-24899: a project whose routes are
+ * all in one file goes back to the content that last loaded, which is kept in
memory.
+ * <p>
+ * The mistake the tests save is always the same one, and it is a real one
(CAMEL-24850): the endpoint of pollEnrich
+ * is an expression, so {@code pollEnrich: {uri: "file:./order.json"}} has no
uri property to bind and the loader
+ * rejects the file with "pollEnrich: unsupported field: uri". It is the right
kind of mistake here because it fails
+ * while the routes are built, not while the YAML is parsed, which is what a
reload has to survive. The form that
+ * works is {@code pollEnrich: {expression: {constant: {expression:
"file:./order.json"}}}}.
*/
class RouteReloadRollbackTest extends YamlTestSupport {
@@ -66,6 +74,125 @@ class RouteReloadRollbackTest extends YamlTestSupport {
dir.toFile().deleteDir()
}
+ def 'the only route file keeps its previous version when the save is
broken'() {
+ setup:
+ def solo = Files.createTempDirectory("camel-reload-solo")
+ def only = solo.resolve("only.camel.yaml")
+ Files.writeString(only, """
+ - route:
+ id: only
+ from:
+ uri: direct:only
+ steps:
+ - to:
+ uri: mock:only
+ """)
+ def context2 = new org.apache.camel.impl.DefaultCamelContext()
+ context2.start()
+ org.apache.camel.support.PluginHelper.getRoutesLoader(context2)
+ .loadRoutes(ResourceHelper.resolveResource(context2,
"file:" + only))
+ def strategy = new RouteWatcherReloadStrategy(solo.toString())
+ strategy.setCamelContext(context2)
+ strategy.setPattern("*.yaml")
+ strategy.doStart()
+ // one successful reload, so the content that runs is remembered
+ strategy.getResourceReload().onReload(only.toString(),
ResourceHelper.resolveResource(context2, "file:" + only))
+ assert context2.getRouteController().getRouteStatus("only") ==
ServiceStatus.Started
+ when: 'the only route file is saved with a mistake (pollEnrich takes
an expression, not a uri)'
+ Files.writeString(only, """
+ - route:
+ id: only
+ from:
+ uri: direct:only
+ steps:
+ - pollEnrich:
+ uri: file:./order.json
+ """)
+ def failure = null
+ try {
+ strategy.getResourceReload().onReload(only.toString(),
ResourceHelper.resolveResource(context2, "file:" + only))
+ } catch (Exception e) {
+ failure = e
+ }
+ then: 'the reload fails and the version that ran before is still
running'
+ failure != null
+ context2.getRouteController().getRouteStatus("only") ==
ServiceStatus.Started
+ when: 'the file is fixed'
+ Files.writeString(only, """
+ - route:
+ id: only
+ from:
+ uri: direct:only
+ steps:
+ - to:
+ uri: mock:fixed
+ """)
+ strategy.getResourceReload().onReload(only.toString(),
ResourceHelper.resolveResource(context2, "file:" + only))
+ then: 'the fixed version runs, not the remembered one: the message
lands in mock:fixed'
+ context2.getRouteController().getRouteStatus("only") ==
ServiceStatus.Started
+ context2.getRoutes().size() == 1
+ def fixed = context2.getEndpoint("mock:fixed", MockEndpoint)
+ fixed.expectedMessageCount(1)
+ def stale = context2.getEndpoint("mock:only", MockEndpoint)
+ stale.expectedMessageCount(0)
+ context2.createProducerTemplate().sendBody("direct:only", "x")
+ MockEndpoint.assertIsSatisfied(context2)
+ cleanup:
+ strategy.doStop()
+ context2.stop()
+ solo.toFile().deleteDir()
+ }
+
+ def 'everything removed forgets the remembered content, a deleted file is
not put back'() {
+ setup:
+ def gone = Files.createTempDirectory("camel-reload-gone")
+ def file = gone.resolve("gone.camel.yaml")
+ Files.writeString(file, """
+ - route:
+ id: gone
+ from:
+ uri: direct:gone
+ steps:
+ - to:
+ uri: mock:gone
+ """)
+ def ctx = new org.apache.camel.impl.DefaultCamelContext()
+ ctx.start()
+ def strategy = new RouteWatcherReloadStrategy(gone.toString())
+ strategy.setCamelContext(ctx)
+ strategy.setPattern("*.yaml")
+ strategy.doStart()
+ strategy.getResourceReload().onReload(file.toString(),
ResourceHelper.resolveResource(ctx, "file:" + file))
+ assert ctx.getRouteController().getRouteStatus("gone") ==
ServiceStatus.Started
+ when: 'every route file is removed (the on-demand strategy asks for
that when the directory is empty)'
+ strategy.onRouteReload(null, true)
+ then: 'no routes run'
+ ctx.routes.isEmpty()
+ when: 'the file comes back with a mistake (pollEnrich takes an
expression, not a uri)'
+ Files.writeString(file, """
+ - route:
+ id: gone
+ from:
+ uri: direct:gone
+ steps:
+ - pollEnrich:
+ uri: file:./order.json
+ """)
+ def failure = null
+ try {
+ strategy.getResourceReload().onReload(file.toString(),
ResourceHelper.resolveResource(ctx, "file:" + file))
+ } catch (Exception e) {
+ failure = e
+ }
+ then: 'the reload fails and the removed route is not put back from
memory'
+ failure != null
+ ctx.routes.isEmpty()
+ cleanup:
+ strategy.doStop()
+ ctx.stop()
+ gone.toFile().deleteDir()
+ }
+
def 'a failed reload restores the previous routes'() {
setup:
def strategy = new RouteWatcherReloadStrategy(dir.toString())
@@ -75,7 +202,7 @@ class RouteReloadRollbackTest extends YamlTestSupport {
strategy.doStart()
assert context.getRouteController().getRouteStatus("good") ==
ServiceStatus.Started
assert context.getRouteController().getRouteStatus("bad") ==
ServiceStatus.Started
- when: 'the second file is saved with a mistake'
+ when: 'the second file is saved with a mistake (pollEnrich takes an
expression, not a uri)'
Files.writeString(bad, '''
- route:
id: bad