Group commit generation history

- Semantic journal and saved-biome appends write without forcing; a shared flusher forces touched files within 2s outside every store lock
- GenerationHistory.sync forces pending history at world save, engine shutdown, pregen completion and before activation promotion
- A failed deferred force poisons the store the same way an uncertain append did
- Cache the active epoch pack root instead of regex-validating it on every stage open
- Tests pin append-without-force, sync barriers, failure poisoning and the save, shutdown and pregen hooks
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-28 11:41:17 -04:00
1 parent 241260a762
commit a35ecca7bd
24 files changed
+877 -136

No files matched your search

@@ -250,6 +250,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
default void save() {
NativeStructureOwnershipStore.flush(this);
syncGenerationHistory();
getMantle().save();
getWorldManager().onSave();
saveEngineData();
@@ -259,6 +260,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
default void saveNow() {
NativeStructureOwnershipStore.flush(this);
syncGenerationHistory();
getMantle().saveAllNow();
saveEngineData();
}
@@ -267,6 +269,8 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
void saveEngineData();
void syncGenerationHistory();
default String getName() {
return getDimension().getName();
}
@@ -1529,6 +1529,7 @@ public class IrisEngine implements Engine {
private void scheduleWorldSave() {
backgroundTasks.scheduleAdmittedTask(this, () -> NativeStructureOwnershipStore.flush(this));
backgroundTasks.scheduleAdmittedTask(this, this::syncGenerationHistory);
getMantle().save();
getWorldManager().onSave();
saveEngineData();
@@ -1539,6 +1540,20 @@ public class IrisEngine implements Engine {
engineDataStore.saveEngineData();
}
@Override
public void syncGenerationHistory() {
GenerationHistoryRuntimeRouter router = getGenerationHistoryRuntimeRouter().orElse(null);
if (router == null) {
return;
}
try {
router.history().sync();
} catch (IOException failure) {
throw new IllegalStateException("Unable to force Iris generation history to stable storage for "
+ getWorld().name() + ".", failure);
}
}
@Override
public void blockUpdatedMetric() {
bud.incrementAndGet();
@@ -82,6 +82,27 @@ public final class GenerationHistory {
return savedBiomes;
}
public void sync() throws IOException {
IOException failure = null;
try {
semantics.sync();
} catch (IOException semanticFailure) {
failure = semanticFailure;
}
try {
savedBiomes.sync();
} catch (IOException biomeFailure) {
if (failure == null) {
failure = biomeFailure;
} else {
failure.addSuppressed(biomeFailure);
}
}
if (failure != null) {
throw failure;
}
}
public static GenerationHistory create(
Path dimensionRoot,
Path packSource,
@@ -478,6 +499,7 @@ public final class GenerationHistory {
private GenerationActivation promotePendingLocked(
BoundarySignatureCapture signatureCapture, boolean startupRecovery, boolean validateSemantics
) throws IOException {
sync();
WorldChunkInventory inventory = recoverUnstoredClaims(startupRecovery);
validateReferencedState(Optional.empty(), validateSemantics);
Optional<GenerationActivation> pending = store.pendingActivation();
@@ -26,6 +26,7 @@ public final class GenerationHistoryPaths {
private final Path legacyPackRoot;
private final Path legacyMantleRoot;
private final Path regionRoot;
private volatile PackRoot lastPackRoot;
private GenerationHistoryPaths(Path dimensionRoot) {
this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
@@ -71,7 +72,13 @@ public final class GenerationHistoryPaths {
}
public Path packRoot(String epochId) {
return epochRoot(epochId).resolve(LEGACY_PACK_DIRECTORY_NAME);
PackRoot cached = lastPackRoot;
if (cached != null && cached.epochId().equals(epochId)) {
return cached.path();
}
Path resolved = epochRoot(epochId).resolve(LEGACY_PACK_DIRECTORY_NAME);
lastPackRoot = new PackRoot(epochId, resolved);
return resolved;
}
public Path activationsRoot() {
@@ -108,4 +115,7 @@ public final class GenerationHistoryPaths {
public Path regionRoot() {
return regionRoot;
}
private record PackRoot(String epochId, Path path) {
}
}
@@ -500,6 +500,11 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
} catch (Throwable detachFailure) {
failure = detachFailure;
}
try {
history.sync();
} catch (Throwable syncFailure) {
failure = appendFailure(failure, syncFailure);
}
try {
biomes.close();
} catch (Throwable biomeFailure) {
@@ -2,6 +2,7 @@ package art.arcane.iris.world.history;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.storage.DeferredDurability;
import art.arcane.iris.world.storage.Durability;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
@@ -93,6 +94,7 @@ public final class GenerationSemanticIndex {
private final PointerPublisher pointerPublisher;
private final CatalogPublisher catalogPublisher;
private final boolean unpublished;
private final DeferredDurability durability;
private long regionDecodeCount;
private volatile IOException journalFailure;
private long appendingRegionKey;
@@ -118,6 +120,8 @@ public final class GenerationSemanticIndex {
this.pointerPublisher = pointerPublisher;
this.catalogPublisher = catalogPublisher;
this.unpublished = unpublished;
this.durability = new DeferredDurability(
DeferredDurability.DEFAULT_FLUSH_DELAY_MILLIS, failure -> journalFailure = failure);
}
public static GenerationSemanticIndex load(Path dimensionRoot) throws IOException {
@@ -265,7 +269,7 @@ public final class GenerationSemanticIndex {
appendingBase = baseRegion;
publicationLock.unlock();
try {
SemanticJournal.append(directory, regionX, regionZ, updates, unpublished);
SemanticJournal.append(directory, regionX, regionZ, updates, unpublished, durability);
} catch (JournalAppendFailure failure) {
journalFailure = failure;
throw failure;
@@ -289,6 +293,15 @@ public final class GenerationSemanticIndex {
}
}
public void sync() throws IOException {
try {
durability.sync();
} catch (IOException failure) {
journalFailure = failure;
throw failure;
}
}
public int discardUnstoredClaims(WorldChunkInventory stored, Set<Long> activationIds) throws IOException {
Objects.requireNonNull(stored, "stored chunks");
Set<Long> selected = Set.copyOf(activationIds);
@@ -1940,7 +1953,8 @@ public final class GenerationSemanticIndex {
int regionX,
int regionZ,
List<ChunkGenerationSemantics> claims,
boolean unpublished
boolean unpublished,
DeferredDurability durability
) throws IOException {
Path file = directory.resolve(journalFileName(regionX, regionZ));
boolean created = !Files.exists(file, LinkOption.NOFOLLOW_LINKS);
@@ -1974,10 +1988,10 @@ public final class GenerationSemanticIndex {
}
}
if (!unpublished) {
Durability.force(channel);
durability.written(file);
}
if (created) {
RegionShard.forceDirectory(directory);
durability.linked(directory);
}
} catch (IOException failure) {
try {
@@ -1,5 +1,6 @@
package art.arcane.iris.world.history;
import art.arcane.iris.world.storage.DeferredDurability;
import art.arcane.iris.world.storage.Durability;
import java.io.BufferedInputStream;
@@ -52,6 +53,7 @@ public final class SavedBiomeStore {
private final Path dimensionRoot;
private final Path directory;
private final boolean unpublished;
private final DeferredDurability durability;
private final WriteStripe[] regionLocks = new WriteStripe[64];
private final LinkedHashMap<Long, RegionIndex> regions = new LinkedHashMap<>(64, 0.75F, true);
private final LinkedHashMap<Long, Optional<SavedBiomeChunk>> chunks = new LinkedHashMap<>(128, 0.75F, true);
@@ -61,6 +63,8 @@ public final class SavedBiomeStore {
private SavedBiomeStore(Path dimensionRoot, boolean unpublished) throws IOException {
this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").toAbsolutePath().normalize();
this.unpublished = unpublished;
this.durability = new DeferredDurability(
DeferredDurability.DEFAULT_FLUSH_DELAY_MILLIS, failure -> writeFailure = failure);
directory = this.dimensionRoot.resolve("iris/generation/biomes");
requireSafeAncestors();
for (int index = 0; index < regionLocks.length; index++) {
@@ -227,7 +231,7 @@ public final class SavedBiomeStore {
}
}
if (!unpublished) {
Durability.force(output.getChannel());
durability.written(region.path);
}
appendCompleted = true;
} catch (IOException failure) {
@@ -269,6 +273,15 @@ public final class SavedBiomeStore {
}
}
public void sync() throws IOException {
try {
durability.sync();
} catch (IOException failure) {
writeFailure = failure;
throw failure;
}
}
public int discardUnstoredClaims(WorldChunkInventory inventory, Set<Long> selectedActivations) throws IOException {
Objects.requireNonNull(inventory, "inventory");
Set<Long> selected = Set.copyOf(selectedActivations);
@@ -779,6 +779,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
TimeUnit.SECONDS,
() -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot())
);
syncGenerationHistory();
mantleCleanup.close(MANTLE_CLEANUP_DRAIN_SECONDS, TimeUnit.SECONDS);
flushAllRemainingChunks();
@@ -807,6 +808,19 @@ public class AsyncPregenMethod implements PregeneratorMethod {
}
}
private void syncGenerationHistory() {
Engine engine = resolveMetricsEngine();
if (engine == null) {
return;
}
try {
engine.syncGenerationHistory();
} catch (Throwable e) {
IrisLogging.reportError("Async pregen could not force generation history to stable storage for world "
+ world.getName() + ".", e);
}
}
private boolean isCancelled() {
return closing.get() || Thread.currentThread().isInterrupted();
}
@@ -112,6 +112,19 @@ public class MedievalPregenMethod implements PregeneratorMethod {
return cached;
}
private void syncGenerationHistory() {
Engine engine = resolveEngine();
if (engine == null) {
return;
}
try {
engine.syncGenerationHistory();
} catch (Throwable e) {
IrisLogging.reportError("Medieval pregen could not force generation history to stable storage for world "
+ world.getName() + ".", e);
}
}
private void waitForChunks() {
for (CompletableFuture<?> i : futures) {
try {
@@ -179,6 +192,7 @@ public class MedievalPregenMethod implements PregeneratorMethod {
if (prefetchPool != null) {
prefetchPool.shutdownNow();
}
syncGenerationHistory();
unloadAndSaveAllChunks(true);
chunkFlush.close(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} finally {
@@ -0,0 +1,138 @@
package art.arcane.iris.world.storage;
import java.io.File;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
/**
* Group commit for append-only stores: writers record which files and directory entries they
* changed, and one background pass forces them shortly afterwards, outside every store lock.
* {@link #sync()} is the durability barrier for saves, shutdown and pregeneration completion.
*/
public final class DeferredDurability {
public static final long DEFAULT_FLUSH_DELAY_MILLIS = 2_000L;
private static final ScheduledThreadPoolExecutor FLUSHER = createFlusher();
private final Set<Path> files = ConcurrentHashMap.newKeySet();
private final Set<Path> directories = ConcurrentHashMap.newKeySet();
private final ReentrantLock flushLock = new ReentrantLock();
private final AtomicBoolean scheduled = new AtomicBoolean();
private final long flushDelayMillis;
private final Consumer<IOException> backgroundFailure;
public DeferredDurability(long flushDelayMillis, Consumer<IOException> backgroundFailure) {
if (flushDelayMillis < 0L) {
throw new IllegalArgumentException("Flush delay must not be negative.");
}
this.flushDelayMillis = flushDelayMillis;
this.backgroundFailure = Objects.requireNonNull(backgroundFailure, "backgroundFailure");
}
public void written(Path file) {
if (!Durability.enabled()) {
return;
}
files.add(file);
schedule();
}
public void linked(Path directory) {
if (!Durability.enabled() || File.separatorChar == '\\') {
return;
}
directories.add(directory);
schedule();
}
public void sync() throws IOException {
flushLock.lock();
try {
IOException failure = null;
for (Path file : files) {
files.remove(file);
try {
forceFile(file);
} catch (IOException fileFailure) {
failure = append(failure, fileFailure);
}
}
for (Path directory : directories) {
directories.remove(directory);
try {
forceDirectory(directory);
} catch (IOException directoryFailure) {
failure = append(failure, directoryFailure);
}
}
if (failure != null) {
throw failure;
}
} finally {
flushLock.unlock();
}
}
private void schedule() {
if (scheduled.compareAndSet(false, true)) {
FLUSHER.schedule(this::flushInBackground, flushDelayMillis, TimeUnit.MILLISECONDS);
}
}
private void flushInBackground() {
scheduled.set(false);
try {
sync();
} catch (IOException failure) {
backgroundFailure.accept(failure);
} catch (RuntimeException failure) {
backgroundFailure.accept(new IOException("Deferred durability sync failed unexpectedly.", failure));
}
}
private static void forceFile(Path file) throws IOException {
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) {
Durability.force(channel);
} catch (NoSuchFileException replaced) {
// Compaction or rewrite already published this file's contents durably under another name.
}
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
Durability.force(channel);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory cannot be durability-synced: " + directory, failure);
}
}
private static IOException append(IOException failure, IOException additional) {
if (failure == null) {
return additional;
}
failure.addSuppressed(additional);
return failure;
}
private static ScheduledThreadPoolExecutor createFlusher() {
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> {
Thread thread = new Thread(runnable, "Iris Durability Flush");
thread.setDaemon(true);
return thread;
});
executor.setKeepAliveTime(30L, TimeUnit.SECONDS);
executor.allowCoreThreadTimeOut(true);
executor.setRemoveOnCancelPolicy(true);
return executor;
}
}