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 1d7ff7a188f1 CAMEL-25041: camel-core - dev mode reload finds changed
files by scanning the folder, not with the JDK WatchService (#26915)
1d7ff7a188f1 is described below
commit 1d7ff7a188f1e196922ffdc5f36f32cdd0652970
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Sep 27 10:06:02 2026 +0200
CAMEL-25041: camel-core - dev mode reload finds changed files by scanning
the folder, not with the JDK WatchService (#26915)
Reloading was per file: the watcher handed each changed file over on its
own,
and each one reloaded. A route is built with the properties of the moment,
so a
route saved together with a property it uses failed with "Property with key
[x]
not found", and the route that then ran was the content the failed reload
restored, not the file on disk (CAMEL-25032).
The folder is now scanned on an interval and each file's modification time
and
length compared with the previous scan, the way the file component finds
changed
files, so one save is one set of changed files. The properties of a change
are
applied first, each without reloading the routes, and the routes of the
change
are reloaded once. If the batch fails, the files are reloaded one at a
time, so
the reload still names the one file that is wrong (CAMEL-24860). A file
whose
reload failed is re-read when the properties later change, which covers the
fix
made by hand across two saves.
This also removes the macOS penalty, where the watch service fell back to a
poll
of about ten seconds despite SensitivityWatchEventModifier.HIGH being
applied.
setPollTimeout is now the scan interval, default 1000 rather than 2000, and
a new
setStableTimeout (default 200) leaves a file still being written for the
next
scan. Both are noted in the 4.23 upgrade guide.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj
---
.../apache/camel/support/FileScanReloadTest.java | 167 +++++++++++
.../camel/support/FileWatcherReloadOrderTest.java | 67 +++++
.../support/FileWatcherResourceReloadStrategy.java | 334 ++++++++++-----------
.../camel/support/RouteWatcherReloadStrategy.java | 81 ++++-
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 14 +
.../camel/dsl/yaml/RouteReloadBatchTest.java | 228 ++++++++++++++
.../dsl/yaml/RouteReloadPropertiesRetryTest.java | 173 +++++++++++
7 files changed, 883 insertions(+), 181 deletions(-)
diff --git
a/core/camel-core/src/test/java/org/apache/camel/support/FileScanReloadTest.java
b/core/camel-core/src/test/java/org/apache/camel/support/FileScanReloadTest.java
new file mode 100644
index 000000000000..98f2f75d3b22
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/support/FileScanReloadTest.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.support;
+
+import java.io.File;
+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 java.util.List;
+
+import org.apache.camel.ContextTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25041: the folder is scanned and each file's modification time and
length compared with the previous scan, so
+ * one save of several files is one set of changed files - which is what a
reload needs, since a route and a property
+ * saved together must be reloaded together.
+ */
+public class FileScanReloadTest extends ContextTestSupport {
+
+ private Path dir;
+ private FileWatcherResourceReloadStrategy strategy;
+
+ @BeforeEach
+ void createFolder() throws Exception {
+ dir = Files.createTempDirectory("camel-file-scan");
+ strategy = new FileWatcherResourceReloadStrategy(dir.toString(), true);
+ strategy.setCamelContext(context);
+ strategy.setFileFilter(f -> f.getName().endsWith(".yaml") ||
f.getName().endsWith(".properties"));
+ // the test writes and scans at once, so no quiet period
+ strategy.setStableTimeout(0);
+ }
+
+ @AfterEach
+ void removeFolder() throws Exception {
+ deleteRecursively(dir);
+ }
+
+ private List<String> scanNames() {
+ return strategy.scan().stream().map(File::getName).sorted().toList();
+ }
+
+ @Test
+ public void testOneSaveOfSeveralFilesIsOneSetOfChanges() throws Exception {
+ Files.writeString(dir.resolve("shop.yaml"), "one");
+ Files.writeString(dir.resolve("application.properties"),
"shop.name=Camel Shop");
+
+ // the first scan is the starting point: both files are new to it
+ assertThat(scanNames()).containsExactly("application.properties",
"shop.yaml");
+ // nothing changed since
+ assertThat(scanNames()).isEmpty();
+
+ // the save: a route and the property it uses, together
+ Files.writeString(dir.resolve("shop.yaml"), "two");
+ Files.writeString(dir.resolve("application.properties"),
"shop.name=Camel Shop\nshop.currency=EUR");
+
+ // one scan, both files: the reload can apply the property before it
builds the route
+ assertThat(scanNames()).containsExactly("application.properties",
"shop.yaml");
+ assertThat(scanNames()).isEmpty();
+ }
+
+ @Test
+ public void testAFileWrittenWithTheSameLengthIsStillAChange() throws
Exception {
+ Path f = dir.resolve("shop.yaml");
+ Files.writeString(f, "one");
+ scanNames();
+
+ // rewritten with the same length, so only the modification time tells
them apart
+ Files.setLastModifiedTime(f,
java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() - 5000));
+ assertThat(scanNames()).containsExactly("shop.yaml");
+ }
+
+ @Test
+ public void testADeletedFileIsAChange() throws Exception {
+ Files.writeString(dir.resolve("shop.yaml"), "one");
+ Files.writeString(dir.resolve("other.yaml"), "one");
+ scanNames();
+
+ Files.delete(dir.resolve("other.yaml"));
+ assertThat(scanNames()).containsExactly("other.yaml");
+ // and only once
+ assertThat(scanNames()).isEmpty();
+ }
+
+ @Test
+ public void testFilesTheFilterRejectsAreNotChanges() throws Exception {
+ Files.writeString(dir.resolve("shop.yaml"), "one");
+ Files.writeString(dir.resolve("notes.txt"), "not mine");
+ assertThat(scanNames()).containsExactly("shop.yaml");
+
+ Files.writeString(dir.resolve("notes.txt"), "still not mine");
+ assertThat(scanNames()).isEmpty();
+ }
+
+ @Test
+ public void testASubdirectoryIsFoundWithoutRegisteringIt() throws
Exception {
+ // a tree created while running: the watch service only reported what
was registered when the event happened,
+ // which needed a workaround of its own (CAMEL-24862); a scan walks
the tree every time
+ Path sub = dir.resolve("src/main/java/com/acme");
+ Files.createDirectories(sub);
+ Files.writeString(sub.resolve("Bean.yaml"), "one");
+ assertThat(scanNames()).containsExactly("Bean.yaml");
+ }
+
+ @Test
+ public void
testAModificationTimeInTheFutureIsReportedRatherThanWaitedOut() throws
Exception {
+ // a clock askew on a network share, or a touch -t: waiting for it to
settle would mean waiting for the clock
+ strategy.setStableTimeout(60_000);
+ Path f = dir.resolve("shop.yaml");
+ Files.writeString(f, "one");
+ Files.setLastModifiedTime(f,
java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() +
60_000));
+ assertThat(scanNames()).containsExactly("shop.yaml");
+ }
+
+ @Test
+ public void testASaveStillBeingWrittenIsLeftForTheNextScan() throws
Exception {
+ strategy.setStableTimeout(60_000);
+ Files.writeString(dir.resolve("shop.yaml"), "one");
+
+ // just written, so not reported yet
+ assertThat(scanNames()).isEmpty();
+
+ // once it has settled it is
+ strategy.setStableTimeout(0);
+ assertThat(scanNames()).containsExactly("shop.yaml");
+ }
+
+ 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 d, IOException exc)
throws IOException {
+ Files.delete(d);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/support/FileWatcherReloadOrderTest.java
b/core/camel-core/src/test/java/org/apache/camel/support/FileWatcherReloadOrderTest.java
new file mode 100644
index 000000000000..b38ec884cdf0
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/support/FileWatcherReloadOrderTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.support;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25032: the properties of one batch of changed files are reloaded
before the routes are, so a route saved
+ * together with a property it uses is built with that property in place
instead of failing on it.
+ */
+public class FileWatcherReloadOrderTest {
+
+ private static List<String> order(String... names) {
+ List<File> files = new ArrayList<>();
+ for (String n : names) {
+ files.add(new File(n));
+ }
+ FileWatcherResourceReloadStrategy.orderPropertiesFirst(files);
+ return files.stream().map(File::getName).toList();
+ }
+
+ @Test
+ public void testPropertiesComeFirst() {
+ assertThat(order("shop.camel.yaml", "application.properties"))
+ .containsExactly("application.properties", "shop.camel.yaml");
+ }
+
+ @Test
+ public void testPropertiesAlreadyFirstStayFirst() {
+ assertThat(order("application.properties", "shop.camel.yaml"))
+ .containsExactly("application.properties", "shop.camel.yaml");
+ }
+
+ @Test
+ public void testTheOrderWithinAKindIsKept() {
+ // a stable sort: two properties files, and two route files, keep the
order they were reported in
+ assertThat(order("b.camel.yaml", "application.properties",
"a.camel.yaml", "application-prod.properties"))
+ .containsExactly("application.properties",
"application-prod.properties", "b.camel.yaml",
+ "a.camel.yaml");
+ }
+
+ @Test
+ public void testNoPropertiesIsUnchanged() {
+ assertThat(order("b.camel.yaml", "a.camel.yaml", "Bean.java"))
+ .containsExactly("b.camel.yaml", "a.camel.yaml", "Bean.java");
+ }
+}
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java
b/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java
index 422340d2a48f..59945b15365c 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/FileWatcherResourceReloadStrategy.java
@@ -23,61 +23,58 @@ import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
-import java.nio.file.WatchEvent;
-import java.nio.file.WatchKey;
-import java.nio.file.WatchService;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.Iterator;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
-import java.util.concurrent.TimeUnit;
-import org.apache.camel.RuntimeCamelException;
import org.apache.camel.api.management.ManagedAttribute;
import org.apache.camel.api.management.ManagedResource;
import org.apache.camel.spi.CompileStrategy;
import org.apache.camel.spi.Resource;
+import org.apache.camel.spi.ResourceReload;
import org.apache.camel.util.FileUtil;
-import org.apache.camel.util.IOHelper;
-import org.apache.camel.util.ObjectHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
-import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
-import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
-
/**
* A file based {@link org.apache.camel.spi.ResourceReloadStrategy} which
watches a file folder for modified files and
* reload on file changes.
* <p/>
- * This implementation uses the JDK {@link WatchService} to watch for when
files are created or modified. Mac OS X users
- * should be noted the osx JDK does not support native file system changes and
therefore the watch service is much
- * slower than on Linux or Windows systems.
+ * The folder is scanned on an interval and each file's modification time and
size compared with the previous scan, the
+ * way the file component finds changed files. A scan therefore reports all
the files of one save together, which is
+ * what a reload needs: a route is built with the properties of the moment, so
a route and a property saved together
+ * must be reloaded together (CAMEL-25032). The JDK {@link
java.nio.file.WatchService} reports one file at a time with
+ * no batch boundary, and on macOS has no native backend at all, where it fell
back to a poll of about ten seconds
+ * (CAMEL-25041).
*/
@ManagedResource(description = "Managed FileWatcherResourceReloadStrategy")
public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySupport {
private static final Logger LOG =
LoggerFactory.getLogger(FileWatcherResourceReloadStrategy.class);
- WatchService watcher;
ExecutorService executorService;
WatchFileChangesTask task;
- Map<WatchKey, Path> folderKeys;
- Set<Path> watchedFolders;
- WatchEvent.Modifier watchModifier;
+ /** The modification time and length of every file of the previous scan,
by path: what a change is measured from. */
+ final Map<String, long[]> known = new HashMap<>();
/** The compile work directory, resolved once at start (null when there is
none). */
Path compileWorkDir;
FileFilter fileFilter;
String folder;
boolean isRecursive;
boolean scheduler = true;
- long pollTimeout = 2000;
+ long pollTimeout = 1000;
+ /**
+ * A file modified less than this ago is left for the next scan: a save
still being written would otherwise be
+ * reloaded half-finished. The file component leaves a file alone the same
way.
+ */
+ long stableTimeout = 200;
public FileWatcherResourceReloadStrategy() {
setRecursive(false);
@@ -110,12 +107,20 @@ public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySup
}
/**
- * Sets the poll timeout in millis. The default value is 2000.
+ * Sets how often the folder is scanned for changed files, in millis. The
default value is 1000.
*/
public void setPollTimeout(long pollTimeout) {
this.pollTimeout = pollTimeout;
}
+ /**
+ * Sets how long a file must have been unchanged before a scan reports it,
in millis. The default value is 200, so
+ * that a save still being written is reloaded once it is complete and not
half-finished.
+ */
+ public void setStableTimeout(long stableTimeout) {
+ this.stableTimeout = stableTimeout;
+ }
+
@ManagedAttribute(description = "Folder being watched")
public String getFolder() {
return folder;
@@ -167,54 +172,16 @@ public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySup
LOG.info(msg);
}
- WatchEvent.Modifier modifier = null;
-
- // if its mac OSX then attempt to apply workaround or warn its
slower
- String os = ObjectHelper.getSystemProperty("os.name", "");
- if (os.toLowerCase(Locale.US).startsWith("mac")) {
- // this modifier can speedup the scanner on mac osx (as java
on mac has no native file notification integration)
- Class<WatchEvent.Modifier> clazz =
getCamelContext().getClassResolver()
-
.resolveClass("com.sun.nio.file.SensitivityWatchEventModifier",
WatchEvent.Modifier.class);
- if (clazz != null) {
- WatchEvent.Modifier[] modifiers = clazz.getEnumConstants();
- for (WatchEvent.Modifier mod : modifiers) {
- if ("HIGH".equals(mod.name())) {
- modifier = mod;
- break;
- }
- }
- }
- if (modifier != null) {
- LOG.debug(
- "On Mac OS X the JDK WatchService is slow by
default so enabling SensitivityWatchEventModifier.HIGH as workaround");
- } else {
- LOG.warn(
- "On Mac OS X the JDK WatchService is slow and it
may take up till 10 seconds to notice file changes");
- }
- }
-
- try {
- Path path = dir.toPath();
- watcher = path.getFileSystem().newWatchService();
- // we cannot support deleting files as we don't know which
routes that would be
- this.watchModifier = modifier;
- this.compileWorkDir = resolveCompileWorkDir();
- if (isRecursive) {
- this.folderKeys = new HashMap<>();
- this.watchedFolders = new HashSet<>();
- registerRecursive(watcher, path, modifier);
- } else {
- registerPathToWatcher(modifier, path, watcher);
- }
+ this.compileWorkDir = resolveCompileWorkDir();
+ // the files as they are now are the starting point, not a change:
record them without reloading
+ known.clear();
+ scan();
- task = new WatchFileChangesTask(watcher, path);
+ task = new WatchFileChangesTask(dir.toPath());
- executorService =
getCamelContext().getExecutorServiceManager().newSingleThreadExecutor(this,
- "FileWatcherReloadStrategy");
- executorService.submit(task);
- } catch (IOException e) {
- throw RuntimeCamelException.wrapRuntimeCamelException(e);
- }
+ executorService =
getCamelContext().getExecutorServiceManager().newSingleThreadExecutor(this,
+ "FileWatcherReloadStrategy");
+ executorService.submit(task);
}
}
@@ -222,61 +189,94 @@ public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySup
return "Starting ReloadStrategy to watch directory: " + dir;
}
- private WatchKey registerPathToWatcher(WatchEvent.Modifier modifier, Path
path, WatchService watcher) throws IOException {
- WatchKey key;
- if (modifier != null) {
- key = path.register(watcher, new WatchEvent.Kind<?>[] {
ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE }, modifier);
- } else {
- key = path.register(watcher, ENTRY_CREATE, ENTRY_MODIFY,
ENTRY_DELETE);
- }
- return key;
- }
-
- private void registerRecursive(final WatchService watcher, final Path
root, final WatchEvent.Modifier modifier)
- throws IOException {
- Files.walkFileTree(root, new SimpleFileVisitor<>() {
- @Override
- public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) throws IOException {
- if (isCompileWorkDir(dir)) {
- return FileVisitResult.SKIP_SUBTREE;
- }
- WatchKey key = registerPathToWatcher(modifier, dir, watcher);
- folderKeys.put(key, dir);
- watchedFolders.add(dir);
- return FileVisitResult.CONTINUE;
- }
- });
+ /**
+ * The properties of a change come before the rest. {@link
RouteWatcherReloadStrategy} reloads a change as one batch
+ * and does not need this, but a listener reloaded one file at a time
does: a route built before the property it
+ * uses was applied fails with "Property with key [x] not found", and the
watch service reports the files of one
+ * save in no particular order. The sort is stable, so files of the same
kind keep the order they were reported in.
+ */
+ static void orderPropertiesFirst(List<File> changed) {
+ changed.sort(Comparator.comparingInt(f ->
f.getName().endsWith(".properties") ? 0 : 1));
}
/**
- * Registers a directory created while watching recursively, and its
subdirectories, and collects the files already
- * in them as changes: a tree such as src/main/java/com/acme is usually
created with its first file in it, before
- * the watcher can see the directory.
+ * The files whose modification time or length changed since the previous
scan, plus the files that are gone. The
+ * snapshot is updated as it goes, so a file is reported once per change.
+ * <p/>
+ * A file modified less than {@link #stableTimeout} ago is left for the
next scan: a save still being written would
+ * otherwise be reloaded half-finished.
*/
- private void registerNewDirectory(Path dir, List<File> changed) {
+ protected List<File> scan() {
+ List<File> changed = new ArrayList<>();
+ Set<String> seen = new HashSet<>();
+ Path root = new File(folder).toPath();
try {
- Files.walkFileTree(dir, new SimpleFileVisitor<>() {
+ Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
- public FileVisitResult preVisitDirectory(Path d,
BasicFileAttributes attrs) throws IOException {
- if (isCompileWorkDir(d) || watchedFolders.contains(d)) {
+ public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) {
+ if (isCompileWorkDir(dir)) {
+ // the class files the runtime writes while compiling
a Java source are not changes of ours,
+ // and would trigger a reload, which compiles again,
which writes again (CAMEL-24862)
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ if (!isRecursive && !dir.equals(root)) {
return FileVisitResult.SKIP_SUBTREE;
}
- WatchKey k = registerPathToWatcher(watchModifier, d,
watcher);
- folderKeys.put(k, d);
- watchedFolders.add(d);
- LOG.debug("Watching new directory: {}", d);
return FileVisitResult.CONTINUE;
}
@Override
- public FileVisitResult visitFile(Path f, BasicFileAttributes
attrs) {
- changed.add(f.toFile());
+ public FileVisitResult visitFile(Path file,
BasicFileAttributes attrs) {
+ if (!attrs.isRegularFile()) {
+ return FileVisitResult.CONTINUE;
+ }
+ File f = file.toFile();
+ if (fileFilter != null && !fileFilter.accept(f)) {
+ return FileVisitResult.CONTINUE;
+ }
+ String name = FileUtil.compactPath(f.getPath());
+ seen.add(name);
+ long modified = attrs.lastModifiedTime().toMillis();
+ long length = attrs.size();
+ long[] previous = known.get(name);
+ if (previous != null && previous[0] == modified &&
previous[1] == length) {
+ return FileVisitResult.CONTINUE;
+ }
+ long age = System.currentTimeMillis() - modified;
+ if (age >= 0 && age < stableTimeout) {
+ // still being written: leave it, and let the next
scan report it. A modification time in the
+ // future (a clock askew on a network share, a touch
-t) is reported now rather than waited out
+ seen.remove(name);
+ return FileVisitResult.CONTINUE;
+ }
+ known.put(name, new long[] { modified, length });
+ LOG.debug("Detected Modified/Created file: {}", name);
+ changed.add(f);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFileFailed(Path file, IOException
exc) {
+ // a file that went away between the walk and reading it
is a change like any other
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
- LOG.warn("Cannot watch new directory: {} due to: {}. This
exception is ignored.", dir, e.getMessage(), e);
+ LOG.warn("Cannot scan directory: {} due to: {}. This exception is
ignored.", folder, e.getMessage(), e);
+ return changed;
+ }
+ // what the snapshot has and the scan did not see is gone; a file
still being written was taken out of seen,
+ // so only drop what the scan positively did not find
+ for (Iterator<Map.Entry<String, long[]>> it =
known.entrySet().iterator(); it.hasNext();) {
+ Map.Entry<String, long[]> entry = it.next();
+ File f = new File(entry.getKey());
+ if (!seen.contains(entry.getKey()) && !f.exists()) {
+ LOG.debug("Detected Deleted file: {}", entry.getKey());
+ it.remove();
+ changed.add(f);
+ }
}
+ return changed;
}
/**
@@ -304,9 +304,6 @@ public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySup
executorService = null;
}
- if (watcher != null) {
- IOHelper.close(watcher);
- }
}
/**
@@ -314,12 +311,10 @@ public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySup
*/
protected class WatchFileChangesTask implements Runnable {
- private final WatchService watcher;
private final Path folder;
private volatile boolean running;
- public WatchFileChangesTask(WatchService watcher, Path folder) {
- this.watcher = watcher;
+ public WatchFileChangesTask(Path folder) {
this.folder = folder;
}
@@ -334,79 +329,19 @@ public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySup
while (isStarting() || isRunAllowed()) {
running = true;
- WatchKey key;
try {
- LOG.trace("FileReloadStrategy is polling for file changes
in directory: {}", folder);
- // wait for a key to be available
- key = watcher.poll(pollTimeout, TimeUnit.MILLISECONDS);
- } catch (InterruptedException ex) {
- LOG.info("Interrupted while polling for file changes");
+ Thread.sleep(pollTimeout);
+ } catch (InterruptedException e) {
+ LOG.info("Interrupted while waiting to scan for file
changes");
Thread.currentThread().interrupt();
break;
}
- if (key != null) {
- Path pathToReload;
- if (isRecursive) {
- pathToReload = folderKeys.get(key);
- } else {
- pathToReload = folder;
- }
-
- // the files of the events, plus the files of a directory
created under a watched one
- // when recursive (registered here, since the watch
service only reports what is registered
- // at the time; a class under src/main/java added while
running was never seen, CAMEL-24862)
- List<File> changed = new ArrayList<>();
- for (WatchEvent<?> event : key.pollEvents()) {
- WatchEvent<Path> we = (WatchEvent<Path>) event;
- Path path = we.context();
- File file = pathToReload.resolve(path).toFile();
- LOG.trace("File watch-event: {} on file: {}", we,
file);
- if (file.isDirectory()) {
- if (isRecursive && we.kind() == ENTRY_CREATE &&
!isCompileWorkDir(file.toPath())) {
- registerNewDirectory(file.toPath(), changed);
- }
- continue;
- }
- if (isCompileWorkDir(file.toPath().getParent())) {
- // a class file the runtime wrote while compiling:
not a change of ours
- continue;
- }
- changed.add(file);
- }
- for (File file : changed) {
- String name = FileUtil.compactPath(file.getPath());
- LOG.debug("Detected Modified/Created file: {}", name);
- boolean accept = fileFilter == null ||
fileFilter.accept(file);
- if (accept) {
- LOG.debug("Accepted Modified/Created file: {}",
name);
- try {
- setLastError(null);
- // must use file resource loader as we cannot
load from classpath
- Resource resource
- =
PluginHelper.getResourceLoader(getCamelContext()).resolveResource("file:" +
name);
- getResourceReload().onReload(name, resource);
- incSucceededCounter();
- } catch (Exception e) {
- setLastError(e);
- incFailedCounter();
- // the same event a failed context reload
emits, so a listener can act on the file
-
EventHelper.notifyContextReloadFailure(getCamelContext(), name, e);
- String msg = e.getMessage();
- if (msg.endsWith(".")) {
- msg = msg.substring(0, msg.length() - 1);
- }
- LOG.warn("Error reloading routes from file: {}
due to: {}. This exception is ignored.", name,
- msg, e);
- }
- }
- }
-
- // the key must be reset after processed
- boolean valid = key.reset();
- if (!valid) {
- break;
- }
+ LOG.trace("FileReloadStrategy is scanning for file changes in
directory: {}", folder);
+ List<File> changed = scan();
+ if (!changed.isEmpty()) {
+ // the files of one scan are the files of one save:
reloaded together
+ onReloadBatch(changed);
}
}
@@ -416,4 +351,45 @@ public class FileWatcherResourceReloadStrategy extends
ResourceReloadStrategySup
}
}
+ /**
+ * Reloads the files of one change. The default reloads them one at a
time, which is what a listener of a single
+ * {@link ResourceReload} expects; {@link RouteWatcherReloadStrategy}
overrides it to apply the properties of the
+ * batch and then reload the routes once.
+ */
+ protected void onReloadBatch(List<File> changed) {
+ // the properties first: a listener that rebuilds something from them
should see them before the rest.
+ // A copy, as the list the caller passed is theirs and may not be
modifiable
+ List<File> files = new ArrayList<>(changed);
+ orderPropertiesFirst(files);
+ for (File file : files) {
+ reloadFile(file);
+ }
+ }
+
+ /**
+ * Reloads one file through the {@link ResourceReload} listener, counting
it and reporting a failure the way a
+ * failed context reload is reported, so a listener can act on the file. A
failure is logged and not rethrown: one
+ * unreadable file does not stop the watcher.
+ */
+ protected void reloadFile(File file) {
+ String name = FileUtil.compactPath(file.getPath());
+ try {
+ setLastError(null);
+ // must use file resource loader as we cannot load from classpath
+ Resource resource =
PluginHelper.getResourceLoader(getCamelContext()).resolveResource("file:" +
name);
+ getResourceReload().onReload(name, resource);
+ incSucceededCounter();
+ } catch (Exception e) {
+ setLastError(e);
+ incFailedCounter();
+ // the same event a failed context reload emits, so a listener can
act on the file
+ EventHelper.notifyContextReloadFailure(getCamelContext(), name, e);
+ String msg = e.getMessage();
+ if (msg != null && msg.endsWith(".")) {
+ msg = msg.substring(0, msg.length() - 1);
+ }
+ LOG.warn("Error reloading routes from file: {} due to: {}. This
exception is ignored.", name, msg, e);
+ }
+ }
+
}
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 40f5ee7088e3..cd2e077603e6 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
@@ -73,6 +73,8 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
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<>();
+ /** The sources of the last failed reload: retried when the properties
change, as a missing property may be why. */
+ private final List<Resource> failedSources = new ArrayList<>();
public RouteWatcherReloadStrategy() {
}
@@ -204,7 +206,7 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
pr.onReload(resource.getLocation(), changed);
// trigger all routes to be reloaded
if (reloadRoutes) {
- onRouteReload(null, false);
+ retryFailedOrReloadAll();
}
} else {
// this may be a new properties file, so we need to add as new
known location
@@ -224,6 +226,27 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
return reloaded;
}
+ /**
+ * Reloads the routes after the properties changed. A file whose reload
failed before is loaded from disk again
+ * rather than left on the content that last ran: a missing property is a
common reason for that failure, and the
+ * property may be what just changed. Without this the file only loads on
its next save, so an application whose
+ * route uses a property added afterwards keeps running the previous route
(CAMEL-25032).
+ */
+ private void retryFailedOrReloadAll() {
+ if (failedSources.isEmpty()) {
+ onRouteReload(null, false);
+ return;
+ }
+ List<Resource> retry = new ArrayList<>(failedSources);
+ LOG.info("Reloading {} file(s) that failed to load before the
properties changed", retry.size());
+ try {
+ onRouteReload(retry, false);
+ } catch (Exception e) {
+ // the retry failed for its own reason, which
restorePreviousRoutes logged; the properties did reload
+ LOG.debug("Retrying the failed reload after the properties changed
failed too: {}", e.getMessage(), e);
+ }
+ }
+
private String getPropertiesByLocation(String loc) {
PropertiesComponent pc = getCamelContext().getPropertiesComponent();
for (String s : pc.getLocations()) {
@@ -237,6 +260,54 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
return null;
}
+ /**
+ * One save of several files is reloaded as one change: the properties
(and any recompiled Groovy) are applied
+ * first, each without reloading the routes, and then the routes of the
whole change are reloaded once. A route
+ * saved together with a property it uses is therefore built with that
property already in place, instead of failing
+ * on it and being restored (CAMEL-25032).
+ * <p/>
+ * If the batch fails, the files are reloaded one at a time: the reload
then fails on the one file that is wrong and
+ * says which it was, so a mistake in one file does not hide behind the
others (CAMEL-24860).
+ */
+ @Override
+ protected void onReloadBatch(List<File> changed) {
+ if (changed.size() < 2) {
+ super.onReloadBatch(changed);
+ return;
+ }
+ List<Resource> routes = new ArrayList<>();
+ boolean others = false;
+ try {
+ setLastError(null);
+ for (File file : changed) {
+ String name = FileUtil.compactPath(file.getPath());
+ Resource resource =
PluginHelper.getResourceLoader(getCamelContext()).resolveResource("file:" +
name);
+ if (name.endsWith(".properties")) {
+ others |= onPropertiesReload(resource, false);
+ } else if (name.endsWith(".groovy")) {
+ others |= onGroovyReload(resource, false);
+ } else {
+ routes.add(resource);
+ }
+ }
+ if (!routes.isEmpty()) {
+ onRouteReload(routes, false);
+ } else if (others) {
+ // only properties or Groovy changed, so the routes are
reloaded to pick them up
+ retryFailedOrReloadAll();
+ }
+ // the counter is files reloaded, as it is when they are reloaded
one at a time
+ for (int i = 0; i < changed.size(); i++) {
+ incSucceededCounter();
+ }
+ } catch (Exception e) {
+ LOG.debug("Reloading {} changed file(s) together failed, reloading
them one at a time to find the file"
+ + " that is wrong: {}",
+ changed.size(), e.getMessage(), e);
+ super.onReloadBatch(changed);
+ }
+ }
+
protected boolean onGroovyReload(Resource resource, boolean reloadRoutes)
throws Exception {
GroovyScriptCompiler compiler
=
getCamelContext().getCamelContextExtension().getContextPlugin(GroovyScriptCompiler.class);
@@ -327,6 +398,7 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
// update okay, so clear as we do not need to remember those
anymore
previousSources.clear();
+ failedSources.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)
@@ -401,8 +473,13 @@ public class RouteWatcherReloadStrategy extends
FileWatcherResourceReloadStrateg
} catch (Exception e) {
// the routes that ran before were removed above and the new ones
failed to load: the app has no routes
// until the next successful reload. Restore the previous routes
now, without the failed resources, so a
- // mistake in one file leaves the rest running (CAMEL-24860); the
failed file loads on its next save
+ // mistake in one file leaves the rest running (CAMEL-24860); the
failed file loads on its next save,
+ // or when the properties change and a missing property was the
reason it failed (CAMEL-25032)
restorePreviousRoutes(resources, e);
+ failedSources.clear();
+ if (resources != null) {
+ failedSources.addAll(resources);
+ }
throw RuntimeCamelException.wrapRuntimeException(e);
}
}
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 4870b4b1242d..411e8a2d67f7 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
@@ -2946,3 +2946,17 @@ This is the intended behaviour and makes the `keepOpen`
toggle deterministic, bu
relied on the setter being inert until an exchange arrived must be aware of
the change.
Setting `keepOpen` back to `false` remains deferred: the half-open timer
attempts to close the circuit on
its next tick (after `halfOpenAfter` milliseconds, default 30 s).
+
+== Route reload
+
+Dev-mode route reloading now finds changed files by scanning the watched
folder on an interval and comparing each
+file's modification time and length with the previous scan, instead of using
the JDK `WatchService`. A scan reports
+all the files of one save together, which is what a reload needs: a route is
built with the properties of the moment,
+so a route and a property saved together must be reloaded together. It also
removes the macOS penalty, where Java has
+no native file notification and the watch service fell back to a poll of about
ten seconds.
+
+`FileWatcherResourceReloadStrategy.setPollTimeout` is now how often the folder
is scanned, and its default changed
+from 2000 to 1000 milliseconds. On Linux and Windows a change is therefore
noticed within a second rather than
+immediately; on macOS within a second rather than up to ten. A new
`setStableTimeout` (default 200 milliseconds)
+leaves a file that was only just modified for the next scan, so a save still
being written is not reloaded
+half-finished.
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadBatchTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadBatchTest.java
new file mode 100644
index 000000000000..9b7694c9d5bd
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadBatchTest.java
@@ -0,0 +1,228 @@
+/*
+ * 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.File;
+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 java.util.Collection;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.dsl.yaml.support.YamlTestSupport;
+import org.apache.camel.spi.PropertiesReload;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.support.RouteWatcherReloadStrategy;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25032: one save of several files is reloaded as one change. The
properties of the change are applied first,
+ * each without reloading the routes, and the routes are then reloaded once -
so a route saved together with a property
+ * it uses is built with that property in place and does not fail at all.
+ */
+class RouteReloadBatchTest extends YamlTestSupport {
+
+ /** Subclass to drive the batch callback by hand, without a live file
watcher, and to count the route reloads. */
+ static class TestableStrategy extends RouteWatcherReloadStrategy {
+ final AtomicInteger routeReloads = new AtomicInteger();
+
+ TestableStrategy(String dir) {
+ super(dir);
+ }
+
+ @Override
+ public void doStart() throws Exception {
+ super.doStart();
+ }
+
+ @Override
+ public void doStop() throws Exception {
+ super.doStop();
+ }
+
+ @Override
+ public void onReloadBatch(List<File> changed) {
+ super.onReloadBatch(changed);
+ }
+
+ @Override
+ protected void onRouteReload(Collection<Resource> resources, boolean
removeEverything) {
+ routeReloads.incrementAndGet();
+ super.onRouteReload(resources, removeEverything);
+ }
+ }
+
+ static class NoopPropertiesReload extends ServiceSupport implements
PropertiesReload {
+ @Override
+ public void onReload(String name, Properties properties) {
+ }
+ }
+
+ private Path dir;
+ private Path route;
+ private Path other;
+ private Path props;
+
+ @Override
+ public void doSetup() throws Exception {
+ dir = Files.createTempDirectory("camel-reload-batch");
+ route = dir.resolve("shop.camel.yaml");
+ other = dir.resolve("other.camel.yaml");
+ props = dir.resolve("application.properties");
+ Files.writeString(props, "shop.name=Camel Shop\n");
+ Files.writeString(route, """
+ - route:
+ id: shop
+ from:
+ uri: direct:shop
+ steps:
+ - to:
+ uri: mock:started
+ """);
+ Files.writeString(other, """
+ - route:
+ id: other
+ from:
+ uri: direct:other
+ steps:
+ - to:
+ uri: mock:other
+ """);
+ context.getPropertiesComponent().setLocation("file:" + props);
+ context.addService(new NoopPropertiesReload());
+ context.start();
+ loadRoutes(ResourceHelper.resolveResource(context, "file:" + route),
+ ResourceHelper.resolveResource(context, "file:" + other));
+ }
+
+ @Override
+ public void doCleanup() throws Exception {
+ deleteRecursively(dir);
+ }
+
+ @Test
+ void aRouteAndThePropertyItUsesSavedTogetherAreOneReloadThatDoesNotFail()
throws Exception {
+ TestableStrategy strategy = new TestableStrategy(dir.toString());
+ strategy.setCamelContext(context);
+ strategy.setPattern("*.yaml,*.properties");
+ strategy.doStart();
+ try {
+ // the save: one route starts using a property, the property is
added, and a second route file changes too
+ Files.writeString(route, """
+ - route:
+ id: shop
+ from:
+ uri: direct:shop
+ steps:
+ - to:
+ uri: "mock:{{shop.currency}}"
+ """);
+ Files.writeString(other, """
+ - route:
+ id: other
+ from:
+ uri: direct:other
+ steps:
+ - to:
+ uri: mock:other-v2
+ """);
+ Files.writeString(props, "shop.name=Camel
Shop\nshop.currency=EUR\n");
+
+ // the route files first, as the watch service may well report
them first
+ strategy.onReloadBatch(List.of(route.toFile(), other.toFile(),
props.toFile()));
+
+ // one reload for the whole save, not one per file: the properties
are applied first, without reloading the
+ // routes for each, so the route that uses the new property is
built once with it already in place
+ assertThat(strategy.routeReloads).hasValue(1);
+ assertThat(strategy.getLastError()).isNull();
+ assertThat(strategy.getFailedCounter()).isZero();
+
assertThat(context.getRouteController().getRouteStatus("shop")).isEqualTo(ServiceStatus.Started);
+
assertThat(context.getRouteController().getRouteStatus("other")).isEqualTo(ServiceStatus.Started);
+ assertThat(context.hasEndpoint("mock://EUR")).isNotNull();
+ assertThat(context.hasEndpoint("mock://other-v2")).isNotNull();
+ } finally {
+ strategy.doStop();
+ }
+ }
+
+ @Test
+ void aBatchWithOneBrokenFileStillLoadsTheOthers() throws Exception {
+ TestableStrategy strategy = new TestableStrategy(dir.toString());
+ strategy.setCamelContext(context);
+ strategy.setPattern("*.yaml,*.properties");
+ strategy.doStart();
+ try {
+ // one of the two saved files has the mistake of CAMEL-24850:
pollEnrich takes an expression, not a uri
+ Files.writeString(route, """
+ - route:
+ id: shop
+ from:
+ uri: direct:shop
+ steps:
+ - pollEnrich:
+ uri: file:./order.json
+ """);
+ Files.writeString(other, """
+ - route:
+ id: other
+ from:
+ uri: direct:other
+ steps:
+ - to:
+ uri: mock:other-v2
+ """);
+
+ // the batch fails, so the files are reloaded one at a time and
the good one gets through
+ strategy.onReloadBatch(List.of(route.toFile(), other.toFile()));
+
+
assertThat(context.getRouteController().getRouteStatus("other")).isEqualTo(ServiceStatus.Started);
+ assertThat(context.hasEndpoint("mock://other-v2")).isNotNull();
+ assertThat(context.getRoute("shop")).isNull();
+ } finally {
+ strategy.doStop();
+ }
+ }
+
+ 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;
+ }
+ });
+ }
+}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadPropertiesRetryTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadPropertiesRetryTest.java
new file mode 100644
index 000000000000..4288d29a163f
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/RouteReloadPropertiesRetryTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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 java.util.Collection;
+import java.util.Properties;
+
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.dsl.yaml.support.YamlTestSupport;
+import org.apache.camel.spi.PropertiesReload;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.support.RouteWatcherReloadStrategy;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25032: a reload that failed because a property was missing is retried
when the properties change. Dev mode
+ * reloads one file at a time, so a route saved with a property that is added
in the same edit fails to start; before
+ * this the failed file only loaded again on its next save, so the application
kept running the previous route even
+ * though the property was there.
+ */
+class RouteReloadPropertiesRetryTest extends YamlTestSupport {
+
+ /** Subclass to drive the reload callbacks by hand, without a live file
watcher. */
+ static class TestableStrategy extends RouteWatcherReloadStrategy {
+ TestableStrategy(String dir) {
+ super(dir);
+ }
+
+ @Override
+ public void doStart() throws Exception {
+ super.doStart();
+ }
+
+ @Override
+ public void doStop() throws Exception {
+ super.doStop();
+ }
+
+ @Override
+ public void onRouteReload(Collection<Resource> resources, boolean
removeEverything) {
+ super.onRouteReload(resources, removeEverything);
+ }
+ }
+
+ /** The service onPropertiesReload needs to see: dev mode registers one,
and only then are properties reloaded. */
+ static class NoopPropertiesReload extends ServiceSupport implements
PropertiesReload {
+ @Override
+ public void onReload(String name, Properties properties) {
+ }
+ }
+
+ private Path dir;
+ private Path route;
+ private Path props;
+
+ @Override
+ public void doSetup() throws Exception {
+ dir = Files.createTempDirectory("camel-reload-props");
+ route = dir.resolve("shop.camel.yaml");
+ props = dir.resolve("application.properties");
+ Files.writeString(props, "shop.name=Camel Shop\n");
+ Files.writeString(route, """
+ - route:
+ id: shop
+ from:
+ uri: direct:shop
+ steps:
+ - to:
+ uri: mock:started
+ """);
+ context.getPropertiesComponent().setLocation("file:" + props);
+ context.addService(new NoopPropertiesReload());
+ context.start();
+ loadRoutes(ResourceHelper.resolveResource(context, "file:" + route));
+ }
+
+ @Override
+ public void doCleanup() throws Exception {
+ deleteRecursively(dir);
+ }
+
+ @Test
+ void aReloadThatFailedOnAMissingPropertyLoadsWhenThePropertyArrives()
throws Exception {
+ TestableStrategy strategy = new TestableStrategy(dir.toString());
+ strategy.setCamelContext(context);
+ strategy.setPattern("*.yaml,*.properties");
+ strategy.doStart();
+ try {
+ // one successful reload, so the content that runs is remembered
+ strategy.getResourceReload().onReload(route.toString(),
+ ResourceHelper.resolveResource(context, "file:" + route));
+
assertThat(context.getRouteController().getRouteStatus("shop")).isEqualTo(ServiceStatus.Started);
+
+ // the route is saved using a property that is not in
application.properties yet, which is what an edit
+ // that adds both looks like when dev mode reloads the route file
first
+ Files.writeString(route, """
+ - route:
+ id: shop
+ from:
+ uri: direct:shop
+ steps:
+ - to:
+ uri: "mock:{{shop.currency}}"
+ """);
+ Exception failure = null;
+ try {
+ strategy.getResourceReload().onReload(route.toString(),
+ ResourceHelper.resolveResource(context, "file:" +
route));
+ } catch (Exception e) {
+ failure = e;
+ }
+
+ // it fails, and the version that ran is restored: the new
endpoint is nowhere
+ assertThat(failure).isNotNull();
+
assertThat(context.getRouteController().getRouteStatus("shop")).isEqualTo(ServiceStatus.Started);
+ assertThat(context.hasEndpoint("mock://EUR")).isNull();
+
+ // the property arrives
+ Files.writeString(props, "shop.name=Camel
Shop\nshop.currency=EUR\n");
+ strategy.getResourceReload().onReload(props.toString(),
+ ResourceHelper.resolveResource(context, "file:" + props));
+
+ // the file that failed is loaded from disk again, so the route
that runs is the saved one
+
assertThat(context.getRouteController().getRouteStatus("shop")).isEqualTo(ServiceStatus.Started);
+ assertThat(context.hasEndpoint("mock://EUR")).isNotNull();
+ } finally {
+ strategy.doStop();
+ }
+ }
+
+ 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;
+ }
+ });
+ }
+}