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);
|
regionCache.put(region);
|
||||||
|
if (region.isDirty()) {
|
||||||
|
regionStorage.markDirty(region);
|
||||||
|
}
|
||||||
return region;
|
return region;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +93,15 @@ public final class RegionManager implements com.livingworld.core.simulation.Regi
|
|||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"a different region instance is already cached for " + region.getCoordinate());
|
"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) {
|
public void unloadRegion(RegionCoordinate coordinate) {
|
||||||
|
|||||||
@@ -53,4 +53,19 @@ public class RegionStorage {
|
|||||||
}
|
}
|
||||||
persistenceService.saveRegion(region);
|
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