Compare commits

...
1 Commits
Author SHA1 Message Date
dmg 3215697e3f feat(crafting): retain ownership through async craft saves
Release / release (push) Successful in 4m58s
CI / build (push) Successful in 2m33s
2026-09-17 22:47:35 -04:00
4 changed files with 592 additions and 2 deletions

No files matched your search

+8
View File
@@ -31,6 +31,14 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. This checkpoint does not implement cross-save reconciliation, an administrator lock, automatic overflow drops or player/session persistence.
## Craft preparation and conservative cleanup
`CharacterStorage.prepareCraft` accepts an immutable batch of distinct fresh character records and returns a process-local `CraftPreparation` handle. Allocation/configured starting points remain the caller's responsibility. Retain this handle until delivery or abandonment: `ready()` returns an observation copy, and cancelling/timing out that future does **not** cancel the owned operation. Use `abandon()` to invalidate the attempt, including while writes are queued/in progress. Writes acknowledge the complete prepared batch; `claimForDelivery()` exposes it once, immediately before native delivery. Claiming permanently rules out cleanup, even if later delivery throws.
Abandonment skips remaining queued writes and schedules cleanup for acknowledged, never-issued creations. Cleanup checks the original file identity/creation and modification attributes and exact bytes under the repository lock. Changed, replaced, corrupt, symlinked, busy or unidentifiable files are retained. Partial-save failure cleans only acknowledged creations and reports uncertain targets without deleting them. Shutdown rejection preserves records rather than inventing successful cleanup. Repeated cleanup cannot delete a later file. Restart never reconstructs cleanup authority from revision zero or scans records for automatic deletion/reissue.
Thirteen craft-persistence tests cover these backend boundaries, including worker timing, partial/post-rename failures, ownership changes, restart, shutdown, claim/abandon races, and mutated/cancelled observer futures. This is **not native inventory delivery**: the controller still must contain vanilla consumption, validate current view/lifecycle/content and commit actual items. Filesystem locks coordinate cooperating writers; concurrent external filesystem mutation must be excluded/coordinated. File flush/atomic rename does not establish directory power-loss durability or Minecraft/plugin-save atomicity. Empty coordination `.lock` files remain after YAML cleanup; they must not be unlinked casually because that can split locking between writers. Their file-count growth still needs a bounded coordination design before gameplay registration.
## Paper sheet and recipe definition
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
@@ -5,6 +5,10 @@ import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -33,6 +37,152 @@ public final class CharacterStorage implements AutoCloseable {
});
}
/**
* Prepare already allocated fresh characters off-thread. The caller validates configured
* starting points and retains the returned handle through success, cancellation and disable.
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
*/
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
var snapshot = List.copyOf(records);
var ids = new HashSet<UUID>();
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
for (var record : snapshot) {
if (!ids.add(record.id()) || record.revision().signum() != 0 || record.name().isBlank()
|| record.dead() || record.deathId() != null || record.level() != 1
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|| !record.learned().isEmpty() || !record.cooldowns().isEmpty()
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
throw new IllegalArgumentException("Craft batch requires distinct new characters");
}
}
return new CraftPreparation(snapshot);
}
public final class CraftPreparation {
private final List<CharacterRecord> requested;
private final CompletableFuture<CraftBatch> completion;
private volatile boolean abandoned;
private CompletableFuture<CraftCleanup> cleanup;
private CraftPreparation(List<CharacterRecord> records) {
requested = records;
completion = writeCraftBatch(records, this);
}
/** Observation only; cancelling/timing out this copy does not cancel the owned operation. */
public CompletableFuture<CraftBatch> ready() { return completion.copy(); }
/** Retain this handle through completion; invalidate the attempt, not an observation future. */
public synchronized CompletableFuture<CraftCleanup> abandon() {
abandoned = true;
if (cleanup == null) {
cleanup = completion.handle((batch, failure) -> {
if (batch != null) return batch.abandon();
var cause = failure instanceof CompletionException ? failure.getCause() : failure;
var outcome = cause instanceof CraftPreparationException preparation ? preparation.cleanup()
: retainAll(requested);
return CompletableFuture.completedFuture(outcome);
}).thenCompose(result -> result);
}
return cleanup.copy();
}
}
private CompletableFuture<CraftBatch> writeCraftBatch(List<CharacterRecord> snapshot, CraftPreparation owner) {
return submit(() -> {
var created = new ArrayList<YamlCharacterRepository.Creation>();
UUID attempted = null;
try {
for (var record : snapshot) {
if (owner.abandoned) break;
attempted = record.id();
created.add(repository.createTracked(record));
attempted = null;
}
return new CraftBatch(created, owner);
} catch (IOException | RuntimeException failure) {
var cleanup = discardUnissued(created);
var retained = new HashSet<>(cleanup.retained());
if (attempted != null) retained.add(attempted);
throw new CraftPreparationException(failure, new CraftCleanup(cleanup.removed(), retained));
}
});
}
public static final class CraftPreparationException extends IOException {
@java.io.Serial private static final long serialVersionUID = 1L;
private final transient CraftCleanup cleanup;
private CraftPreparationException(Throwable cause, CraftCleanup cleanup) {
super("Character craft preparation failed; no batch was issued", cause);
this.cleanup = cleanup;
}
public CraftCleanup cleanup() { return cleanup; }
}
/** Removed includes already-absent files; retained includes failed writes whose outcome is unknown. */
public record CraftCleanup(Set<UUID> removed, Set<UUID> retained) {
public CraftCleanup {
removed = Set.copyOf(removed);
retained = Set.copyOf(retained);
}
}
public final class CraftBatch {
private final List<CharacterRecord> records;
private final List<YamlCharacterRepository.Creation> created;
private final CraftPreparation owner;
private boolean resolved;
private CompletableFuture<CraftCleanup> cleanup;
private CraftBatch(List<YamlCharacterRepository.Creation> created, CraftPreparation owner) {
this.owner = owner;
this.created = List.copyOf(created);
this.records = created.stream().map(creation -> creation.record).toList();
}
/** One-use handoff immediately before native delivery; an exception afterward cannot authorize deletion. */
public synchronized Optional<List<CharacterRecord>> claimForDelivery() {
if (resolved || owner.abandoned) return Optional.empty();
resolved = true;
if (worker.isShutdown()) return Optional.empty();
return Optional.of(records);
}
public synchronized CompletableFuture<CraftCleanup> abandon() {
if (cleanup == null) {
if (resolved) {
cleanup = CompletableFuture.completedFuture(retainAll(records));
} else {
resolved = true;
cleanup = submit(() -> discardUnissued(created)).exceptionally(failure -> retainAll(records));
}
}
return cleanup.copy();
}
}
private static CraftCleanup retainAll(List<CharacterRecord> records) {
return new CraftCleanup(Set.of(), records.stream().map(CharacterRecord::id)
.collect(java.util.stream.Collectors.toSet()));
}
private CraftCleanup discardUnissued(List<YamlCharacterRepository.Creation> created) {
var removed = new HashSet<UUID>();
var retained = new HashSet<UUID>();
for (var creation : created) {
try {
if (repository.discardUnissued(creation)) removed.add(creation.record.id());
else retained.add(creation.record.id());
} catch (IOException exception) {
retained.add(creation.record.id());
}
}
return new CraftCleanup(removed, retained);
}
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
return submit(() -> repository.load(id));
}
@@ -11,6 +11,8 @@ import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -37,13 +39,32 @@ public final class YamlCharacterRepository {
}
public void create(CharacterRecord character) throws IOException {
createTracked(character);
}
/** Process-local proof; never reconstructed from a revision-zero file after restart. */
static final class Creation {
final CharacterRecord record;
private final BasicFileAttributes attributes;
private final byte[] bytes;
private Creation(CharacterRecord record, BasicFileAttributes attributes, byte[] bytes) {
this.record = record;
this.attributes = attributes;
this.bytes = bytes;
}
}
Creation createTracked(CharacterRecord character) throws IOException {
if (character.revision().signum() != 0) throw new IOException("New character revision must be zero");
Creation[] created = new Creation[1];
withLock(character.id(), () -> {
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Character already exists: " + character.id());
}
write(character);
created[0] = write(character);
});
return created[0];
}
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
@@ -57,6 +78,31 @@ public final class YamlCharacterRepository {
});
}
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
boolean discardUnissued(Creation created) throws IOException {
boolean[] removed = {false};
withLock(created.record.id(), () -> {
var path = file(created.record.id());
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
removed[0] = true;
return;
}
var current = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
var expected = created.attributes;
if (!current.isRegularFile() || expected.fileKey() == null
|| !expected.fileKey().equals(current.fileKey())
|| !expected.creationTime().equals(current.creationTime())
|| !expected.lastModifiedTime().equals(current.lastModifiedTime())
|| current.size() != created.bytes.length) return;
try (var input = Files.newInputStream(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
if (!Arrays.equals(created.bytes, input.readNBytes(created.bytes.length + 1))) return;
}
Files.delete(path);
removed[0] = true;
});
return removed[0];
}
public Optional<CharacterRecord> load(UUID id) throws IOException {
Path file = file(id);
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
@@ -73,7 +119,7 @@ public final class YamlCharacterRepository {
}
}
private void write(CharacterRecord character) throws IOException {
private Creation write(CharacterRecord character) throws IOException {
byte[] bytes = CharacterYaml.encode(character).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Character YAML exceeds the safe file-size limit");
Path temporary = Files.createTempFile(directory, ".character-", ".tmp");
@@ -83,8 +129,10 @@ public final class YamlCharacterRepository {
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
var attributes = Files.readAttributes(temporary, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
// Never fall back to an in-place, partially written character record.
replacement.move(temporary, file(character.id()));
return new Creation(character, attributes, bytes);
} finally {
Files.deleteIfExists(temporary);
}
@@ -0,0 +1,384 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.CharacterProgress;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.nio.file.Path;
import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CraftPersistenceTest {
@TempDir Path data;
@Test
void persistsTheWholeBatchBeforeExposingItForOneDeliveryAndRetainsPossiblyIssuedRecords() throws Exception {
var first = fresh("Ari Ash");
var second = fresh("Iris Vale");
var records = List.of(first, second);
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
var batch = storage.prepareCraft(records).ready().join();
for (var record : records) assertEquals(record, storage.find(record.id()).join().orElseThrow());
assertEquals(records, batch.claimForDelivery().orElseThrow());
assertTrue(batch.claimForDelivery().isEmpty(), "A duplicate callback cannot claim the same batch twice");
var cleanup = batch.abandon().join();
assertTrue(cleanup.removed().isEmpty());
assertEquals(Set.of(first.id(), second.id()), cleanup.retained());
for (var record : records) assertEquals(record, storage.find(record.id()).join().orElseThrow());
}
}
@Test
void abandoningAnUnissuedBatchRemovesOnlyItsOwnFilesAndCannotReplayCleanup() throws Exception {
var unused = fresh("Unused character");
var unrelated = fresh("Existing character");
var repository = new YamlCharacterRepository(data);
repository.create(unrelated);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var batch = storage.prepareCraft(List.of(unused)).ready().join();
var cleanup = batch.abandon().join();
assertEquals(Set.of(unused.id()), cleanup.removed(), "An unchanged never-issued record must be cleaned up");
assertTrue(cleanup.retained().isEmpty());
assertTrue(storage.find(unused.id()).join().isEmpty());
assertEquals(unrelated, storage.find(unrelated.id()).join().orElseThrow());
assertTrue(batch.claimForDelivery().isEmpty(), "Abandonment permanently invalidates delivery");
repository.create(unused);
assertEquals(cleanup, batch.abandon().join(), "Repeated callbacks return the original cleanup outcome");
assertEquals(unused, storage.find(unused.id()).join().orElseThrow(), "Never replay deletion on a later file");
}
}
@Test
void retainsEditedBytesAndReplacedFilesEvenWhenTheyDecodeToTheOriginalCharacter() throws Exception {
for (boolean replaceFile : new boolean[] {false, true}) {
var original = fresh("Do not discard edited or replaced state");
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
var batch = storage.prepareCraft(List.of(original)).ready().join();
var file = data.resolve("characters").resolve(original.id() + ".yml");
byte[] bytes = java.nio.file.Files.readAllBytes(file);
if (replaceFile) {
var replacement = java.nio.file.Files.createTempFile(file.getParent(), "external-", ".tmp");
java.nio.file.Files.write(replacement, bytes);
java.nio.file.Files.move(replacement, file, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} else {
java.nio.file.Files.writeString(file, "\n# Operator annotation\n", java.nio.file.StandardOpenOption.APPEND);
}
assertEquals(original, storage.find(original.id()).join().orElseThrow());
var cleanup = batch.abandon().join();
assertEquals(Set.of(original.id()), cleanup.retained(), "Semantic equality is not ownership/unchanged-file proof");
assertTrue(cleanup.removed().isEmpty());
assertTrue(java.nio.file.Files.isRegularFile(file));
}
}
}
@Test
void partialSaveFailureCleansAcknowledgedUnissuedFilesAndAllowsANormalRetry() throws Exception {
var first = fresh("First");
var second = fresh("Second");
var third = fresh("Never attempted");
var records = List.of(first, second, third);
var repository = new YamlCharacterRepository(data);
var writes = new java.util.concurrent.atomic.AtomicInteger();
var failing = new YamlCharacterRepository(data, (temporary, target) -> {
if (writes.incrementAndGet() == 2) throw new java.io.IOException("Simulated second write failure");
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
});
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
assertThrows(java.util.concurrent.CompletionException.class, () -> storage.prepareCraft(records).ready().join());
assertTrue(repository.load(first.id()).isEmpty(), "Clean the acknowledged first write, not a half-issued batch");
assertTrue(repository.load(second.id()).isEmpty());
assertTrue(repository.load(third.id()).isEmpty());
assertEquals(2, writes.get());
}
try (var retry = new CharacterStorage(repository, Clock.systemUTC())) {
assertEquals(records, retry.prepareCraft(records).ready().join().claimForDelivery().orElseThrow());
}
}
@Test
void retainsAnUnacknowledgedWriteInsteadOfGuessingWhetherTheMoveCommitted() throws Exception {
var first = fresh("Acknowledged");
var uncertain = fresh("Unacknowledged");
var writes = new java.util.concurrent.atomic.AtomicInteger();
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
if (writes.incrementAndGet() == 2) throw new java.io.IOException("Simulated failure after rename");
});
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var error = assertThrows(java.util.concurrent.CompletionException.class,
() -> storage.prepareCraft(List.of(first, uncertain)).ready().join());
var failure = assertInstanceOf(CharacterStorage.CraftPreparationException.class, error.getCause());
assertEquals(Set.of(first.id()), failure.cleanup().removed());
assertEquals(Set.of(uncertain.id()), failure.cleanup().retained());
assertTrue(storage.find(first.id()).join().isEmpty());
assertEquals(uncertain, storage.find(uncertain.id()).join().orElseThrow());
}
}
@Test
void rejectsDuplicateEmptyOrNonFreshCraftBatchesBeforeAnyWrite() throws Exception {
var first = fresh("New character");
var advanced = YamlCharacterRepositoryTest.rename(first, "Already changed", java.math.BigInteger.ONE);
var writes = new java.util.concurrent.atomic.AtomicInteger();
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
writes.incrementAndGet();
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
});
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
for (var invalid : List.<List<CharacterRecord>>of(List.of(), List.of(first, first), List.of(first, advanced),
List.of(fresh(" ")), List.of(YamlCharacterRepositoryTest.sample(UUID.randomUUID())))) {
assertThrows(IllegalArgumentException.class, () -> storage.prepareCraft(invalid));
}
assertEquals(0, writes.get());
assertTrue(storage.find(first.id()).join().isEmpty());
}
}
@Test
void shutdownPreventsNewDeliveryAndRetainsRecordsWhenCleanupCannotRun() throws Exception {
var original = fresh("Shutdown uncertainty");
var other = fresh("No cleanup worker");
var repository = new YamlCharacterRepository(data);
var storage = new CharacterStorage(repository, Clock.systemUTC());
try {
var batch = storage.prepareCraft(List.of(original)).ready().join();
var unclaimed = storage.prepareCraft(List.of(other)).ready().join();
storage.close();
assertTrue(batch.claimForDelivery().isEmpty(), "A late completion cannot begin delivery after disable");
assertEquals(Set.of(original.id()), batch.abandon().join().retained());
assertEquals(Set.of(other.id()), unclaimed.abandon().join().retained());
assertEquals(original, repository.load(original.id()).orElseThrow());
assertEquals(other, repository.load(other.id()).orElseThrow());
assertThrows(java.util.concurrent.CompletionException.class,
() -> storage.prepareCraft(List.of(fresh("Rejected"))).ready().join());
} finally {
storage.close();
}
}
@Test
void batchWritesRunOffCallerThreadAndAcknowledgeOnlyAfterTheLastCommit() throws Exception {
var original = List.of(fresh("First durable write"), fresh("Last durable write"));
var input = new java.util.ArrayList<>(original);
var entered = new java.util.concurrent.CountDownLatch(1);
var allowCommit = new java.util.concurrent.CountDownLatch(1);
var caller = Thread.currentThread();
var writes = new java.util.concurrent.atomic.AtomicInteger();
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
assertNotSame(caller, Thread.currentThread());
if (writes.incrementAndGet() == 2) {
entered.countDown();
try {
if (!allowCommit.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timed out");
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new java.io.IOException(error);
}
}
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
});
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var ready = storage.prepareCraft(input).ready();
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
input.clear();
assertFalse(ready.isDone(), "No usable batch before the final file is acknowledged");
} finally {
allowCommit.countDown();
}
assertEquals(original, ready.join().claimForDelivery().orElseThrow());
}
}
@Test
void aCollisionAndAReopenedStoreCannotTreatExistingFilesAsOwnedUnissuedRecords() throws Exception {
var existing = fresh("Existing possibly issued character");
var newRecord = fresh("New record before collision");
var repository = new YamlCharacterRepository(data);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.prepareCraft(List.of(existing)).ready().join(); // Deliberately lose the in-memory proof at shutdown.
}
try (var reopened = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
assertEquals(existing, reopened.find(existing.id()).join().orElseThrow());
var error = assertThrows(java.util.concurrent.CompletionException.class,
() -> reopened.prepareCraft(List.of(newRecord, existing)).ready().join());
var failure = assertInstanceOf(CharacterStorage.CraftPreparationException.class, error.getCause());
assertEquals(Set.of(newRecord.id()), failure.cleanup().removed());
assertEquals(Set.of(existing.id()), failure.cleanup().retained());
assertEquals(existing, reopened.find(existing.id()).join().orElseThrow());
assertTrue(reopened.find(newRecord.id()).join().isEmpty());
}
}
@Test
void concurrentDeliveryAndAbandonmentHaveOnlyOneWinner() throws Exception {
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var pool = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
for (int round = 0; round < 12; round++) {
var record = fresh("Race " + round);
var batch = storage.prepareCraft(List.of(record)).ready().join();
var start = new java.util.concurrent.CountDownLatch(1);
var delivery = pool.submit(() -> { start.await(); return batch.claimForDelivery(); });
var abandonment = pool.submit(() -> { start.await(); return batch.abandon().join(); });
start.countDown();
var claimed = delivery.get();
var cleanup = abandonment.get();
if (claimed.isPresent()) {
assertEquals(Set.of(record.id()), cleanup.retained());
assertTrue(cleanup.removed().isEmpty());
assertEquals(record, storage.find(record.id()).join().orElseThrow());
} else {
assertEquals(Set.of(record.id()), cleanup.removed());
assertTrue(cleanup.retained().isEmpty());
assertTrue(storage.find(record.id()).join().isEmpty());
}
assertTrue(batch.claimForDelivery().isEmpty());
}
}
}
@Test
void cleanupRetainsChangedCorruptSymlinkedAndBusyRecordsWithoutStoppingOtherCleanup() throws Exception {
var repository = new YamlCharacterRepository(data);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
for (int scenario = 0; scenario < 4; scenario++) {
var protectedRecord = fresh("Retained " + scenario);
var removable = fresh("Removable " + scenario);
var batch = storage.prepareCraft(List.of(protectedRecord, removable)).ready().join();
var file = data.resolve("characters").resolve(protectedRecord.id() + ".yml");
if (scenario == 0) repository.replace(YamlCharacterRepositoryTest.rename(protectedRecord,
"Updated progression revision", java.math.BigInteger.ONE), java.math.BigInteger.ZERO);
if (scenario == 1) java.nio.file.Files.writeString(file, "unparseable character state");
if (scenario == 2) {
var target = data.resolve("protected-target.yml");
java.nio.file.Files.copy(file, target);
java.nio.file.Files.delete(file);
java.nio.file.Files.createSymbolicLink(file, target);
}
CharacterStorage.CraftCleanup cleanup;
if (scenario == 3) {
try (var channel = java.nio.channels.FileChannel.open(
data.resolve("characters").resolve(protectedRecord.id() + ".lock"),
java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
cleanup = batch.abandon().join();
}
} else cleanup = batch.abandon().join();
assertEquals(Set.of(protectedRecord.id()), cleanup.retained());
assertEquals(Set.of(removable.id()), cleanup.removed());
assertTrue(java.nio.file.Files.exists(file, java.nio.file.LinkOption.NOFOLLOW_LINKS));
assertTrue(storage.find(removable.id()).join().isEmpty());
if (scenario == 2) {
assertTrue(java.nio.file.Files.isSymbolicLink(file));
assertTrue(java.nio.file.Files.isRegularFile(data.resolve("protected-target.yml")));
}
}
}
}
@Test
void cancellingTimingOutOrCompletingAnObservationCannotLosePendingOwnershipOrPreventAbandonment() throws Exception {
for (int mutation = 0; mutation < 3; mutation++) {
var first = fresh("Pending ownership");
var second = fresh("Cancelled remainder");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
entered.countDown();
try {
if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timed out");
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new java.io.IOException(failure);
}
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
});
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var attempt = storage.prepareCraft(List.of(first, second));
var observation = attempt.ready();
java.util.concurrent.CompletableFuture<CharacterStorage.CraftCleanup> cleanup;
java.util.concurrent.CompletableFuture<Boolean> refusedDelivery;
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
if (mutation == 0) observation.cancel(true);
if (mutation == 1) observation.completeExceptionally(new java.util.concurrent.TimeoutException("Observer timeout"));
if (mutation == 2) observation.complete(null);
assertFalse(attempt.ready().isDone(), "An observer must not mutate the owned persistence operation");
cleanup = attempt.abandon();
refusedDelivery = attempt.ready().thenApply(batch -> batch.claimForDelivery().isEmpty());
} finally {
release.countDown();
}
assertTrue(refusedDelivery.join(), "A stale attempt must refuse even an early completion callback");
assertEquals(Set.of(first.id()), cleanup.join().removed());
assertTrue(cleanup.join().retained().isEmpty());
assertTrue(storage.find(first.id()).join().isEmpty());
assertTrue(storage.find(second.id()).join().isEmpty());
}
}
}
@Test
void abandonmentBeforeExecutionSkipsWritesAndCancelledCleanupObserversCannotStopCleanup() throws Exception {
var original = fresh("Already prepared");
var blocker = fresh("Queue blocker");
var neverWritten = fresh("Cancelled before worker start");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var writes = new java.util.concurrent.atomic.AtomicInteger();
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
writes.incrementAndGet();
if (target.getFileName().toString().equals(blocker.id() + ".yml")) {
entered.countDown();
try {
if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timed out");
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new java.io.IOException(failure);
}
}
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
});
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var attempt = storage.prepareCraft(List.of(original));
attempt.ready().join();
var busy = storage.create(blocker);
CharacterStorage.CraftPreparation queued;
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
queued = storage.prepareCraft(List.of(neverWritten));
queued.abandon();
assertTrue(attempt.abandon().cancel(true));
assertFalse(attempt.abandon().isDone());
} finally {
release.countDown();
}
busy.join();
assertTrue(queued.ready().join().claimForDelivery().isEmpty());
assertTrue(queued.abandon().join().removed().isEmpty());
assertTrue(queued.abandon().join().retained().isEmpty());
assertEquals(Set.of(original.id()), attempt.abandon().join().removed());
assertEquals(2, writes.get(), "The abandoned queued attempt must never reach the file writer");
assertTrue(storage.find(neverWritten.id()).join().isEmpty());
assertTrue(storage.find(original.id()).join().isEmpty());
assertEquals(blocker, storage.find(blocker.id()).join().orElseThrow());
}
}
private static CharacterRecord fresh(String name) {
var initial = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults()).snapshot();
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(), Set.of(),
List.of("", "", "", "", "", "", ""), Map.of(), false, null, null);
}
}