From c301d29c7bd44451941f1d1248c5e8b638f06a0a Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:51:33 +0100 Subject: [PATCH 1/4] perf: replace table files and drop finished intents without a directory flush Every chip on an auto-tray blackjack table did ~8 fsyncs on the main thread (intent write 2, ledger 1, table file 2, intent delete 1, plus chip placement's table save 2): ~8 s in Main's busiest hour. Two hot paths now skip only the directory flush: - TableOwnershipStore.save replaces an existing table file with DurableFiles.writeReplacing: the new bytes are still flushed before the atomic rename, so the file is always whole; a new table file is flushed in full. - WagerJournal deletes a finished wager intent with deleteUnflushed. Until the filesystem's next journal commit (any fsync on it, or ext4's 5 s commit) an OS crash or power loss can bring back the previous complete table file or the finished intent; a returning intent freezes the table for review, which asks Denars for the posting's status. A server crash loses nothing. New intents, review/conflict intents, estate publications and journals, migrations and the running marker keep the full flush. Co-Authored-By: Claude Opus 5.5 --- .../games/ownership/TableOwnershipStore.java | 6 +++- .../games/persistence/DurableFiles.java | 33 ++++++++++++++++++- .../tfminecraft/games/wager/WagerJournal.java | 4 ++- .../games/persistence/DurableFilesTest.java | 33 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java index 6555ef6..d16f1d2 100644 --- a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java +++ b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java @@ -165,7 +165,11 @@ 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, and the wager journal's intent (deleted after this save) then still 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..178a01f 100644 --- a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java +++ b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java @@ -35,7 +35,28 @@ 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); + } + + 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 +69,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 +95,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/wager/WagerJournal.java b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java index 33d27c2..6a5bc74 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerJournal.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java @@ -98,7 +98,9 @@ static TxResult apply(Table table, Plan plan, java.util.function.Consumer Date: Tue, 3 Nov 2026 21:08:33 +0100 Subject: [PATCH 2/4] test: expect table saves through writeReplacing and the finished-intent delete through deleteUnflushed CI on the PR: tests that stub or count DurableFiles.write for table files, and the delete of an applied wager intent, now follow the save paths this branch moved to writeReplacing and deleteUnflushed. Estate publication stubs keep write, since that path still flushes in full. Co-Authored-By: Claude Opus 5.5 --- .../games/ownership/TableOwnershipStoreTest.java | 6 +++--- .../games/table/TableManagerSaveBatchingTest.java | 4 ++-- .../net/tfminecraft/games/table/TableOwnerSessionTest.java | 2 +- .../java/net/tfminecraft/games/wager/WagerJournalTest.java | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/java/net/tfminecraft/games/ownership/TableOwnershipStoreTest.java b/src/test/java/net/tfminecraft/games/ownership/TableOwnershipStoreTest.java index 259d060..ea7c20f 100644 --- a/src/test/java/net/tfminecraft/games/ownership/TableOwnershipStoreTest.java +++ b/src/test/java/net/tfminecraft/games/ownership/TableOwnershipStoreTest.java @@ -132,7 +132,7 @@ private static void verifyWrites( int times) { writes.verify( () -> - 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/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..bc3d87f 100644 --- a/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java +++ b/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java @@ -213,7 +213,7 @@ private long credit(String account) { 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)); } assertFalse(Files.exists(journal())); verify(manager).saveWager(table); From e27a71792b19002f76e019470d718c93216cdc3e Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:29:15 +0100 Subject: [PATCH 3/4] fix: flush the tables folder before deleting a finished wager intent CodeRabbit on #50: POSIX does not order two directory updates, so with the table save's folder flush skipped and the intent deleted unflushed, a power loss could keep the deletion while losing the table rename: the table back at its previous version, the posting applied, and no intent left to freeze it for review. WagerJournal.apply now flushes the tables folder (new TableManager.flushTableSaves via DurableFiles.flushDirectory) after the table save and before deleteUnflushed. That still saves one sync per wager compared with main (table save 1 data + 1 folder, intent delete without a folder flush). Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/games/ownership/TableOwnershipStore.java | 5 +++-- .../net/tfminecraft/games/persistence/DurableFiles.java | 9 +++++++++ .../java/net/tfminecraft/games/table/TableManager.java | 9 +++++++++ .../java/net/tfminecraft/games/wager/WagerJournal.java | 7 +++++-- .../tfminecraft/games/persistence/DurableFilesTest.java | 8 ++++++++ .../net/tfminecraft/games/wager/WagerJournalTest.java | 4 ++++ 6 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java index d16f1d2..fa09982 100644 --- a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java +++ b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java @@ -167,8 +167,9 @@ public void save(UUID id, JsonObject known) throws IOException { if (previous != null && previous.durable() && previous.hash().equals(hash)) return; // 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, and the wager journal's intent (deleted after this save) then still freezes the - // table for review. A new table file is flushed in full. + // 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 178a01f..5342d1b 100644 --- a/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java +++ b/src/main/java/net/tfminecraft/games/persistence/DurableFiles.java @@ -54,6 +54,15 @@ 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); diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 377eddf..ef42959 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -4857,6 +4857,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 6a5bc74..2965e64 100644 --- a/src/main/java/net/tfminecraft/games/wager/WagerJournal.java +++ b/src/main/java/net/tfminecraft/games/wager/WagerJournal.java @@ -98,8 +98,11 @@ static TxResult apply(Table table, Plan plan, java.util.function.Consumer net.tfminecraft.games.persistence.DurableFiles.write(eq(journal()), anyString()), 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); } From 7e3706e947a7ba2d30ba67f25fb39f4ad57a4b64 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:41:52 +0100 Subject: [PATCH 4/4] fix: flush table saves on estate checkpoints; test the flush order and a failing flush Review of e27a717: TableManager.checkpoint (estate plans take the saved table as their before) now flushes the tables folder too; WagerJournalTest checks the intent is still on disk when the table save is flushed and that a failing flush keeps the intent, freezes the table and posts nothing twice; the Source.durable comment now matches writeReplacing. Co-Authored-By: Claude Opus 5.5 --- .../games/ownership/TableOwnershipStore.java | 4 +++- .../net/tfminecraft/games/table/TableManager.java | 3 ++- .../tfminecraft/games/wager/WagerJournalTest.java | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java b/src/main/java/net/tfminecraft/games/ownership/TableOwnershipStore.java index fa09982..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) {} diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index ef42959..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); } diff --git a/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java b/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java index d72b22b..9294c4d 100644 --- a/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java +++ b/src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java @@ -209,6 +209,8 @@ 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()); @@ -239,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());