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());