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
Expand Up @@ -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) {}

Expand Down Expand Up @@ -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);
Comment thread
XxFran10xX marked this conversation as resolved.
Resolved
sources.put(id, new Source(file, hash, next, true));
rememberWagers(id, next);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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);
Expand All @@ -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);
}
Expand All @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion src/main/java/net/tfminecraft/games/table/TableManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ public void estatePending(Collection<UUID> 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); }
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/net/tfminecraft/games/wager/WagerJournal.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ static TxResult apply(Table table, Plan plan, java.util.function.Consumer<String
TableManager.get().saveWager(table);
if (!GuildBank.declareProfit(table, intent.operationId(), plan.profit()))
throw new IllegalStateException("Casino profit bookkeeping is pending");
DurableFiles.delete(file);
// The posting and the table save are done. The table save skipped its folder flush, so
// flush it now: the intent must not be gone from disk while the table's previous
// version could still come back. If an OS crash or power loss brings this intent back,
// the table freezes for review, which asks Denars for the posting's status.
TableManager.get().flushTableSaves();
DurableFiles.deleteUnflushed(file);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
ACTIVE.remove(file);
return moved;
} catch (IOException | RuntimeException ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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"));
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ private MockedStatic<DurableFiles> countWrites() {
}

private static void verifyWrites(MockedStatic<DurableFiles> 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));
}
Expand Down Expand Up @@ -124,7 +124,7 @@ void aMoneySaveWritesWaitingCardChangesAndClearsTheMark(String save) throws Exce
}

private static void failWrites(MockedStatic<DurableFiles> 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"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 20 additions & 1 deletion src/test/java/net/tfminecraft/games/wager/WagerJournalTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<net.tfminecraft.games.persistence.DurableFiles> 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);
}
Expand All @@ -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<net.tfminecraft.games.persistence.DurableFiles> 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());
Expand Down
Loading