From 5cdec9b9825a6a892d8701c9855a2a05e30b5d22 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?= *.apis.yaml / *.apis.yml files in that directory are included
* in lexicographical order. Includes are resolved recursively and cyclic include chains are rejected.
+ * Directory includes are not watched for YAML hot deployment; adding or removing files there does not
+ * trigger a reload automatically.
*
* include:
@@ -62,7 +64,8 @@ public class IncludeList {
*
*
* Each string is a path to either a YAML file or a directory. Relative paths are resolved
- * against the directory of the including file. Absolute paths are also supported.
+ * against the directory of the including file. Absolute paths are also supported. Directory
+ * entries are loaded during parsing, but they are not watched for YAML hot deployment.
*
*/
@MCChildElement(allowForeign = true)
diff --git a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java
index 23fc17e37e..8ebab075a4 100644
--- a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java
+++ b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java
@@ -25,7 +25,7 @@
import com.predic8.membrane.core.router.DefaultRouter;
import com.predic8.membrane.core.router.Router;
import com.predic8.membrane.core.router.RouterXmlBootstrap;
-import com.predic8.membrane.core.router.YamlRouterBootstrap;
+import com.predic8.membrane.core.router.hotdeploy.YamlRouterReloader;
import org.apache.commons.cli.ParseException;
import org.apache.commons.codec.binary.Hex;
import org.jetbrains.annotations.NotNull;
@@ -51,6 +51,7 @@
import static com.predic8.membrane.core.openapi.serviceproxy.OpenAPISpec.YesNoOpenAPIOption.YES;
import static com.predic8.membrane.core.openapi.util.OpenAPIUtil.isOpenAPIMisplacedError;
import static com.predic8.membrane.core.proxies.ApiInfo.logInfosAboutStartedProxies;
+import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter;
import static com.predic8.membrane.core.util.ExceptionUtil.concatMessageAndCauseMessages;
import static com.predic8.membrane.core.util.OSUtil.fixBackslashes;
import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI;
@@ -228,7 +229,7 @@ private static Router initRouterByYAML(MembraneCommandLine commandLine, String o
static Router initRouterByYAML(String location) throws Exception {
var router = new DefaultRouter();
- YamlRouterBootstrap.loadIntoRouter(router, location);
+ router.setConfigurationReloader(new YamlRouterReloader(router, loadIntoRouter(router, location)));
router.start();
logInfosAboutStartedProxies(router.getRuleManager());
logStartupMessage();
diff --git a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java
index 917557e265..2f7a64c18b 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/DefaultRouter.java
@@ -36,6 +36,7 @@
import com.predic8.membrane.core.proxies.RuleManager.RuleDefinitionSource;
import com.predic8.membrane.core.proxies.SSLableProxy;
import com.predic8.membrane.core.resolver.ResolverMap;
+import com.predic8.membrane.core.router.hotdeploy.ConfigurationReloader;
import com.predic8.membrane.core.router.hotdeploy.DefaultHotDeployer;
import com.predic8.membrane.core.router.hotdeploy.HotDeployer;
import com.predic8.membrane.core.transport.PortOccupiedException;
@@ -55,20 +56,11 @@
import org.springframework.context.support.AbstractRefreshableApplicationContext;
import javax.annotation.concurrent.GuardedBy;
-import java.io.File;
import java.io.IOException;
-import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.Collection;
-import java.util.List;
-import static com.predic8.membrane.annot.Constants.PRODUCT_NAME;
-import static com.predic8.membrane.annot.Constants.VERSION;
-import static com.predic8.membrane.core.proxies.ApiInfo.logInfosAboutStartedProxies;
import static com.predic8.membrane.core.proxies.RuleManager.RuleDefinitionSource.MANUAL;
import static com.predic8.membrane.core.util.DLPUtil.displayTraceWarning;
-import static com.predic8.membrane.core.util.text.TerminalColors.BRIGHT_CYAN;
-import static com.predic8.membrane.core.util.text.TerminalColors.RESET;
/*
* Responsibilities:
@@ -130,11 +122,6 @@ public class DefaultRouter extends AbstractRouter implements ApplicationContextA
private RuleReinitializer reinitializer;
- @GuardedBy("lock")
- private String yamlConfigurationLocation;
- @GuardedBy("lock")
- private List yamlTrackedFiles = List.of();
-
public DefaultRouter() {
log.debug("Creating new router.");
mainComponents = new DefaultMainComponents(this);
@@ -474,72 +461,29 @@ public RuleReinitializer getReinitializer() {
return reinitializer;
}
- public void setYamlConfiguration(String yamlConfigurationLocation, List trackedFiles) {
- synchronized (lock) {
- this.yamlConfigurationLocation = yamlConfigurationLocation;
- this.yamlTrackedFiles = trackedFiles.stream()
- .map(Path::toFile)
- .toList();
- }
- }
-
- public String getYamlConfigurationLocation() {
- synchronized (lock) {
- return yamlConfigurationLocation;
- }
- }
-
- public List getYamlTrackedFiles() {
- synchronized (lock) {
- return new ArrayList<>(yamlTrackedFiles);
- }
+ public void setConfigurationReloader(ConfigurationReloader configurationReloader) {
+ hotDeployer.setConfigurationReloader(configurationReloader);
}
- public boolean reloadYamlConfiguration() {
- String location;
- YamlRuntimeState previousState = null;
+ public boolean markReloading() {
synchronized (lock) {
if (reloading) {
return false;
}
reloading = true;
- location = yamlConfigurationLocation;
lock.notifyAll();
+ return true;
}
+ }
- try {
- if (location == null || location.isBlank()) {
- throw new IllegalStateException("No YAML configuration location is known.");
- }
-
- log.info("Reloading YAML configuration from {}.", location);
- validateYamlConfiguration(location);
- previousState = captureYamlRuntimeState();
- shutdownForYamlReload();
- resetForYamlReload();
- YamlRouterBootstrap.loadIntoRouter(this, location);
- start();
- disposeYamlRuntime(previousState);
- logInfosAboutStartedProxies(getRuleManager());
- log.info("{}{} {} up and running!{}", BRIGHT_CYAN(), PRODUCT_NAME, VERSION, RESET());
- log.info("YAML configuration reloaded successfully.");
- return true;
- } catch (Exception e) {
- log.error("Could not reload YAML configuration.", e);
- if (previousState != null) {
- return recoverPreviousYamlRuntime(previousState, e);
- }
- log.info("Keeping the previous YAML runtime because reload validation failed before shutdown.");
- return true;
- } finally {
- synchronized (lock) {
- reloading = false;
- lock.notifyAll();
- }
+ public void clearReloading() {
+ synchronized (lock) {
+ reloading = false;
+ lock.notifyAll();
}
}
- private void shutdownForYamlReload() {
+ public void stopRuntimeForReload() {
getRegistry().getBean(KubernetesWatcher.class).ifPresent(KubernetesWatcher::stop);
if (reinitializer != null)
@@ -553,7 +497,7 @@ private void shutdownForYamlReload() {
}
}
- private void resetForYamlReload() {
+ public void resetRuntime() {
synchronized (lock) {
mainComponents = new DefaultMainComponents(this);
configuration = new Configuration();
@@ -562,65 +506,11 @@ private void resetForYamlReload() {
}
}
- private void validateYamlConfiguration(String location) throws Exception {
- DefaultRouter candidate = new DefaultRouter();
- try {
- YamlRouterBootstrap.loadIntoRouter(candidate, location);
- } finally {
- candidate.getTimerManager().shutdown();
- candidate.closeRegistryIfSupported();
- }
- }
-
- private YamlRuntimeState captureYamlRuntimeState() {
- synchronized (lock) {
- return new YamlRuntimeState(
- mainComponents,
- configuration,
- initialized,
- reinitializer,
- yamlConfigurationLocation,
- new ArrayList<>(yamlTrackedFiles)
- );
- }
- }
-
- private boolean recoverPreviousYamlRuntime(YamlRuntimeState previousState, Exception originalError) {
- log.info("Attempting to recover the previous YAML runtime after reload failure.");
-
- restoreYamlRuntime(previousState);
-
- try {
- start();
- log.info("Recovered the previous YAML runtime after reload failure.");
- return true;
- } catch (Exception recoveryError) {
- log.error("Could not recover the previous YAML runtime after reload failure.", recoveryError);
- recoveryError.addSuppressed(originalError);
- return false;
- }
- }
-
- private void restoreYamlRuntime(YamlRuntimeState previousState) {
- synchronized (lock) {
- mainComponents = previousState.mainComponents();
- configuration = previousState.configuration();
- initialized = previousState.initialized();
- reinitializer = previousState.reinitializer();
- yamlConfigurationLocation = previousState.yamlConfigurationLocation();
- yamlTrackedFiles = new ArrayList<>(previousState.yamlTrackedFiles());
- }
- hotDeployer.setEnabled(previousState.configuration().isHotDeploy());
- }
-
- private void disposeYamlRuntime(YamlRuntimeState previousState) {
- if (previousState == null)
- return;
-
- if (previousState.reinitializer() != null)
- previousState.reinitializer().stop();
- previousState.mainComponents().getTimerManager().shutdown();
- closeRegistryIfSupported(previousState.mainComponents().getRegistry());
+ public void disposeRuntime() {
+ if (reinitializer != null)
+ reinitializer.stop();
+ mainComponents.getTimerManager().shutdown();
+ closeRegistryIfSupported();
}
private void closeRegistryIfSupported() {
@@ -633,16 +523,6 @@ private void closeRegistryIfSupported(BeanRegistry registry) {
}
}
- private record YamlRuntimeState(
- DefaultMainComponents mainComponents,
- Configuration configuration,
- boolean initialized,
- RuleReinitializer reinitializer,
- String yamlConfigurationLocation,
- List yamlTrackedFiles
- ) {
- }
-
private static void handleOpenAPIParsingException(OpenAPIParsingException e) {
System.err.printf("""
================================================================================================
diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java
new file mode 100644
index 0000000000..acb9d068be
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java
@@ -0,0 +1,25 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed 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 com.predic8.membrane.core.router;
+
+import java.nio.file.Path;
+import java.util.List;
+
+public record YamlConfigurationSource(String location, List trackedFiles) {
+
+ public YamlConfigurationSource {
+ trackedFiles = trackedFiles == null ? List.of() : List.copyOf(trackedFiles);
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
index 8f6ddd34d2..c386c44d62 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
@@ -34,7 +34,7 @@ public final class YamlRouterBootstrap {
private YamlRouterBootstrap() {
}
- public static void loadIntoRouter(DefaultRouter router, String location) throws Exception {
+ public static YamlConfigurationSource loadIntoRouter(DefaultRouter router, String location) throws Exception {
var grammar = new GrammarAutoGenerated();
var registry = new BeanRegistryImplementation(grammar);
@@ -51,12 +51,12 @@ public static void loadIntoRouter(DefaultRouter router, String location) throws
beanDefinitions.ensureSingleGlobalDefinition();
beanDefinitions.getConfigDefinition().ifPresent(configBd -> router.applyConfiguration((Configuration) registry.resolve(configBd.getName())));
- router.setYamlConfiguration(location, collectTrackedFiles(location, definitions));
registry.finishStaticConfiguration();
router.getConfiguration().setBaseLocation(location);
+ return new YamlConfigurationSource(location, collectTrackedFiles(location, definitions));
}
- private static List collectTrackedFiles(String location, List definitions) {
+ static List collectTrackedFiles(String location, List definitions) {
LinkedHashSet trackedFiles = new LinkedHashSet<>();
Path rootSourceFile = getRootSourceFile(location);
@@ -74,7 +74,7 @@ private static List collectTrackedFiles(String location, List trackedFiles();
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
index 999c5f5cc6..1c334b6a2b 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
@@ -21,6 +21,8 @@
import javax.annotation.concurrent.*;
+import static java.lang.Thread.currentThread;
+
public class DefaultHotDeployer implements HotDeployer {
private static final Logger log = LoggerFactory.getLogger(DefaultHotDeployer.class.getName());
@@ -29,6 +31,7 @@ public class DefaultHotDeployer implements HotDeployer {
private Thread hdt;
private DefaultRouter router;
+ private ConfigurationReloader configurationReloader;
private final Object lock = new Object();
private volatile boolean enabled = true;
@@ -73,8 +76,9 @@ private void startInternal() {
return;
}
- if (router.getYamlConfigurationLocation() != null && !router.getYamlTrackedFiles().isEmpty()) {
- hdt = new YamlHotDeploymentThread(router, this);
+ // Start from YAML
+ if (configurationReloader != null && !configurationReloader.trackedFiles().isEmpty()) {
+ hdt = new YamlHotDeploymentThread(configurationReloader, this::isEnabled);
hdt.start();
return;
}
@@ -85,22 +89,30 @@ private void startInternal() {
@Override
public void stop() {
+ Thread threadToStop;
synchronized (lock) {
if (hdt == null)
return;
+ threadToStop = hdt;
+ hdt = null;
+ }
- if (router != null && router.getReinitializer() != null) {
- router.getReinitializer().stop();
- }
+ if (threadToStop == currentThread()) {
+ return;
+ }
- if (hdt instanceof HotDeploymentThread hotDeploymentThread) {
- hotDeploymentThread.stopASAP();
- } else if (hdt instanceof YamlHotDeploymentThread yamlHotDeploymentThread) {
- yamlHotDeploymentThread.stopASAP();
- } else {
- hdt.interrupt();
- }
- hdt = null;
+ if (threadToStop instanceof HotDeploymentThread hotDeploymentThread) {
+ hotDeploymentThread.stopASAP();
+ } else if (threadToStop instanceof YamlHotDeploymentThread yamlHotDeploymentThread) {
+ yamlHotDeploymentThread.stopASAP();
+ } else {
+ threadToStop.interrupt();
+ }
+
+ try {
+ threadToStop.join(2000);
+ } catch (InterruptedException e) {
+ currentThread().interrupt();
}
}
@@ -108,7 +120,12 @@ public void stop() {
public void setEnabled(boolean enabled) {
this.enabled = enabled;
- if (enabled && router != null) {
+ if (!enabled) {
+ stop();
+ return;
+ }
+
+ if (router != null) {
startInternal();
}
}
@@ -117,4 +134,9 @@ public void setEnabled(boolean enabled) {
public boolean isEnabled() {
return enabled;
}
+
+ @Override
+ public void setConfigurationReloader(ConfigurationReloader configurationReloader) {
+ this.configurationReloader = configurationReloader;
+ }
}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java
new file mode 100644
index 0000000000..6773a76635
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java
@@ -0,0 +1,55 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed 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 com.predic8.membrane.core.router.hotdeploy;
+
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+
+final class FileWatchSnapshot {
+
+ private final List files;
+
+ private FileWatchSnapshot(List files) {
+ this.files = files;
+ }
+
+ static FileWatchSnapshot capture(Collection files) {
+ LinkedHashSet trackedPaths = new LinkedHashSet<>();
+ for (Path file : files) {
+ trackedPaths.add(file.toAbsolutePath().normalize());
+ }
+
+ return new FileWatchSnapshot(trackedPaths.stream()
+ .map(path -> new WatchedPath(path, lastModified(path)))
+ .toList());
+ }
+
+ boolean hasChanged() {
+ for (WatchedPath file : files) {
+ if (lastModified(file.path()) != file.lastModified()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static long lastModified(Path path) {
+ return path.toFile().lastModified();
+ }
+
+ private record WatchedPath(Path path, long lastModified) {}
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java
index 71592e4143..8d5fd6030a 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/HotDeployer.java
@@ -24,9 +24,10 @@ public interface HotDeployer {
void setEnabled(boolean enabled);
+ default void setConfigurationReloader(ConfigurationReloader configurationReloader) {}
+
default boolean isEnabled() {
return false;
}
}
-
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
index 358d2b27c5..a2bef8007c 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
@@ -14,80 +14,40 @@
package com.predic8.membrane.core.router.hotdeploy;
-import com.predic8.membrane.core.router.DefaultRouter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.io.File;
-import java.util.ArrayList;
-import java.util.LinkedHashSet;
-import java.util.List;
+import java.util.function.BooleanSupplier;
+
+import static com.predic8.membrane.core.router.hotdeploy.FileWatchSnapshot.capture;
public class YamlHotDeploymentThread extends Thread {
private static final Logger log = LoggerFactory.getLogger(YamlHotDeploymentThread.class.getName());
- private final DefaultRouter router;
- private final DefaultHotDeployer hotDeployer;
- private final List files = new ArrayList<>();
-
- private static class FileInfo {
- public String file;
- public long lastModified;
- }
+ private final ConfigurationReloader reloader;
+ private final BooleanSupplier enabled;
+ private FileWatchSnapshot snapshot;
- public YamlHotDeploymentThread(DefaultRouter router, DefaultHotDeployer hotDeployer) {
+ public YamlHotDeploymentThread(ConfigurationReloader reloader, BooleanSupplier enabled) {
super("yaml-hotdeploy");
- this.router = router;
- this.hotDeployer = hotDeployer;
+ this.reloader = reloader;
+ this.enabled = enabled;
refreshTrackedFiles();
}
private void refreshTrackedFiles() {
- files.clear();
- LinkedHashSet trackedPaths = new LinkedHashSet<>();
- for (File file : router.getYamlTrackedFiles()) {
- trackedPaths.add(file.getAbsolutePath());
- File parent = file.getParentFile();
- if (parent != null) {
- trackedPaths.add(parent.getAbsolutePath());
- }
- }
- for (String path : trackedPaths) {
- FileInfo fileInfo = new FileInfo();
- fileInfo.file = path;
- files.add(fileInfo);
- }
- updateLastModified();
- }
-
- private void updateLastModified() {
- for (FileInfo fileInfo : files) {
- fileInfo.lastModified = getLastModified(fileInfo.file);
- }
- }
-
- private static long getLastModified(String file) {
- return new File(file).lastModified();
- }
-
- private boolean configurationChanged() {
- for (FileInfo fileInfo : files) {
- if (getLastModified(fileInfo.file) != fileInfo.lastModified) {
- return true;
- }
- }
- return false;
+ snapshot = capture(reloader.trackedFiles());
}
@Override
public void run() {
log.debug("YAML Hot Deployment Thread started.");
- while (!isInterrupted() && hotDeployer.isEnabled()) {
+ while (!isInterrupted() && enabled.getAsBoolean()) {
try {
- while (!configurationChanged()) {
- if (isInterrupted() || !hotDeployer.isEnabled()) {
+ while (!snapshot.hasChanged()) {
+ if (isInterrupted() || !enabled.getAsBoolean()) {
log.debug("YAML Hot Deployment Thread interrupted.");
return;
}
@@ -96,13 +56,13 @@ public void run() {
sleep(1000);
}
- log.debug("yaml configuration changed.");
+ log.info("yaml configuration changed.");
- if (!router.reloadYamlConfiguration()) {
+ if (!reloader.reload()) {
break;
}
- if (!hotDeployer.isEnabled()) {
+ if (!enabled.getAsBoolean()) {
break;
}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
new file mode 100644
index 0000000000..e00dd10e8c
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
@@ -0,0 +1,108 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed 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 com.predic8.membrane.core.router.hotdeploy;
+
+import com.predic8.membrane.core.router.DefaultRouter;
+import com.predic8.membrane.core.router.YamlConfigurationSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.concurrent.GuardedBy;
+import java.nio.file.Path;
+import java.util.List;
+
+import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter;
+
+public final class YamlRouterReloader implements ConfigurationReloader {
+
+ private static final Logger log = LoggerFactory.getLogger(YamlRouterReloader.class);
+
+ private final DefaultRouter router;
+
+ @GuardedBy("this")
+ private YamlConfigurationSource source;
+
+ public YamlRouterReloader(DefaultRouter router, YamlConfigurationSource source) {
+ this.router = router;
+ this.source = source;
+ }
+
+ @Override
+ public synchronized List trackedFiles() {
+ return source == null ? List.of() : source.trackedFiles();
+ }
+
+ @Override
+ public boolean reload() {
+ YamlConfigurationSource currentSource = getSource();
+ boolean runtimeStopped = false;
+
+ if (!router.markReloading()) {
+ return false;
+ }
+
+ try {
+ if (currentSource == null || currentSource.location() == null || currentSource.location().isBlank()) {
+ throw new IllegalStateException("No YAML configuration location is known.");
+ }
+
+ log.info("Reloading YAML configuration from {}.", currentSource.location());
+ validate(currentSource.location());
+
+ router.stopRuntimeForReload();
+ runtimeStopped = true;
+ router.resetRuntime();
+
+ setSource(loadIntoRouter(router, currentSource.location()));
+ router.start();
+ log.info("YAML configuration reloaded successfully.");
+ return true;
+ } catch (Exception e) {
+ log.error("Could not reload YAML configuration.", e);
+ if (!runtimeStopped) {
+ log.info("Keeping the previous YAML runtime because reload validation failed before shutdown.");
+ return true;
+ }
+ try {
+ router.stopRuntimeForReload();
+ router.disposeRuntime();
+ } catch (Exception cleanupError) {
+ cleanupError.addSuppressed(e);
+ log.error("Could not clean up the failed YAML runtime after reload failure.", cleanupError);
+ }
+ log.info("YAML runtime remains stopped after reload failure.");
+ return false;
+ } finally {
+ router.clearReloading();
+ }
+ }
+
+ private void validate(String location) throws Exception {
+ DefaultRouter candidate = new DefaultRouter();
+ try {
+ loadIntoRouter(candidate, location);
+ } finally {
+ candidate.disposeRuntime();
+ }
+ }
+
+ private synchronized YamlConfigurationSource getSource() {
+ return source;
+ }
+
+ private synchronized void setSource(YamlConfigurationSource source) {
+ this.source = source;
+ }
+}
From c61ec0a77bb745747087864a49398b001999824b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 10:23:47 +0200
Subject: [PATCH 08/12] Add tests for YAML hot deployment with error handling
and recovery
---
.../core/router/YamlHotDeploymentTest.java | 120 ++++++++++++++++++
1 file changed, 120 insertions(+)
create mode 100644 core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java
diff --git a/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java b/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java
new file mode 100644
index 0000000000..6429c8f4a7
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/core/router/YamlHotDeploymentTest.java
@@ -0,0 +1,120 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed 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 com.predic8.membrane.core.router;
+
+import com.predic8.membrane.core.proxies.Proxy;
+import com.predic8.membrane.core.router.hotdeploy.YamlRouterReloader;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class YamlHotDeploymentTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void shouldKeepPreviousRuntimeWhenValidationFails() throws Exception {
+ Path config = tempDir.resolve("apis.yaml");
+ Files.writeString(config, configWithInternal("first", "https://example.com"));
+
+ DefaultRouter router = new DefaultRouter();
+ try {
+ YamlRouterReloader reloader = new YamlRouterReloader(router, loadIntoRouter(router, config.toString()));
+ router.setConfigurationReloader(reloader);
+ router.start();
+
+ Files.writeString(config, """
+ configuration:
+ hotDeploy: false
+ ---
+ internal:
+ name: broken
+ target:
+ url: "https://example.org
+ """);
+
+ assertTrue(reloader.reload());
+ assertTrue(router.isRunning());
+ assertEquals(List.of("first"), getRuleNames(router));
+ } finally {
+ router.stop();
+ }
+ }
+
+ @Test
+ void shouldLeaveRouterStoppedWhenReloadFailsAfterShutdown() throws Exception {
+ Path config = tempDir.resolve("apis.yaml");
+ Files.writeString(config, configWithInternal("first", "https://example.com"));
+
+ FailingReloadStartRouter router = new FailingReloadStartRouter();
+ try {
+ YamlRouterReloader reloader = new YamlRouterReloader(router, loadIntoRouter(router, config.toString()));
+ router.setConfigurationReloader(reloader);
+ router.start();
+
+ Files.writeString(config, configWithInternal("second", "https://example.org"));
+ router.failNextStart();
+
+ assertFalse(reloader.reload());
+ assertFalse(router.isRunning());
+ } finally {
+ router.stop();
+ }
+ }
+
+ private static List getRuleNames(DefaultRouter router) {
+ return router.getRuleManager().getRules().stream()
+ .map(Proxy::getName)
+ .toList();
+ }
+
+ private static String configWithInternal(String name, String url) {
+ return """
+ configuration:
+ hotDeploy: false
+ ---
+ internal:
+ name: %s
+ target:
+ url: %s
+ """.formatted(name, url);
+ }
+
+ private static class FailingReloadStartRouter extends DefaultRouter {
+ private boolean failNextStart;
+
+ void failNextStart() {
+ failNextStart = true;
+ }
+
+ @Override
+ public void start() {
+ if (failNextStart) {
+ failNextStart = false;
+ throw new RuntimeException("simulated reload start failure");
+ }
+ super.start();
+ }
+ }
+}
From 0ab75db9e5b758ca7a1a205cf496051a76892cce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 11:11:02 +0200
Subject: [PATCH 09/12] Simplify logging messages for YAML configuration reload
and change detection
---
.../core/router/hotdeploy/YamlHotDeploymentThread.java | 2 +-
.../membrane/core/router/hotdeploy/YamlRouterReloader.java | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
index a2bef8007c..fa8be9033b 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
@@ -56,7 +56,7 @@ public void run() {
sleep(1000);
}
- log.info("yaml configuration changed.");
+ log.info("Configuration Changed.");
if (!reloader.reload()) {
break;
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
index e00dd10e8c..3b761dcd2f 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
@@ -58,7 +58,7 @@ public boolean reload() {
throw new IllegalStateException("No YAML configuration location is known.");
}
- log.info("Reloading YAML configuration from {}.", currentSource.location());
+ log.debug("Reloading YAML configuration from {}.", currentSource.location());
validate(currentSource.location());
router.stopRuntimeForReload();
@@ -67,7 +67,7 @@ public boolean reload() {
setSource(loadIntoRouter(router, currentSource.location()));
router.start();
- log.info("YAML configuration reloaded successfully.");
+ log.info("Configuration Reloaded.");
return true;
} catch (Exception e) {
log.error("Could not reload YAML configuration.", e);
From e46a45a91ef7100f06a4a607122e7195334e932e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 11:18:18 +0200
Subject: [PATCH 10/12] Refactor error logging for YAML configuration reload
failures
---
.../core/router/hotdeploy/YamlRouterReloader.java | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
index 3b761dcd2f..d397bfcc5a 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
@@ -14,6 +14,7 @@
package com.predic8.membrane.core.router.hotdeploy;
+import com.predic8.membrane.annot.yaml.ConfigurationParsingException;
import com.predic8.membrane.core.router.DefaultRouter;
import com.predic8.membrane.core.router.YamlConfigurationSource;
import org.slf4j.Logger;
@@ -70,7 +71,7 @@ public boolean reload() {
log.info("Configuration Reloaded.");
return true;
} catch (Exception e) {
- log.error("Could not reload YAML configuration.", e);
+ logReloadFailure(e);
if (!runtimeStopped) {
log.info("Keeping the previous YAML runtime because reload validation failed before shutdown.");
return true;
@@ -89,6 +90,14 @@ public boolean reload() {
}
}
+ private void logReloadFailure(Exception e) {
+ if (e instanceof ConfigurationParsingException) {
+ log.error("Could not reload YAML configuration: {}", e.getMessage());
+ return;
+ }
+ log.error("Could not reload YAML configuration.", e);
+ }
+
private void validate(String location) throws Exception {
DefaultRouter candidate = new DefaultRouter();
try {
From dca061e908e4728b3679f41c66cd6c3e9e1c0d09 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 11:31:46 +0200
Subject: [PATCH 11/12] Add documentation comments for YAML hot deployment and
reloading components
---
.../com/predic8/membrane/core/router/YamlRouterBootstrap.java | 3 +++
.../membrane/core/router/hotdeploy/DefaultHotDeployer.java | 1 +
.../membrane/core/router/hotdeploy/FileWatchSnapshot.java | 3 +++
.../core/router/hotdeploy/YamlHotDeploymentThread.java | 3 +++
.../membrane/core/router/hotdeploy/YamlRouterReloader.java | 4 ++++
5 files changed, 14 insertions(+)
diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
index c386c44d62..201f35d12a 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/YamlRouterBootstrap.java
@@ -29,6 +29,9 @@
import static com.predic8.membrane.core.util.OSUtil.isWindowsAbsolutePath;
import static com.predic8.membrane.core.util.URIUtil.pathFromFileURI;
+/**
+ * Parses YAML into a router instance and returns the local files that can later be watched for hot reloads.
+ */
public final class YamlRouterBootstrap {
private YamlRouterBootstrap() {
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
index 1c334b6a2b..aaf2710fae 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/DefaultHotDeployer.java
@@ -97,6 +97,7 @@ public void stop() {
hdt = null;
}
+ // A watcher can stop itself after a failed reload; joining the current thread would deadlock here.
if (threadToStop == currentThread()) {
return;
}
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java
index 6773a76635..3141aa4a46 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/FileWatchSnapshot.java
@@ -19,6 +19,9 @@
import java.util.LinkedHashSet;
import java.util.List;
+/**
+ * Snapshot of the currently watched YAML files and their last-modified timestamps.
+ */
final class FileWatchSnapshot {
private final List files;
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
index fa8be9033b..702f79e4f9 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlHotDeploymentThread.java
@@ -21,6 +21,9 @@
import static com.predic8.membrane.core.router.hotdeploy.FileWatchSnapshot.capture;
+/**
+ * Polling watcher for the tracked YAML files; it only detects changes and delegates reload decisions.
+ */
public class YamlHotDeploymentThread extends Thread {
private static final Logger log = LoggerFactory.getLogger(YamlHotDeploymentThread.class.getName());
diff --git a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
index d397bfcc5a..c5e45f6f2b 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/hotdeploy/YamlRouterReloader.java
@@ -26,6 +26,9 @@
import static com.predic8.membrane.core.router.YamlRouterBootstrap.loadIntoRouter;
+/**
+ * Performs YAML validate-and-reload orchestration around the router lifecycle.
+ */
public final class YamlRouterReloader implements ConfigurationReloader {
private static final Logger log = LoggerFactory.getLogger(YamlRouterReloader.class);
@@ -60,6 +63,7 @@ public boolean reload() {
}
log.debug("Reloading YAML configuration from {}.", currentSource.location());
+ // Validation only bootstraps a fresh router; it does not call start() or bind ports.
validate(currentSource.location());
router.stopRuntimeForReload();
From 31cfdadd771ed5bbfd59fcb97691c05ea629bf26 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 11:36:55 +0200
Subject: [PATCH 12/12] Add Javadoc comment for YamlConfigurationSource
---
.../predic8/membrane/core/router/YamlConfigurationSource.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java
index acb9d068be..a650652483 100644
--- a/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java
+++ b/core/src/main/java/com/predic8/membrane/core/router/YamlConfigurationSource.java
@@ -17,6 +17,7 @@
import java.nio.file.Path;
import java.util.List;
+/** Root YAML location and included local YAML files. */
public record YamlConfigurationSource(String location, List trackedFiles) {
public YamlConfigurationSource {