Add file-backed region persistence
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package com.livingworld.core.services;
|
||||
|
||||
import com.livingworld.data.saved.SaveMetadata;
|
||||
import com.livingworld.debug.DiagnosticCategory;
|
||||
import com.livingworld.debug.LivingWorldLogger;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFlags;
|
||||
import com.livingworld.regions.RegionLifecycleState;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import com.livingworld.regions.RegionModuleData;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Clock;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* File-backed persistence for the Volume 1 region model.
|
||||
*/
|
||||
public final class FileRegionPersistenceService implements PersistenceService {
|
||||
|
||||
static final int SCHEMA_VERSION = 1;
|
||||
|
||||
private static final String METADATA_FILE = "metadata.properties";
|
||||
private static final String REGIONS_DIRECTORY = "regions";
|
||||
|
||||
private final Path rootDirectory;
|
||||
private final Path regionsDirectory;
|
||||
private final String modVersion;
|
||||
private final Clock clock;
|
||||
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
|
||||
private SaveMetadata metadata;
|
||||
|
||||
public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
|
||||
this(rootDirectory, modVersion, Clock.systemUTC());
|
||||
}
|
||||
|
||||
FileRegionPersistenceService(Path rootDirectory, String modVersion, Clock clock) {
|
||||
if (rootDirectory == null) {
|
||||
throw new IllegalArgumentException("rootDirectory must not be null");
|
||||
}
|
||||
if (modVersion == null || modVersion.isBlank()) {
|
||||
throw new IllegalArgumentException("modVersion must not be blank");
|
||||
}
|
||||
if (clock == null) {
|
||||
throw new IllegalArgumentException("clock must not be null");
|
||||
}
|
||||
this.rootDirectory = rootDirectory;
|
||||
this.regionsDirectory = rootDirectory.resolve(REGIONS_DIRECTORY);
|
||||
this.modVersion = modVersion;
|
||||
this.clock = clock;
|
||||
initializeStorage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void markRegionDirty(Region region) {
|
||||
requireRegion(region);
|
||||
region.markDirty();
|
||||
dirtyRegions.put(region.getCoordinate(), region);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void saveDirtyRegions() {
|
||||
for (Region region : java.util.List.copyOf(dirtyRegions.values())) {
|
||||
saveRegion(region);
|
||||
}
|
||||
updateMetadata();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<Region> loadRegion(RegionCoordinate coordinate) {
|
||||
if (coordinate == null) {
|
||||
throw new IllegalArgumentException("coordinate must not be null");
|
||||
}
|
||||
Path file = regionPath(coordinate);
|
||||
if (!Files.exists(file)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
Properties properties = new Properties();
|
||||
properties.load(input);
|
||||
Region region = decodeRegion(properties);
|
||||
if (!coordinate.equals(region.getCoordinate())) {
|
||||
throw new IllegalStateException(
|
||||
"stored coordinate does not match requested coordinate");
|
||||
}
|
||||
return Optional.of(region);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
quarantineCorruptFile(file, exception);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void saveRegion(Region region) {
|
||||
requireRegion(region);
|
||||
region.validate();
|
||||
|
||||
Properties properties = encodeRegion(region);
|
||||
Path target = regionPath(region.getCoordinate());
|
||||
Path temporary = target.resolveSibling(target.getFileName() + ".tmp");
|
||||
try {
|
||||
Files.createDirectories(regionsDirectory);
|
||||
try (OutputStream output = Files.newOutputStream(temporary)) {
|
||||
properties.store(output, "Living World region data");
|
||||
}
|
||||
moveAtomically(temporary, target);
|
||||
region.clearDirty();
|
||||
dirtyRegions.remove(region.getCoordinate());
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException(
|
||||
"Failed to save region " + region.getCoordinate(), exception);
|
||||
} finally {
|
||||
tryDelete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void flushAll() {
|
||||
saveDirtyRegions();
|
||||
}
|
||||
|
||||
public synchronized int getDirtyRegionCount() {
|
||||
return dirtyRegions.size();
|
||||
}
|
||||
|
||||
private void initializeStorage() {
|
||||
try {
|
||||
Files.createDirectories(regionsDirectory);
|
||||
metadata = loadMetadata().orElseGet(() -> {
|
||||
long now = clock.millis();
|
||||
return new SaveMetadata(SCHEMA_VERSION, modVersion, now, now);
|
||||
});
|
||||
if (metadata.schemaVersion() != SCHEMA_VERSION) {
|
||||
throw new IllegalStateException(
|
||||
"Unsupported save schema version: " + metadata.schemaVersion());
|
||||
}
|
||||
writeMetadata(metadata);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException(
|
||||
"Failed to initialize persistence at " + rootDirectory, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<SaveMetadata> loadMetadata() throws IOException {
|
||||
Path path = rootDirectory.resolve(METADATA_FILE);
|
||||
if (!Files.exists(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
properties.load(input);
|
||||
}
|
||||
return Optional.of(new SaveMetadata(
|
||||
requiredInt(properties, "schemaVersion"),
|
||||
required(properties, "modVersion"),
|
||||
requiredLong(properties, "createdAt"),
|
||||
requiredLong(properties, "updatedAt")));
|
||||
}
|
||||
|
||||
private void updateMetadata() {
|
||||
metadata = new SaveMetadata(
|
||||
metadata.schemaVersion(),
|
||||
modVersion,
|
||||
metadata.createdAt(),
|
||||
clock.millis());
|
||||
try {
|
||||
writeMetadata(metadata);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to update save metadata", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeMetadata(SaveMetadata value) throws IOException {
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("schemaVersion", Integer.toString(value.schemaVersion()));
|
||||
properties.setProperty("modVersion", value.modVersion());
|
||||
properties.setProperty("createdAt", Long.toString(value.createdAt()));
|
||||
properties.setProperty("updatedAt", Long.toString(value.updatedAt()));
|
||||
Path target = rootDirectory.resolve(METADATA_FILE);
|
||||
Path temporary = target.resolveSibling(target.getFileName() + ".tmp");
|
||||
try (OutputStream output = Files.newOutputStream(temporary)) {
|
||||
properties.store(output, "Living World save metadata");
|
||||
}
|
||||
moveAtomically(temporary, target);
|
||||
}
|
||||
|
||||
private Properties encodeRegion(Region region) {
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("schemaVersion", Integer.toString(SCHEMA_VERSION));
|
||||
properties.setProperty("id", region.getId().toString());
|
||||
properties.setProperty("dimensionId", region.getCoordinate().dimensionId());
|
||||
properties.setProperty("regionX", Integer.toString(region.getCoordinate().x()));
|
||||
properties.setProperty("regionZ", Integer.toString(region.getCoordinate().z()));
|
||||
properties.setProperty("lifecycleState", region.getLifecycleState().name());
|
||||
properties.setProperty(
|
||||
"createdAtSimulationTick",
|
||||
Long.toString(region.getCreatedAtSimulationTick()));
|
||||
properties.setProperty(
|
||||
"lastUpdatedSimulationTick",
|
||||
Long.toString(region.getLastUpdatedSimulationTick()));
|
||||
|
||||
RegionFlags flags = region.getFlags();
|
||||
properties.setProperty("flag.playerActivity", Boolean.toString(flags.isHasPlayerActivity()));
|
||||
properties.setProperty("flag.highPollution", Boolean.toString(flags.isHasHighPollution()));
|
||||
properties.setProperty("flag.lowSoilQuality", Boolean.toString(flags.isHasLowSoilQuality()));
|
||||
properties.setProperty(
|
||||
"flag.activeEcosystemEvent",
|
||||
Boolean.toString(flags.isHasActiveEcosystemEvent()));
|
||||
properties.setProperty(
|
||||
"flag.forceLoaded",
|
||||
Boolean.toString(flags.isForceLoadedBySimulation()));
|
||||
properties.setProperty("flag.corrupted", Boolean.toString(flags.isCorrupted()));
|
||||
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
properties.setProperty(
|
||||
"metric.ecosystemHealth",
|
||||
Double.toString(metrics.getEcosystemHealth()));
|
||||
properties.setProperty(
|
||||
"metric.pollutionScore",
|
||||
Double.toString(metrics.getPollutionScore()));
|
||||
properties.setProperty("metric.soilQuality", Double.toString(metrics.getSoilQuality()));
|
||||
properties.setProperty("metric.waterQuality", Double.toString(metrics.getWaterQuality()));
|
||||
properties.setProperty(
|
||||
"metric.vegetationPressure",
|
||||
Double.toString(metrics.getVegetationPressure()));
|
||||
properties.setProperty(
|
||||
"metric.resourceDepletion",
|
||||
Double.toString(metrics.getResourceDepletion()));
|
||||
properties.setProperty(
|
||||
"metric.recoveryPressure",
|
||||
Double.toString(metrics.getRecoveryPressure()));
|
||||
return properties;
|
||||
}
|
||||
|
||||
private Region decodeRegion(Properties properties) {
|
||||
int schemaVersion = requiredInt(properties, "schemaVersion");
|
||||
if (schemaVersion != SCHEMA_VERSION) {
|
||||
throw new IllegalStateException(
|
||||
"Unsupported region schema version: " + schemaVersion);
|
||||
}
|
||||
|
||||
RegionFlags flags = new RegionFlags();
|
||||
flags.setHasPlayerActivity(requiredBoolean(properties, "flag.playerActivity"));
|
||||
flags.setHasHighPollution(requiredBoolean(properties, "flag.highPollution"));
|
||||
flags.setHasLowSoilQuality(requiredBoolean(properties, "flag.lowSoilQuality"));
|
||||
flags.setHasActiveEcosystemEvent(
|
||||
requiredBoolean(properties, "flag.activeEcosystemEvent"));
|
||||
flags.setForceLoadedBySimulation(requiredBoolean(properties, "flag.forceLoaded"));
|
||||
flags.setCorrupted(requiredBoolean(properties, "flag.corrupted"));
|
||||
|
||||
RegionMetrics metrics = new RegionMetrics();
|
||||
metrics.setEcosystemHealth(requiredDouble(properties, "metric.ecosystemHealth"));
|
||||
metrics.setPollutionScore(requiredDouble(properties, "metric.pollutionScore"));
|
||||
metrics.setSoilQuality(requiredDouble(properties, "metric.soilQuality"));
|
||||
metrics.setWaterQuality(requiredDouble(properties, "metric.waterQuality"));
|
||||
metrics.setVegetationPressure(requiredDouble(properties, "metric.vegetationPressure"));
|
||||
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
|
||||
metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));
|
||||
|
||||
Region region = new Region(
|
||||
UUID.fromString(required(properties, "id")),
|
||||
new RegionCoordinate(
|
||||
required(properties, "dimensionId"),
|
||||
requiredInt(properties, "regionX"),
|
||||
requiredInt(properties, "regionZ")),
|
||||
RegionLifecycleState.valueOf(required(properties, "lifecycleState")),
|
||||
requiredLong(properties, "createdAtSimulationTick"),
|
||||
requiredLong(properties, "lastUpdatedSimulationTick"),
|
||||
false,
|
||||
flags,
|
||||
metrics,
|
||||
new RegionModuleData());
|
||||
region.validate();
|
||||
return region;
|
||||
}
|
||||
|
||||
private Path regionPath(RegionCoordinate coordinate) {
|
||||
String dimension = Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(coordinate.dimensionId().getBytes(StandardCharsets.UTF_8));
|
||||
return regionsDirectory.resolve(
|
||||
dimension + "_" + coordinate.x() + "_" + coordinate.z() + ".properties");
|
||||
}
|
||||
|
||||
private void quarantineCorruptFile(Path file, Exception exception) {
|
||||
Path quarantined = file.resolveSibling(
|
||||
file.getFileName() + ".corrupt-" + clock.millis());
|
||||
try {
|
||||
Files.move(file, quarantined, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException moveException) {
|
||||
exception.addSuppressed(moveException);
|
||||
}
|
||||
LivingWorldLogger.warn(
|
||||
DiagnosticCategory.PERSISTENCE,
|
||||
"Quarantined unreadable region file " + file + ": " + exception.getMessage());
|
||||
}
|
||||
|
||||
private static String required(Properties properties, String key) {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException("Missing required property: " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int requiredInt(Properties properties, String key) {
|
||||
return Integer.parseInt(required(properties, key));
|
||||
}
|
||||
|
||||
private static long requiredLong(Properties properties, String key) {
|
||||
return Long.parseLong(required(properties, key));
|
||||
}
|
||||
|
||||
private static double requiredDouble(Properties properties, String key) {
|
||||
return Double.parseDouble(required(properties, key));
|
||||
}
|
||||
|
||||
private static boolean requiredBoolean(Properties properties, String key) {
|
||||
String value = required(properties, key);
|
||||
if (!value.equals("true") && !value.equals("false")) {
|
||||
throw new IllegalStateException("Invalid boolean property " + key + ": " + value);
|
||||
}
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
private static void requireRegion(Region region) {
|
||||
if (region == null) {
|
||||
throw new IllegalArgumentException("region must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
private static void moveAtomically(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(
|
||||
source,
|
||||
target,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private static void tryDelete(Path path) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// The target save already succeeded or reported its own failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ public final class RegionManager implements com.livingworld.core.simulation.Regi
|
||||
}
|
||||
|
||||
regionCache.put(region);
|
||||
if (region.isDirty()) {
|
||||
regionStorage.markDirty(region);
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
@@ -90,7 +93,15 @@ public final class RegionManager implements com.livingworld.core.simulation.Regi
|
||||
throw new IllegalArgumentException(
|
||||
"a different region instance is already cached for " + region.getCoordinate());
|
||||
}
|
||||
region.markDirty();
|
||||
regionStorage.markDirty(region);
|
||||
}
|
||||
|
||||
public void saveDirtyRegions() {
|
||||
regionStorage.saveDirtyRegions();
|
||||
}
|
||||
|
||||
public void flushAll() {
|
||||
regionStorage.flushAll();
|
||||
}
|
||||
|
||||
public void unloadRegion(RegionCoordinate coordinate) {
|
||||
|
||||
@@ -53,4 +53,19 @@ public class RegionStorage {
|
||||
}
|
||||
persistenceService.saveRegion(region);
|
||||
}
|
||||
|
||||
public void markDirty(Region region) {
|
||||
if (region == null) {
|
||||
throw new IllegalArgumentException("Region must not be null");
|
||||
}
|
||||
persistenceService.markRegionDirty(region);
|
||||
}
|
||||
|
||||
public void saveDirtyRegions() {
|
||||
persistenceService.saveDirtyRegions();
|
||||
}
|
||||
|
||||
public void flushAll() {
|
||||
persistenceService.flushAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.livingworld.core.services;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class FileRegionPersistenceServiceTest {
|
||||
|
||||
private static final Clock CLOCK =
|
||||
Clock.fixed(Instant.ofEpochMilli(1_000L), ZoneOffset.UTC);
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void roundTripsCoreRegionState() {
|
||||
FileRegionPersistenceService service = service();
|
||||
Region original = new RegionFactory().createNewRegion(
|
||||
new RegionCoordinate("minecraft:overworld", -2, 3),
|
||||
12);
|
||||
original.updateLastSimulatedTick(42);
|
||||
original.getFlags().setHasHighPollution(true);
|
||||
original.getFlags().setForceLoadedBySimulation(true);
|
||||
original.getMetrics().setEcosystemHealth(37.5);
|
||||
original.getMetrics().setWaterQuality(22.25);
|
||||
|
||||
service.saveRegion(original);
|
||||
Optional<Region> loaded = service.loadRegion(original.getCoordinate());
|
||||
|
||||
assertTrue(loaded.isPresent());
|
||||
Region restored = loaded.orElseThrow();
|
||||
assertEquals(original.getId(), restored.getId());
|
||||
assertEquals(original.getCoordinate(), restored.getCoordinate());
|
||||
assertEquals(original.getLifecycleState(), restored.getLifecycleState());
|
||||
assertEquals(12, restored.getCreatedAtSimulationTick());
|
||||
assertEquals(42, restored.getLastUpdatedSimulationTick());
|
||||
assertTrue(restored.getFlags().isHasHighPollution());
|
||||
assertTrue(restored.getFlags().isForceLoadedBySimulation());
|
||||
assertEquals(37.5, restored.getMetrics().getEcosystemHealth());
|
||||
assertEquals(22.25, restored.getMetrics().getWaterQuality());
|
||||
assertFalse(restored.isDirty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyQueueIsPersistedAndCleared() {
|
||||
FileRegionPersistenceService service = service();
|
||||
Region region = new RegionFactory().createNewRegion(
|
||||
new RegionCoordinate("minecraft:overworld", 0, 0),
|
||||
0);
|
||||
|
||||
service.markRegionDirty(region);
|
||||
assertEquals(1, service.getDirtyRegionCount());
|
||||
|
||||
service.saveDirtyRegions();
|
||||
|
||||
assertEquals(0, service.getDirtyRegionCount());
|
||||
assertFalse(region.isDirty());
|
||||
assertTrue(service.loadRegion(region.getCoordinate()).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void corruptRegionIsQuarantinedInsteadOfCrashingLoad() throws Exception {
|
||||
FileRegionPersistenceService service = service();
|
||||
Region region = new RegionFactory().createNewRegion(
|
||||
new RegionCoordinate("minecraft:overworld", 1, 0),
|
||||
0);
|
||||
service.saveRegion(region);
|
||||
Path regionFile;
|
||||
try (var files = Files.list(temporaryDirectory.resolve("regions"))) {
|
||||
regionFile = files.findFirst().orElseThrow();
|
||||
}
|
||||
Files.writeString(regionFile, "not=a-valid-region");
|
||||
|
||||
assertTrue(service.loadRegion(region.getCoordinate()).isEmpty());
|
||||
try (var files = Files.list(temporaryDirectory.resolve("regions"))) {
|
||||
assertTrue(files.anyMatch(path -> path.getFileName().toString().contains(".corrupt-")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataSurvivesServiceRestart() {
|
||||
service().flushAll();
|
||||
|
||||
FileRegionPersistenceService restarted = service();
|
||||
|
||||
assertTrue(Files.exists(temporaryDirectory.resolve("metadata.properties")));
|
||||
assertEquals(0, restarted.getDirtyRegionCount());
|
||||
}
|
||||
|
||||
private FileRegionPersistenceService service() {
|
||||
return new FileRegionPersistenceService(
|
||||
temporaryDirectory,
|
||||
"0.1.0",
|
||||
CLOCK);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user