Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.bencodez.simpleapi.file.velocity;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
Expand All @@ -19,7 +22,7 @@ public class VelocityYMLFile {

@Getter
@Setter
private ConfigurationNode conf;
private volatile ConfigurationNode conf;

@Getter
private final File file;
Expand All @@ -31,7 +34,7 @@ public VelocityYMLFile(File file) {
ensureFileExists(file);

this.loader = buildLoader(file.toPath());
this.conf = loadOrEmpty();
this.conf = loadInitialOrEmpty();
}

private static void ensureFileExists(File file) {
Expand All @@ -55,7 +58,7 @@ private static YamlConfigurationLoader buildLoader(Path path) {
.build();
}

private ConfigurationNode loadOrEmpty() {
private ConfigurationNode loadInitialOrEmpty() {
try {
return loader.load();
} catch (IOException e) {
Expand Down Expand Up @@ -110,9 +113,25 @@ public List<String> getStringList(ConfigurationNode node, ArrayList<String> def)
}
}

public void reload() {
this.loader = buildLoader(file.toPath());
this.conf = loadOrEmpty();
/**
* Publishes a replacement only after a successful load. Startup may use
* defaults, but a reload failure must keep the last valid active snapshot.
* @throws UncheckedIOException when the replacement cannot be read or parsed
*/
public synchronized void reload() {
YamlConfigurationLoader replacement = buildLoader(file.toPath());
try {
ConfigurationNode loaded;
// Open before invoking Configurate: its path loader treats a missing file as empty.
try (BufferedReader reader = Files.newBufferedReader(file.toPath())) {
loaded = YamlConfigurationLoader.builder().source(() -> reader)
.nodeStyle(NodeStyle.BLOCK).build().load();
}
this.loader = replacement;
this.conf = loaded;
} catch (IOException failure) {
throw new UncheckedIOException("Velocity YAML reload failed; previous configuration remains active", failure);
}
}

public void save() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,51 @@ public void tearDown() throws IOException {
Files.deleteIfExists(Paths.get(TEST_FILE_PATH));
}

@Test
void malformedReloadPreservesActiveSnapshotAndDiskThenRecovers() throws Exception {
Files.writeString(testFile.toPath(), "BungeeMethod: MQTT\nCustom:\n label: administrator\n");
VelocityYMLFile config = new VelocityYMLFile(testFile);
ConfigurationNode active = config.getData();
String malformed = "BungeeMethod: [broken\n";
Files.writeString(testFile.toPath(), malformed);
org.junit.jupiter.api.Assertions.assertThrows(java.io.UncheckedIOException.class, config::reload);
org.junit.jupiter.api.Assertions.assertSame(active, config.getData());
assertEquals("MQTT", config.getNode("BungeeMethod").getString());
assertEquals("administrator", config.getNode("Custom", "label").getString());
assertEquals(malformed, Files.readString(testFile.toPath()));
Files.writeString(testFile.toPath(), "BungeeMethod: REDIS\nCustom:\n label: corrected\n");
config.reload();
assertEquals("REDIS", config.getNode("BungeeMethod").getString());
assertEquals("corrected", config.getNode("Custom", "label").getString());
}

@Test
void missingOrUnreadableReloadDoesNotPublishStartupDefaults() throws Exception {
Files.writeString(testFile.toPath(), "BungeeMethod: MQTT\n");
VelocityYMLFile config = new VelocityYMLFile(testFile);
ConfigurationNode active = config.getData();
Files.delete(testFile.toPath());
org.junit.jupiter.api.Assertions.assertThrows(java.io.UncheckedIOException.class, config::reload);
org.junit.jupiter.api.Assertions.assertSame(active, config.getData());
Files.createDirectory(testFile.toPath());
org.junit.jupiter.api.Assertions.assertThrows(java.io.UncheckedIOException.class, config::reload);
org.junit.jupiter.api.Assertions.assertSame(active, config.getData());
}

@Test
void missingStartupFileAndValidEmptyReloadKeepDefaultSemantics() throws Exception {
Files.delete(testFile.toPath());
VelocityYMLFile config = new VelocityYMLFile(testFile);
assertTrue(testFile.isFile());
assertEquals("default", config.getNode("missing").getString("default"));
Files.writeString(testFile.toPath(), "BungeeMethod: MQTT\n");
config.reload();
assertEquals("MQTT", config.getNode("BungeeMethod").getString());
Files.writeString(testFile.toPath(), "");
config.reload();
assertEquals("PLUGINMESSAGING", config.getNode("BungeeMethod").getString("PLUGINMESSAGING"));
}

@Test
public void getBooleanReturnsDefaultWhenNodeDoesNotExist() {
VelocityYMLFile velocityYMLFile = new VelocityYMLFile(testFile);
Expand Down
10 changes: 10 additions & 0 deletions docs/shared-libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,13 @@ The deterministic lifecycle tests run in the normal Maven suite. To additionally
exercise real bidirectional restoration after an ephemeral loopback broker
restart, pass `-Dsimpleapi.mqtt.broker=/path/to/mosquitto`. This test never calls
manual reconnect or re-registers subscriptions after the restart.

### Legacy Velocity YAML reload

`VelocityYMLFile` retains its existing startup behavior (including creation of a
missing initial file and defaults when initial loading fails). Normal `reload()`
is different: it reads a replacement before publishing it and throws
`UncheckedIOException` on unreadable, missing, or malformed input, keeping the
last valid active configuration. The failed reload does not rewrite the file.
Callers must abort runtime reconfiguration when reload fails. An intentionally
empty, valid YAML document remains accepted.
Loading