diff --git a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java
index 6555ef6..6b51d7f 100644
--- a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java
+++ b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java
@@ -22,7 +22,9 @@ public record Row(
/**
* The bytes this store last saw at a table's path. {@code durable} is set only when this
- * process wrote them itself and the whole write, directory flush included, succeeded.
+ * process wrote them itself and the write succeeded: the data is synced, and the folder too for
+ * a new file. A replaced file's folder is flushed by the wager journal or an estate checkpoint
+ * before anything relies on it ({@code TableManager.flushTableSaves}).
*/
private record Source(Path path, String hash, JsonObject raw, boolean durable) {}
@@ -165,7 +167,12 @@ public void save(UUID id, JsonObject known) throws IOException {
// there in full, so the same bytes again need no new write or flush. A failed write never
// reaches sources, so whatever it tried to write is still written next time.
if (previous != null && previous.durable() && previous.hash().equals(hash)) return;
- DurableFiles.write(file, text);
+ // Rewritten on every money movement. A table file that already exists is replaced without
+ // a directory flush: only an OS crash or power loss can bring back its previous complete
+ // version. The wager journal flushes this folder before deleting its intent, so a returning
+ // previous version always comes with the intent that freezes the table for review. A new
+ // table file is flushed in full.
+ DurableFiles.writeReplacing(file, text);
sources.put(id, new Source(file, hash, next, true));
rememberWagers(id, next);
}
diff --git a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java
index f57b266..5342d1b 100644
--- a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java
+++ b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java
@@ -35,7 +35,37 @@ public static void write(Path target, String text) throws IOException {
}
public static void write(Path target, byte[] bytes) throws IOException {
+ write(target, bytes, true);
+ }
+
+ /**
+ * Like {@link #write(Path, String)}, but replacing a file that already exists does not flush
+ * its directory. The new bytes are still flushed before the rename, so the file is always
+ * whole: until the rename reaches disk (the filesystem's next journal commit, which any fsync
+ * on it or ext4's five-second commit forces), an operating-system crash or power loss brings
+ * back the previous complete file. A server crash loses nothing. A file that does not exist
+ * yet is flushed in full, as {@code write} does.
+ *
+ *
For files rewritten on every money movement whose loss to the previous version is safe;
+ * anything that acknowledges a transfer elsewhere (estates, migrations, journals) keeps
+ * {@code write}.
+ */
+ public static void writeReplacing(Path target, String text) throws IOException {
+ write(target, text.getBytes(StandardCharsets.UTF_8), false);
+ }
+
+ /**
+ * Flush a directory now, so every rename and delete made in it so far is on disk before
+ * anything that depends on them. Pairs with {@link #writeReplacing} when a later step (such
+ * as deleting a journal) must not reach disk before the replacement does.
+ */
+ public static void flushDirectory(Path directory) throws IOException {
+ syncer.sync(directory.toAbsolutePath().normalize());
+ }
+
+ private static void write(Path target, byte[] bytes, boolean flushReplace) throws IOException {
target = target.toAbsolutePath();
+ boolean replacing = !flushReplace && Files.isRegularFile(target);
Path parent = target.getParent();
Path created = firstMissing(parent);
if (created != null) forget(created);
@@ -48,7 +78,7 @@ public static void write(Path target, byte[] bytes) throws IOException {
channel.force(true);
}
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
- flushEntry(parent);
+ if (!replacing) flushEntry(parent);
} finally {
Files.deleteIfExists(temp);
}
@@ -74,6 +104,16 @@ public static void delete(Path target) throws IOException {
syncer.sync(target.toAbsolutePath().getParent());
}
+ /**
+ * Delete without flushing the directory: the removal reaches disk with the filesystem's next
+ * journal commit, and until then an operating-system crash or power loss can bring the file
+ * back. Only for files whose return is safe, such as a finished wager intent, which then
+ * freezes its table for review.
+ */
+ public static void deleteUnflushed(Path target) throws IOException {
+ Files.deleteIfExists(target);
+ }
+
/** Flush a file's directory entry, and its ancestors the first time this process sees it. */
private static void flushEntry(Path parent) throws IOException {
parent = parent.normalize();
diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java
index 377eddf..8825fd1 100644
--- a/src/main/java/net/tfminecraft/games/table/TableManager.java
+++ b/src/main/java/net/tfminecraft/games/table/TableManager.java
@@ -316,7 +316,8 @@ public void estatePending(Collection ids, boolean pending) {
Table table=tables.get(id);if(table!=null) table.setEstatePending(pending);
}
}
- public void checkpoint(Table table) throws IOException { saveWager(table); }
+ /** Save the table and put the save on disk: an estate plan written next takes it as its "before". */
+ public void checkpoint(Table table) throws IOException { saveWager(table); flushTableSaves(); }
public void estateOwner(UUID id,UUID account,UUID character) {
Table table=tables.get(id);
if(table!=null) { table.setOwnerPlayer(account);table.setOwnerCharacterId(character);refreshLabel(table); }
@@ -4857,6 +4858,15 @@ private Table tableFrom(Entity entity) {
}
}
+ /**
+ * Put every table save made so far on disk. Table files are replaced without a folder flush
+ * ({@code DurableFiles.writeReplacing}); a finished wager intent may only be deleted once its
+ * table save is durable, so the journal calls this first.
+ */
+ public void flushTableSaves() throws IOException {
+ net.tfminecraft.games.persistence.DurableFiles.flushDirectory(ownershipStore().folder());
+ }
+
public void saveWager(Table table) throws IOException {
ownershipStore().save(table.getId(), net.tfminecraft.games.ownership.RawJson.JSON.toJsonTree(toData(table)).getAsJsonObject());
// The whole table is on disk, so any change that was waiting went with it. A failed
diff --git a/src/main/java/net/tfminecraft/games/wager/WagerJournal.java b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java
index 33d27c2..2965e64 100644
--- a/src/main/java/net/tfminecraft/games/wager/WagerJournal.java
+++ b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java
@@ -98,7 +98,12 @@ static TxResult apply(Table table, Plan plan, java.util.function.Consumer
- net.tfminecraft.games.persistence.DurableFiles.write(
+ net.tfminecraft.games.persistence.DurableFiles.writeReplacing(
org.mockito.ArgumentMatchers.any(Path.class),
org.mockito.ArgumentMatchers.anyString()),
org.mockito.Mockito.times(times));
@@ -194,7 +194,7 @@ void aFailedWriteIsTriedAgainAndKeepsThrowingUntilItSucceeds() throws Exception
try (var writes = writes()) {
writes.when(
() ->
- net.tfminecraft.games.persistence.DurableFiles.write(
+ net.tfminecraft.games.persistence.DurableFiles.writeReplacing(
org.mockito.ArgumentMatchers.any(Path.class),
org.mockito.ArgumentMatchers.anyString()))
.thenThrow(new IOException("disk full"));
@@ -219,7 +219,7 @@ void aWriteThatFailsAfterReplacingTheFileHoldsTheTableRatherThanSkipping() throw
try (var writes = writes()) {
writes.when(
() ->
- net.tfminecraft.games.persistence.DurableFiles.write(
+ net.tfminecraft.games.persistence.DurableFiles.writeReplacing(
org.mockito.ArgumentMatchers.any(Path.class),
org.mockito.ArgumentMatchers.anyString()))
.thenAnswer(
diff --git a/src/test/java/net/tfminecraft/games/persistence/DurableFilesTest.java b/src/test/java/net/tfminecraft/games/persistence/DurableFilesTest.java
index 0ff8fc9..ee3ae75 100644
--- a/src/test/java/net/tfminecraft/games/persistence/DurableFilesTest.java
+++ b/src/test/java/net/tfminecraft/games/persistence/DurableFilesTest.java
@@ -55,6 +55,14 @@ void writeCreatesMissingDirectoriesAndLeavesNoPendingFiles() throws Exception {
}
}
+ @Test
+ void flushDirectoryFlushesExactlyThatFolderEveryTime() throws Exception {
+ Files.createDirectories(tables());
+ DurableFiles.flushDirectory(tables());
+ DurableFiles.flushDirectory(tables().resolve("..").resolve("tables"));
+ assertEquals(java.util.List.of(tables().normalize(), tables().normalize()), flushed);
+ }
+
@Test
void firstWriteFlushesEveryAncestorAndLaterWritesOnlyTheParent() throws Exception {
DurableFiles.write(tables().resolve("a.json"), "a");
@@ -66,6 +74,39 @@ void firstWriteFlushesEveryAncestorAndLaterWritesOnlyTheParent() throws Exceptio
assertEquals(List.of(tables()), flushed);
}
+ @Test
+ void replacingWriteFlushesOnlyANewFileAndAlwaysPublishesWholeContent() throws Exception {
+ Path target = tables().resolve("table.json");
+ DurableFiles.writeReplacing(target, "first");
+ assertEquals("first", Files.readString(target));
+ assertEquals(tables(), flushed.get(0));
+ assertTrue(flushed.contains(data.toAbsolutePath()), "a new file gets its whole ancestor chain");
+ flushed.clear();
+ DurableFiles.writeReplacing(target, "second");
+ assertEquals("second", Files.readString(target));
+ assertTrue(flushed.isEmpty(), "replacing an existing file skips the directory flush");
+ DurableFiles.write(target, "third");
+ assertEquals(List.of(tables()), flushed, "plain write still flushes a replace");
+ try (var files = Files.list(tables())) {
+ assertEquals(1, files.count(), "no pending temp file is left");
+ }
+ }
+
+ @Test
+ void unflushedDeleteRemovesTheFileWithoutAFlush() throws Exception {
+ Path target = tables().resolve("intent.json");
+ DurableFiles.write(target, "intent");
+ flushed.clear();
+ DurableFiles.deleteUnflushed(target);
+ assertFalse(Files.exists(target));
+ DurableFiles.deleteUnflushed(target);
+ assertTrue(flushed.isEmpty());
+ DurableFiles.write(target, "intent");
+ flushed.clear();
+ DurableFiles.delete(target);
+ assertEquals(List.of(tables()), flushed, "plain delete still flushes");
+ }
+
@Test
void folderMadeElsewhereStillGetsItsAncestorsFlushed() throws Exception {
// Games.createFolders() and TableManager use mkdirs before their first durable write.
diff --git a/src/test/java/net/tfminecraft/games/table/TableManagerSaveBatchingTest.java b/src/test/java/net/tfminecraft/games/table/TableManagerSaveBatchingTest.java
index 99f01d7..989a298 100644
--- a/src/test/java/net/tfminecraft/games/table/TableManagerSaveBatchingTest.java
+++ b/src/test/java/net/tfminecraft/games/table/TableManagerSaveBatchingTest.java
@@ -39,7 +39,7 @@ private MockedStatic countWrites() {
}
private static void verifyWrites(MockedStatic writes, Table table, int times) {
- writes.verify(() -> DurableFiles.write(
+ writes.verify(() -> DurableFiles.writeReplacing(
argThat((Path path) -> path != null && path.getFileName().toString().equals(table.getId() + ".json")),
anyString()), times(times));
}
@@ -124,7 +124,7 @@ void aMoneySaveWritesWaitingCardChangesAndClearsTheMark(String save) throws Exce
}
private static void failWrites(MockedStatic writes, Table table) {
- writes.when(() -> DurableFiles.write(
+ writes.when(() -> DurableFiles.writeReplacing(
argThat((Path path) -> path != null && path.getFileName().toString().equals(table.getId() + ".json")),
anyString())).thenThrow(new java.io.IOException("disk full"));
}
diff --git a/src/test/java/net/tfminecraft/games/table/TableOwnerSessionTest.java b/src/test/java/net/tfminecraft/games/table/TableOwnerSessionTest.java
index 80e886a..bb5a6fe 100644
--- a/src/test/java/net/tfminecraft/games/table/TableOwnerSessionTest.java
+++ b/src/test/java/net/tfminecraft/games/table/TableOwnerSessionTest.java
@@ -193,7 +193,7 @@ void failedDurableSessionCheckpointCannotStartGameEffects() throws Exception {
net.tfminecraft.games.persistence.DurableFiles.class, CALLS_REAL_METHODS)) {
writer.when(
() ->
- net.tfminecraft.games.persistence.DurableFiles.write(
+ net.tfminecraft.games.persistence.DurableFiles.writeReplacing(
eq(file), anyString()))
.thenThrow(new java.io.IOException("session checkpoint cut"));
manager.beginSession(table);
diff --git a/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java b/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java
index 0ce3a59..9294c4d 100644
--- a/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java
+++ b/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java
@@ -209,12 +209,18 @@ private long credit(String account) {
}
@Test void appliedPostingWritesItsIntentOnceAndRemovesIt() throws Exception {
+ // The intent is still on disk when the table save is flushed.
+ doAnswer(call -> { assertTrue(Files.exists(journal())); return null; }).when(manager).flushTableSaves();
try (MockedStatic files =
mockStatic(net.tfminecraft.games.persistence.DurableFiles.class, CALLS_REAL_METHODS)) {
assertEquals(5, funding(5).commit().moved());
files.verify(() -> net.tfminecraft.games.persistence.DurableFiles.write(eq(journal()), anyString()), times(1));
- files.verify(() -> net.tfminecraft.games.persistence.DurableFiles.delete(journal()), times(1));
+ files.verify(() -> net.tfminecraft.games.persistence.DurableFiles.deleteUnflushed(journal()), times(1));
}
+ // The table save is flushed before the intent goes.
+ var order = inOrder(manager);
+ order.verify(manager).saveWager(table);
+ order.verify(manager).flushTableSaves();
assertFalse(Files.exists(journal()));
verify(manager).saveWager(table);
}
@@ -235,6 +241,19 @@ private long credit(String account) {
assertEquals(retained, Files.readString(journal()));
}
+ @Test void failedTableFlushKeepsTheIntentAndBlocksSecondPayment() throws Exception {
+ doThrow(new IOException("disk full")).when(manager).flushTableSaves();
+ try (MockedStatic files =
+ mockStatic(net.tfminecraft.games.persistence.DurableFiles.class, CALLS_REAL_METHODS)) {
+ assertFalse(funding(5).commit().ok());
+ files.verify(() -> net.tfminecraft.games.persistence.DurableFiles.deleteUnflushed(any()), never());
+ }
+ assertTrue(table.moneyBlocked());
+ assertTrue(Files.exists(journal()));
+ assertFalse(funding(5).commit().ok());
+ assertEquals(1, postings.size());
+ }
+
@Test void failedTableSavePreservesAmbiguousDeliveryAndBlocksSecondPayment() throws Exception {
doThrow(new IOException("disk full")).when(manager).saveWager(table);
assertFalse(funding(5).commit().ok());