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
5 changes: 4 additions & 1 deletion src/main/java/net/tfminecraft/games/Games.java
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ private void mkdir(String relativePath) {
File folder = new File(getDataFolder(), relativePath);
if (!folder.exists()) {
folder.mkdirs();
net.tfminecraft.games.persistence.DurableFiles.forget(folder.toPath());
}
}

Expand All @@ -209,7 +210,9 @@ private void copyResourceIfMissing(String relativePath) {
if (target.exists()) {
return;
}
target.getParentFile().mkdirs();
if (target.getParentFile().mkdirs()) {
net.tfminecraft.games.persistence.DurableFiles.forget(target.getParentFile().toPath());
}
// Every default file is bundled in the plugin jar.
try (InputStream in = getResource(relativePath)) {
Files.copy(in, target.toPath());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ else if (!older.equals(file)) {
}
if (decision.property().character().isEmpty()) {
if (plan == null) plan = retain(decision, rows, moved);
DurableFiles.write(file, RawJson.JSON.toJson(plan));
// Hourly reconcile settles this again; an unchanged receipt needs no new flush.
DurableFiles.writeIfChanged(file, RawJson.JSON.toJson(plan));
return done(moved);
}
if (plan != null) {
Expand Down
78 changes: 72 additions & 6 deletions src/main/java/net/tfminecraft/games/persistence/DurableFiles.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,26 @@
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/** Publish whole, flushed files before money or inventory changes. */
public final class DurableFiles {
/** Flushes one directory; tests swap it to count or fail flushes. */
interface Syncer {
void sync(Path directory) throws IOException;
}

static Syncer syncer = DurableFiles::force;

// Directories whose whole ancestor chain this process has flushed. Until then every write
// flushes all ancestors, which covers folders made by mkdirs elsewhere or left behind by a
// write whose ancestor flush failed. A folder this class or Games creates again is
// forgotten (see forget), so a recreated folder is flushed in full; data folders are not
// deleted by anyone else while the server runs.
private static final Set<Path> durableDirectories = ConcurrentHashMap.newKeySet();

private DurableFiles() {}

public static void write(Path target, String text) throws IOException {
Expand All @@ -19,28 +36,77 @@ public static void write(Path target, String text) throws IOException {

public static void write(Path target, byte[] bytes) throws IOException {
target = target.toAbsolutePath();
Files.createDirectories(target.getParent());
Path temp = Files.createTempFile(target.getParent(), ".pending-", ".tmp");
Path parent = target.getParent();
Path created = firstMissing(parent);
if (created != null) forget(created);
Files.createDirectories(parent);
Path temp = Files.createTempFile(parent, ".pending-", ".tmp");
try {
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
// Parent entries also need flushing when an intent creates its first directory.
for (Path directory = target.getParent(); directory != null; directory = directory.getParent()) sync(directory);
flushEntry(parent);
} finally {
Files.deleteIfExists(temp);
}
}

/**
* Publish only when the file does not already hold exactly these bytes. Identical content
* still gets its directory entry flushed, in case an earlier write moved it into place but
* failed before that flush; on a clean directory this costs next to nothing.
*/
public static boolean writeIfChanged(Path target, String text) throws IOException {
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
if (Files.isRegularFile(target) && Arrays.equals(Files.readAllBytes(target), bytes)) {
flushEntry(target.toAbsolutePath().getParent());
return false;
}
write(target, bytes);
return true;
}

public static void delete(Path target) throws IOException {
Files.deleteIfExists(target);
sync(target.getParent());
syncer.sync(target.toAbsolutePath().getParent());
}

/** 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();
syncer.sync(parent);
if (durableDirectories.contains(parent)) return;
for (Path directory = parent.getParent(); directory != null; directory = directory.getParent())
syncer.sync(directory);
durableDirectories.add(parent);
}

/**
* Forget a folder that was just created, and every folder under it, so the next write
* there flushes its whole ancestor chain again. Call after mkdirs on a folder that may
* have existed before.
*/
public static void forget(Path directory) {
Path root = directory.toAbsolutePath().normalize();
durableDirectories.removeIf(known -> known.startsWith(root));
}

/** The highest ancestor of {@code directory} (or itself) that does not exist yet, or null. */
private static Path firstMissing(Path directory) {
Path missing = null;
for (Path path = directory; path != null && !Files.exists(path); path = path.getParent()) missing = path;
return missing;
}

/** Test hook: forget which directories were flushed. */
static void forgetDurableDirectories() {
durableDirectories.clear();
}

private static void sync(Path directory) throws IOException {
private static void force(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4835,6 +4835,7 @@ private static File tablesFolder() {
File folder = new File(Games.plugin.getDataFolder(), "Data/tables");
if (!folder.exists()) {
folder.mkdirs();
net.tfminecraft.games.persistence.DurableFiles.forget(folder.toPath());
}
return folder;
}
Expand Down
15 changes: 15 additions & 0 deletions src/test/java/net/tfminecraft/games/ownership/TableEstateTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,21 @@ void untouchedRetainedPropertyTransfersOnlyOnNewRevisionWithCumulativeCompletion
assertFalse(Files.exists(journal(4)));
}

@Test
void repeatedRetainedSettlementKeepsItsUnchangedReceiptFile() throws Exception {
write(raw(tableId));
var decision = decision(1, Recipient.WAITING);
Outcome first = estate.settle(decision);
assertInstanceOf(Outcome.Done.class, first);
String receipt = Files.readString(journal(1));
java.nio.file.attribute.FileTime old =
java.nio.file.attribute.FileTime.fromMillis(1_000_000L);
Files.setLastModifiedTime(journal(1), old);
assertEquals(first, estate.settle(decision));
assertEquals(receipt, Files.readString(journal(1)));
assertEquals(old, Files.getLastModifiedTime(journal(1)));
}

@Test
void guardedPublicationCutAfterRetainedCompletionReplaysItsExactCurrentSnapshot()
throws Exception {
Expand Down
143 changes: 143 additions & 0 deletions src/test/java/net/tfminecraft/games/persistence/DurableFilesTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package net.tfminecraft.games.persistence;

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.List;

class DurableFilesTest {
@TempDir Path data;
private final List<Path> flushed = new ArrayList<>();
private Path failOnce;
private DurableFiles.Syncer original;

@BeforeEach
void countFlushes() {
original = DurableFiles.syncer;
DurableFiles.forgetDurableDirectories();
DurableFiles.syncer = directory -> {
if (directory.equals(failOnce)) {
failOnce = null;
throw new IOException("flush failed");
}
flushed.add(directory);
};
}

@AfterEach
void restoreFlushes() {
DurableFiles.syncer = original;
DurableFiles.forgetDurableDirectories();
}

private Path tables() {
return data.toAbsolutePath().resolve("estates/tables");
}

@Test
void writeCreatesMissingDirectoriesAndLeavesNoPendingFiles() throws Exception {
Path target = data.resolve("estates/tables/plan.json");
DurableFiles.write(target, "first");
assertEquals("first", Files.readString(target));
DurableFiles.write(target, "second");
assertEquals("second", Files.readString(target));
try (var files = Files.list(target.getParent())) {
assertEquals(1, files.count());
}
}

@Test
void firstWriteFlushesEveryAncestorAndLaterWritesOnlyTheParent() throws Exception {
DurableFiles.write(tables().resolve("a.json"), "a");
assertEquals(tables(), flushed.get(0));
assertTrue(flushed.contains(data.toAbsolutePath()));
assertTrue(flushed.contains(tables().getRoot()));
flushed.clear();
DurableFiles.write(tables().resolve("b.json"), "b");
assertEquals(List.of(tables()), flushed);
}

@Test
void folderMadeElsewhereStillGetsItsAncestorsFlushed() throws Exception {
// Games.createFolders() and TableManager use mkdirs before their first durable write.
Files.createDirectories(tables());
DurableFiles.write(tables().resolve("a.json"), "a");
assertTrue(flushed.contains(tables().getParent()));
assertTrue(flushed.contains(data.toAbsolutePath()));
}

@Test
void failedAncestorFlushIsRetriedEvenForAnUnchangedFile() throws Exception {
Path target = tables().resolve("plan.json");
failOnce = tables().getParent();
assertThrows(IOException.class, () -> DurableFiles.writeIfChanged(target, "receipt"));
assertEquals("receipt", Files.readString(target));
flushed.clear();
assertFalse(DurableFiles.writeIfChanged(target, "receipt"));
assertTrue(flushed.contains(tables()));
assertTrue(flushed.contains(tables().getParent()));
flushed.clear();
assertFalse(DurableFiles.writeIfChanged(target, "receipt"));
assertEquals(List.of(tables()), flushed);
}

@Test
void folderRecreatedByAWriteIsFlushedInFull() throws Exception {
Path target = tables().resolve("plan.json");
DurableFiles.write(target, "a");
Files.delete(target);
Files.delete(tables());
flushed.clear();
DurableFiles.write(target, "b");
assertTrue(flushed.contains(tables().getParent()));
assertTrue(flushed.contains(data.toAbsolutePath()));
}

@Test
void folderRecreatedByMkdirsIsFlushedInFullOnceForgotten() throws Exception {
Path target = tables().resolve("plan.json");
DurableFiles.write(target, "a");
Files.delete(target);
Files.delete(tables());
Files.createDirectories(tables());
DurableFiles.forget(tables().getParent());
flushed.clear();
DurableFiles.write(target, "b");
assertTrue(flushed.contains(tables().getParent()));
flushed.clear();
DurableFiles.write(target, "c");
assertEquals(List.of(tables()), flushed);
}

@Test
void writeIfChangedSkipsIdenticalContentOnly() throws Exception {
Path target = data.resolve("estates/tables/plan.json");
assertTrue(DurableFiles.writeIfChanged(target, "receipt"));
FileTime old = FileTime.fromMillis(1_000_000L);
Files.setLastModifiedTime(target, old);
assertFalse(DurableFiles.writeIfChanged(target, "receipt"));
assertEquals(old, Files.getLastModifiedTime(target));
assertTrue(DurableFiles.writeIfChanged(target, "receipt "));
assertEquals("receipt ", Files.readString(target));
assertNotEquals(old, Files.getLastModifiedTime(target));
}

@Test
void deleteRemovesTheFileAndFlushesItsFolder() throws Exception {
Path target = data.resolve("ownership-active");
DurableFiles.write(target, "Games is running\n");
flushed.clear();
DurableFiles.delete(target);
assertFalse(Files.exists(target));
assertEquals(List.of(data.toAbsolutePath()), flushed);
}
}
Loading