From 62c610ce76a7e38a512e2215c8b7a1ccf1ceb5e0 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 15 Oct 2026 10:08:51 -0400 Subject: [PATCH] Restored saved dimensions off-thread - Saved Iris dimensions are prepared in parallel during world load (server-about-to-start on NeoForge and Forge, server-starting on Fabric), before the server watchdog runs - Server start only creates the restored levels, builds their engines in parallel, and publishes them, so its wait no longer grows one world at a time - Each restored dimension opens its generation history once instead of twice and resolves its active pack once instead of three times - An epoch snapshot whose content matches its validated source pack reuses that validation instead of running the full pack validator for every world - Level creation reads the configured pack and structure biome keys from data warmed during preparation - Immutable generation packs feed the prepared hydrology cache identity from the data's memoized fingerprint instead of hashing the pack twice per engine build - Restore logging reports each dimension's preparation and engine time and how long the server thread waited --- .../iris/modded/IrisModdedChunkGenerator.java | 10 +- .../iris/modded/ModdedDimensionManager.java | 269 ++++++++++------- .../iris/modded/ModdedDimensionMetadata.java | 15 + .../iris/modded/ModdedDimensionRestore.java | 233 +++++++++++++++ .../iris/modded/ModdedEngineBootstrap.java | 2 + .../ModdedGenerationHistoryStorage.java | 88 ++++-- .../art/arcane/iris/modded/ModdedStartup.java | 124 ++++++-- .../iris/modded/ModdedWorldEngines.java | 200 ++++++++++--- .../modded/ModdedDimensionRestoreTest.java | 276 ++++++++++++++++++ .../ModdedPackValidationPublicationTest.java | 51 +++- .../ModdedStartupReinjectFailureTest.java | 43 ++- .../PreparedHydrologyCacheIdentity.java | 9 +- .../PreparedHydrologyCacheIdentityTest.java | 21 ++ 13 files changed, 1118 insertions(+), 223 deletions(-) create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRestoreTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 165ee583a..045832dd2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -743,14 +743,8 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat PackValidationRegistry.requireLoadable(packRoot); packDirectory = packRoot.toFile(); } - IrisData data = IrisData.get(packDirectory); - IrisDimension dimension = data.getDimensionLoader().load(activeDimensionKey); - if (dimension == null) { - throw new IllegalStateException("Iris dimension '" + activeDimensionKey - + "' missing from pack " + packDirectory.getAbsolutePath()); - } - ModdedDimensionMetadata.ConfiguredPack resolved = new ModdedDimensionMetadata.ConfiguredPack( - data, dimension, ModdedDimensionMetadata.dimensionMetadata(dimension)); + ModdedDimensionMetadata.ConfiguredPack resolved = + ModdedDimensionMetadata.ConfiguredPack.load(packDirectory, activeDimensionKey); configuredPack = resolved; heightMetadata = resolved.metadata(); return resolved; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index b2d0a475f..d4303bea0 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -107,104 +107,109 @@ public final class ModdedDimensionManager { ) { return create( server, - dimensionId, - pack, - packDimensionKey, - seed, - ModdedGenerationMode.TRANSIENT_STUDIO + new DimensionRequest(dimensionId, pack, packDimensionKey, seed), + () -> transientStudioPack(pack, packDimensionKey) ); } - static Handle restorePersistent( + static RestoringLevel beginRestore( NativeModdedServer server, - String dimensionId, - String pack, - String packDimensionKey, - long seed - ) { - return create( - server, - dimensionId, - pack, - packDimensionKey, - seed, - ModdedGenerationMode.PERSISTENT_RESTORE - ); - } - - private static Handle create( - NativeModdedServer server, - String dimensionId, - String pack, - String packDimensionKey, - long seed, - ModdedGenerationMode generationMode + ModdedDimensionRegistryStore.PersistentDimension dimension, + ModdedGenerationHistoryStorage.ActivePack active ) { NativeDimensionRuntime serverAccess = requireAccess(); + DimensionRequest requested = new DimensionRequest(dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed()); synchronized (LOCK) { - String key = dimensionId; - RuntimePack runtimePack = switch (generationMode) { - case TRANSIENT_STUDIO -> transientStudioPack(pack, packDimensionKey); - case PERSISTENT_CREATE -> persistentRuntimePack( - ModdedGenerationHistoryStorage.createOrStage( - server, - key, - pack, - packDimensionKey, - seed - ), - generationMode - ); - case PERSISTENT_RESTORE -> persistentRuntimePack( - ModdedGenerationHistoryStorage.restoreOrAdopt( - server, - key, - pack, - packDimensionKey, - seed - ), - generationMode - ); - }; - Handle existing = HANDLES.get(dimensionId); - if (existing != null && serverAccess.hasLevel(server, key)) { - Handle refreshed = new Handle(dimensionId, pack, packDimensionKey, seed, existing.level(), existing.generator()); - HANDLES.put(dimensionId, refreshed); - return refreshed; + Handle present = presentHandle(server, serverAccess, requested); + if (present != null) { + return new RestoringLevel(present, true); } - if (serverAccess.hasLevel(server, key)) { - NativeWorld present = level(server, dimensionId); - IrisModdedChunkGenerator generator = NativeWorldGenerators.find(present, IrisModdedChunkGenerator.class); - if (generator == null) { - throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded"); - } - ModdedIrisLog.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId); - Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, present, generator); - HANDLES.put(dimensionId, handle); - return handle; - } - try { - Handle handle = inject( - server, - serverAccess, - dimensionId, - key, - pack, - packDimensionKey, - seed, - runtimePack - ); - HANDLES.put(dimensionId, handle); - ModdedIrisLog.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed); - return handle; + RuntimePack runtimePack = persistentRuntimePack(active, ModdedGenerationMode.PERSISTENT_RESTORE); + return new RestoringLevel(createLevel(server, serverAccess, requested, runtimePack), false); } catch (Throwable e) { - ModdedIrisLog.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed, e); - throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e); + throw injectionFailure(requested, e); } } } + static Handle completeRestore(NativeModdedServer server, RestoringLevel restoring, Engine engine) { + if (restoring.present()) { + return restoring.handle(); + } + NativeDimensionRuntime serverAccess = requireAccess(); + synchronized (LOCK) { + try { + return register(publishLevel(server, serverAccess, restoring.handle(), engine)); + } catch (Throwable e) { + throw injectionFailure(DimensionRequest.of(restoring.handle()), e); + } + } + } + + static void abandonRestore(NativeModdedServer server, RestoringLevel restoring, Throwable failure) { + if (restoring.present()) { + return; + } + NativeDimensionRuntime serverAccess = requireAccess(); + Handle handle = restoring.handle(); + synchronized (LOCK) { + rollbackInjection(server, serverAccess, handle.dimensionId(), handle.level(), handle.generator(), false, failure); + } + } + + private static Handle create(NativeModdedServer server, DimensionRequest requested, RuntimePackSource source) { + NativeDimensionRuntime serverAccess = requireAccess(); + synchronized (LOCK) { + RuntimePack runtimePack = source.open(); + Handle present = presentHandle(server, serverAccess, requested); + if (present != null) { + return present; + } + try { + return register(publishLevel(server, serverAccess, + createLevel(server, serverAccess, requested, runtimePack), null)); + } catch (Throwable e) { + throw injectionFailure(requested, e); + } + } + } + + private static Handle presentHandle(NativeModdedServer server, NativeDimensionRuntime serverAccess, DimensionRequest requested) { + String dimensionId = requested.dimensionId(); + Handle existing = HANDLES.get(dimensionId); + if (existing != null && serverAccess.hasLevel(server, dimensionId)) { + Handle refreshed = requested.bind(existing.level(), existing.generator()); + HANDLES.put(dimensionId, refreshed); + return refreshed; + } + if (!serverAccess.hasLevel(server, dimensionId)) { + return null; + } + NativeWorld present = level(server, dimensionId); + IrisModdedChunkGenerator generator = NativeWorldGenerators.find(present, IrisModdedChunkGenerator.class); + if (generator == null) { + throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded"); + } + ModdedIrisLog.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId); + Handle handle = requested.bind(present, generator); + HANDLES.put(dimensionId, handle); + return handle; + } + + private static Handle register(Handle handle) { + HANDLES.put(handle.dimensionId(), handle); + ModdedIrisLog.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})", + handle.dimensionId(), handle.pack(), handle.packDimensionKey(), handle.seed()); + return handle; + } + + private static IllegalStateException injectionFailure(DimensionRequest requested, Throwable failure) { + ModdedIrisLog.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})", + requested.dimensionId(), requested.pack(), requested.packDimensionKey(), requested.seed(), failure); + return new IllegalStateException("Iris runtime dimension injection failed for " + requested.dimensionId(), failure); + } + public static Handle createPersistent(NativeModdedServer server, String dimensionId, String pack, String packDimensionKey, long seed) { synchronized (LOCK) { ModdedDimensionRegistryStore.PersistentDimension previous = @@ -222,11 +227,17 @@ public final class ModdedDimensionManager { try { return create( server, - dimensionId, - pack, - packDimensionKey, - seed, - ModdedGenerationMode.PERSISTENT_CREATE + new DimensionRequest(dimensionId, pack, packDimensionKey, seed), + () -> persistentRuntimePack( + ModdedGenerationHistoryStorage.createOrStage( + server, + dimensionId, + pack, + packDimensionKey, + seed + ), + ModdedGenerationMode.PERSISTENT_CREATE + ) ); } catch (Throwable e) { try { @@ -392,16 +403,16 @@ public final class ModdedDimensionManager { } } - private static Handle inject( + private static Handle createLevel( NativeModdedServer server, NativeDimensionRuntime serverAccess, - String dimensionId, - String key, - String pack, - String packDimensionKey, - long seed, + DimensionRequest requested, RuntimePack runtimePack ) { + String dimensionId = requested.dimensionId(); + String pack = requested.pack(); + String packDimensionKey = requested.packDimensionKey(); + long seed = requested.seed(); String typeRef = runtimePack.dimensionTypeKey(); ModdedForcedDatapack.requireRegisteredDimensionType(typeRef, serverAccess.hasDimensionType(server, typeRef) ? Optional.of(Boolean.TRUE) : Optional.empty(), @@ -414,35 +425,54 @@ public final class ModdedDimensionManager { runtimePack.generationMode()); return generator; })); - NativeWorld level = created.world(); - IrisModdedChunkGenerator generator = created.generator(); + try { + serverAccess.initialize(server, created.world()); + return requested.bind(created.world(), created.generator()); + } catch (Throwable error) { + rollbackInjection(server, serverAccess, dimensionId, created.world(), created.generator(), false, error); + throw rethrow(dimensionId, error); + } + } + private static Handle publishLevel( + NativeModdedServer server, + NativeDimensionRuntime serverAccess, + Handle created, + Engine prepared + ) { + NativeWorld level = created.level(); + IrisModdedChunkGenerator generator = created.generator(); boolean loadEventStarted = false; try { - serverAccess.initialize(server, level); + if (prepared != null) { + ModdedWorldEngines.installPrepared(level, prepared); + } generator.bindLevel(level); - Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, level, generator); NativeWorld previous = serverAccess.publish(server, level); if (previous != null) { - throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + throw new IllegalStateException("Iris cannot inject dimension '" + created.dimensionId() + "': the level was registered concurrently"); } serverAccess.addWorldBorderListener(server, level); loadEventStarted = true; ModdedEngineBootstrap.loader().fireDynamicLevelLoad(level); - return handle; + return created; } catch (Throwable error) { - rollbackInjection(server, serverAccess, key, level, generator, loadEventStarted, error); - if (error instanceof RuntimeException runtimeException) { - throw runtimeException; - } - if (error instanceof Error fatalError) { - throw fatalError; - } - throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, error); + rollbackInjection(server, serverAccess, created.dimensionId(), level, generator, loadEventStarted, error); + throw rethrow(created.dimensionId(), error); } } + private static RuntimeException rethrow(String dimensionId, Throwable error) { + if (error instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (error instanceof Error fatalError) { + throw fatalError; + } + return new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, error); + } + private static void rollbackInjection(NativeModdedServer server, NativeDimensionRuntime serverAccess, String key, NativeWorld level, IrisModdedChunkGenerator generator, boolean loadEventStarted, @@ -544,9 +574,30 @@ public final class ModdedDimensionManager { } } + @FunctionalInterface + private interface RuntimePackSource { + RuntimePack open(); + } + + private record DimensionRequest(String dimensionId, String pack, String packDimensionKey, long seed) { + private static DimensionRequest of(Handle handle) { + return new DimensionRequest(handle.dimensionId(), handle.pack(), handle.packDimensionKey(), handle.seed()); + } + + private Handle bind(NativeWorld level, IrisModdedChunkGenerator generator) { + return new Handle(dimensionId, pack, packDimensionKey, seed, level, generator); + } + } + public record Handle(String dimensionId, String pack, String packDimensionKey, long seed, NativeWorld level, IrisModdedChunkGenerator generator) { } + record RestoringLevel(Handle handle, boolean present) { + RestoringLevel { + handle = Objects.requireNonNull(handle, "handle"); + } + } + public record UpdateResult( boolean pending, boolean restartRequired, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java index 3fd14abab..009b2451d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java @@ -28,6 +28,7 @@ import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeResourceKeys; +import java.io.File; import java.util.LinkedHashSet; import java.util.Locale; import java.util.Set; @@ -126,6 +127,20 @@ final class ModdedDimensionMetadata { } } + static void warmConfiguredPack(File packDirectory, String dimensionKey) { + ConfiguredPack configured = ConfiguredPack.load(packDirectory, dimensionKey); + collectConfiguredBiomeKeys(configured.dimension(), configured.data()); + } + record ConfiguredPack(IrisData data, IrisDimension dimension, DimensionMetadata metadata) { + static ConfiguredPack load(File packDirectory, String dimensionKey) { + IrisData data = IrisData.get(packDirectory); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IllegalStateException("Iris dimension '" + dimensionKey + + "' missing from pack " + packDirectory.getAbsolutePath()); + } + return new ConfiguredPack(data, dimension, dimensionMetadata(dimension)); + } } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java new file mode 100644 index 000000000..ec1d9c7e2 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java @@ -0,0 +1,233 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +final class ModdedDimensionRestore implements AutoCloseable { + private static final int MINIMUM_WORKERS = 2; + + private final List> preparations; + private final Steps steps; + private final ExecutorService workers; + private final AtomicBoolean finished = new AtomicBoolean(); + + private ModdedDimensionRestore(List> preparations, Steps steps, ExecutorService workers) { + this.preparations = preparations; + this.steps = steps; + this.workers = workers; + } + + static ModdedDimensionRestore start( + List dimensions, + Steps steps + ) { + Steps requiredSteps = Objects.requireNonNull(steps, "steps"); + ExecutorService workers = Executors.newFixedThreadPool(workerCount(dimensions.size()), + Thread.ofPlatform().name("Iris Dimension Restore ", 1L).daemon(true).factory()); + List> preparations = new ArrayList<>(dimensions.size()); + for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) { + preparations.add(new Preparation<>(dimension, CompletableFuture.supplyAsync( + () -> timed(() -> requiredSteps.prepare(dimension)), workers))); + } + return new ModdedDimensionRestore<>(List.copyOf(preparations), requiredSteps, workers); + } + + void awaitPreparations() { + List>> futures = new ArrayList<>(preparations.size()); + for (Preparation

preparation : preparations) { + futures.add(preparation.future()); + } + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .handle((Void ignored, Throwable failure) -> null) + .join(); + } + + List finish() { + if (!finished.compareAndSet(false, true)) { + throw new IllegalStateException("Iris dimension restore already finished"); + } + try { + List> building = new ArrayList<>(preparations.size()); + Result[] results = new Result[preparations.size()]; + for (int index = 0; index < preparations.size(); index++) { + Preparation

preparation = preparations.get(index); + Timed

prepared; + try { + prepared = join(preparation.future()); + } catch (Throwable failure) { + results[index] = new Result(preparation.dimension(), failure, 0L, 0L); + continue; + } + L level; + try { + level = steps.begin(preparation.dimension(), prepared.value()); + } catch (Throwable failure) { + discard(prepared.value(), failure); + results[index] = new Result(preparation.dimension(), failure, prepared.millis(), 0L); + continue; + } + CompletableFuture> engine = CompletableFuture.supplyAsync( + () -> timed(() -> steps.build(level, prepared.value())), workers); + building.add(new Building<>(index, preparation.dimension(), prepared.millis(), level, engine)); + } + for (Building pending : building) { + results[pending.index()] = complete(pending); + } + return List.of(results); + } finally { + workers.shutdown(); + } + } + + @Override + public void close() { + if (finished.compareAndSet(false, true)) { + for (Preparation

preparation : preparations) { + preparation.future().whenComplete((Timed

prepared, Throwable failure) -> { + if (prepared != null) { + discard(prepared.value(), null); + } + }); + } + } + workers.shutdown(); + } + + private Result complete(Building pending) { + Timed engine; + try { + engine = join(pending.engine()); + } catch (Throwable failure) { + abandon(pending.level(), failure); + return new Result(pending.dimension(), failure, pending.preparedMillis(), 0L); + } + try { + steps.complete(pending.level(), engine.value()); + return new Result(pending.dimension(), null, pending.preparedMillis(), engine.millis()); + } catch (Throwable failure) { + return new Result(pending.dimension(), failure, pending.preparedMillis(), engine.millis()); + } + } + + private void abandon(L level, Throwable failure) { + try { + steps.abandon(level, failure); + } catch (Throwable cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + + private void discard(P prepared, Throwable failure) { + try { + steps.discard(prepared); + } catch (Throwable cleanupFailure) { + if (failure != null) { + failure.addSuppressed(cleanupFailure); + return; + } + ModdedIrisLog.error("Iris could not release an unused dimension restore preparation", cleanupFailure); + } + } + + private static int workerCount(int dimensions) { + int processors = Runtime.getRuntime().availableProcessors(); + return Math.max(1, Math.min(dimensions, Math.max(MINIMUM_WORKERS, processors / 2))); + } + + private static Timed timed(Step step) { + long startedAt = System.nanoTime(); + try { + T value = step.run(); + return new Timed<>(value, (System.nanoTime() - startedAt) / 1_000_000L); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Exception failure) { + throw new CompletionException(failure); + } + } + + private static T join(CompletableFuture future) throws Throwable { + try { + return future.get(); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof CompletionException wrapped && wrapped.getCause() != null) { + throw wrapped.getCause(); + } + throw cause == null ? failure : cause; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while restoring Iris dimensions", interrupted); + } + } + + interface Steps { + P prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) throws Exception; + + L begin(ModdedDimensionRegistryStore.PersistentDimension dimension, P prepared) throws Exception; + + E build(L level, P prepared) throws Exception; + + void complete(L level, E engine) throws Exception; + + void abandon(L level, Throwable failure); + + void discard(P prepared); + } + + record Result( + ModdedDimensionRegistryStore.PersistentDimension dimension, + Throwable failure, + long preparedMillis, + long builtMillis + ) { + } + + @FunctionalInterface + private interface Step { + T run() throws Exception; + } + + private record Timed(T value, long millis) { + } + + private record Preparation

( + ModdedDimensionRegistryStore.PersistentDimension dimension, + CompletableFuture> future + ) { + } + + private record Building( + int index, + ModdedDimensionRegistryStore.PersistentDimension dimension, + long preparedMillis, + L level, + CompletableFuture> engine + ) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 5818308ff..44dfb3681 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -145,6 +145,7 @@ public final class ModdedEngineBootstrap { } IrisModdedCommands.openDownloadAdmission(); ModdedStartup.prepareForStartup(); + ModdedStartup.prepareDimensionRestore(server); IrisModdedChunkGenerator.startGenPool(); bindWorldGenerators(server); services().enableAll(); @@ -164,6 +165,7 @@ public final class ModdedEngineBootstrap { finishPreviousServer("server start"); captureInitialSpawn(server); ModdedStartup.prepareForStartup(); + ModdedStartup.prepareDimensionRestore(server); } public static void serverStarted(NativeModdedServer server) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java index c4cd571e5..4ce134029 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java @@ -9,6 +9,7 @@ import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.PackFingerprints; import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationResult; +import art.arcane.iris.world.history.GenerationAdmission; import art.arcane.iris.world.history.GenerationEpoch; import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationHistory; @@ -53,7 +54,7 @@ final class ModdedGenerationHistoryStorage { ModdedGenerationHistoryStorage::requireRegistryDefinitions, IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() ); - return resolveActive(history); + return resolveActive(history, source); } catch (IOException failure) { throw historyFailure(levelKey, failure); } @@ -68,25 +69,57 @@ final class ModdedGenerationHistoryStorage { ) { Path dimensionRoot = dimensionRoot(server, levelKey); try { - GenerationHistory history = restoreOrAdopt( - dimensionRoot, - worldSeed, - () -> { - Path source = selectedPackSource(selectedPack, selectedDimensionKey); - return new AdoptionSource(source, captureCandidate( - source, - selectedDimensionKey, - adoptionAliasPolicy(dimensionRoot) - )); - }, - ModdedGenerationHistoryStorage::requireRegistryDefinitions - ); - return resolveActive(history); + GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed); + return resolveActive(history, installedPackSource(selectedPack)); } catch (IOException failure) { throw historyFailure(levelKey, failure); } } + static PreparedHistory prepareRestore( + NativeModdedServer server, + String levelKey, + String selectedPack, + String selectedDimensionKey, + long worldSeed + ) { + Path dimensionRoot = dimensionRoot(server, levelKey); + try { + GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed); + GenerationAdmission.RuntimeLease lease = history.retainRuntime(); + try { + history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); + return new PreparedHistory(resolveActive(history, installedPackSource(selectedPack)), lease); + } catch (IOException | RuntimeException | Error failure) { + lease.close(); + throw failure; + } + } catch (IOException failure) { + throw historyFailure(levelKey, failure); + } + } + + private static GenerationHistory openForRestore( + Path dimensionRoot, + String selectedPack, + String selectedDimensionKey, + long worldSeed + ) throws IOException { + return restoreOrAdopt( + dimensionRoot, + worldSeed, + () -> { + Path source = selectedPackSource(selectedPack, selectedDimensionKey); + return new AdoptionSource(source, captureCandidate( + source, + selectedDimensionKey, + adoptionAliasPolicy(dimensionRoot) + )); + }, + ModdedGenerationHistoryStorage::requireRegistryDefinitions + ); + } + static ActivePack openOrAdopt( NativeWorld level, String selectedPack, @@ -184,10 +217,10 @@ final class ModdedGenerationHistoryStorage { } } - static ActivePack resolveActive(GenerationHistory history) throws IOException { + static ActivePack resolveActive(GenerationHistory history, Path validatedSource) throws IOException { GenerationEpoch epoch = history.activeEpoch(); Path packRoot = normalize(history.activePackRoot()); - validatePack(packRoot); + validatePack(packRoot, validatedSource); validateDimension(packRoot, epoch); return new ActivePack( packRoot, @@ -197,9 +230,12 @@ final class ModdedGenerationHistoryStorage { ); } - static PackValidationResult validatePack(Path packRoot) { - PackValidationResult validation = PackValidationRegistry.getMatching( - packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile())); + static PackValidationResult validatePack(Path packRoot, Path validatedSource) { + String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packRoot.toFile()); + PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint); + if (validation == null && validatedSource != null) { + validation = PackValidationRegistry.publishMatchingCopy(validatedSource, packRoot, contentFingerprint); + } if (validation == null) { validation = ModdedStartup.validatePack(packRoot.toFile()); } @@ -286,6 +322,11 @@ final class ModdedGenerationHistoryStorage { return normalize(ModdedWorldEngines.resolvePack(pack, dimensionKey).toPath()); } + private static Path installedPackSource(String pack) { + File folder = ModdedWorldEngines.packFolder(pack); + return folder.isDirectory() ? normalize(folder.toPath()) : null; + } + private static GenerationRegistryContract requireRegistryDefinitions( Collection contracts ) @@ -346,6 +387,13 @@ final class ModdedGenerationHistoryStorage { } } + record PreparedHistory(ActivePack active, GenerationAdmission.RuntimeLease lease) { + PreparedHistory { + active = Objects.requireNonNull(active, "active"); + lease = Objects.requireNonNull(lease, "lease"); + } + } + record Candidate( String packFingerprint, GenerationEpoch.DimensionContract dimensionContract, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 0cd0ef925..a8d0bf13b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -27,6 +27,7 @@ import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; +import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.modded.command.ModdedPackCommands; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.world.safeguard.GenerationRefusalNotice; @@ -39,19 +40,29 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; public final class ModdedStartup { private static final int COMPAT_BOOT_KEY_CAP = 3; private static final AtomicBoolean PREPARED = new AtomicBoolean(false); private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final AtomicBoolean RESTORE_BEGUN = new AtomicBoolean(false); + private static final AtomicReference> RESTORE = + new AtomicReference<>(); private static final List DIMENSION_FAILURES = new CopyOnWriteArrayList<>(); private ModdedStartup() { } public static void reset() { + ModdedDimensionRestore restore = + RESTORE.getAndSet(null); + if (restore != null) { + restore.close(); + } PREPARED.set(false); STARTED.set(false); + RESTORE_BEGUN.set(false); DIMENSION_FAILURES.clear(); } @@ -62,6 +73,24 @@ public final class ModdedStartup { validateAllPacks(); } + public static void prepareDimensionRestore(NativeModdedServer server) { + if (server == null || !RESTORE_BEGUN.compareAndSet(false, true)) { + return; + } + List dimensions = + ModdedDimensionRegistryStore.loadForStartup(server); + if (dimensions.isEmpty()) { + return; + } + long startedAt = System.currentTimeMillis(); + ModdedDimensionRestore restore = + ModdedDimensionRestore.start(dimensions, new PersistentRestoreSteps(server)); + RESTORE.set(restore); + restore.awaitPreparations(); + ModdedIrisLog.info("Iris prepared {} persistent dimension(s) during world load in {}ms", + dimensions.size(), System.currentTimeMillis() - startedAt); + } + /** * Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler: * ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this @@ -219,38 +248,46 @@ public final class ModdedStartup { } static void reinjectPersistentDimensions(NativeModdedServer server) { - List dimensions = - ModdedDimensionRegistryStore.loadForStartup(server); - if (dimensions.isEmpty()) { + prepareDimensionRestore(server); + ModdedDimensionRestore restore = + RESTORE.getAndSet(null); + if (restore == null) { return; } + finishDimensionRestore(restore); + } + + static void finishDimensionRestore(ModdedDimensionRestore restore) { + long startedAt = System.currentTimeMillis(); + List results; + try (restore) { + results = restore.finish(); + } + long waited = System.currentTimeMillis() - startedAt; int injected = 0; int index = 0; - long startedAt = System.currentTimeMillis(); - for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) { + OutOfMemoryError outOfMemory = null; + for (ModdedDimensionRestore.Result result : results) { index++; - long dimensionStartedAt = System.currentTimeMillis(); - try { - ModdedDimensionManager.restorePersistent( - server, - dimension.id(), - dimension.pack(), - dimension.dimension(), - dimension.seed() - ); + ModdedDimensionRegistryStore.PersistentDimension dimension = result.dimension(); + Throwable failure = result.failure(); + if (failure == null) { injected++; - ModdedIrisLog.info("Iris re-injected {}/{} '{}' (pack={} dim={}) in {}ms", - index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(), - System.currentTimeMillis() - dimensionStartedAt); - } catch (Throwable e) { - reportDimensionFailure(dimension, e); - if (e instanceof OutOfMemoryError outOfMemory) { - throw outOfMemory; - } + ModdedIrisLog.info("Iris re-injected {}/{} '{}' (pack={} dim={}): prepared {}ms, engine {}ms", + index, results.size(), dimension.id(), dimension.pack(), dimension.dimension(), + result.preparedMillis(), result.builtMillis()); + continue; + } + reportDimensionFailure(dimension, failure); + if (failure instanceof OutOfMemoryError fatal && outOfMemory == null) { + outOfMemory = fatal; } } - ModdedIrisLog.info("Iris re-injected {}/{} persistent dimension(s) at startup in {}ms", - injected, dimensions.size(), System.currentTimeMillis() - startedAt); + ModdedIrisLog.info("Iris re-injected {}/{} persistent dimension(s) at startup; server thread waited {}ms", + injected, results.size(), waited); + if (outOfMemory != null) { + throw outOfMemory; + } } private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) { @@ -304,4 +341,43 @@ public final class ModdedStartup { } } + private record PersistentRestoreSteps(NativeModdedServer server) + implements ModdedDimensionRestore.Steps { + @Override + public ModdedWorldEngines.PreparedPack prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) { + return ModdedWorldEngines.preparePersistent(server, dimension); + } + + @Override + public ModdedDimensionManager.RestoringLevel begin( + ModdedDimensionRegistryStore.PersistentDimension dimension, + ModdedWorldEngines.PreparedPack prepared + ) { + return ModdedDimensionManager.beginRestore(server, dimension, prepared.active()); + } + + @Override + public Engine build(ModdedDimensionManager.RestoringLevel level, ModdedWorldEngines.PreparedPack prepared) { + if (level.present()) { + prepared.close(); + return null; + } + return ModdedWorldEngines.createPrepared(level.handle().level(), level.handle().seed(), prepared); + } + + @Override + public void complete(ModdedDimensionManager.RestoringLevel level, Engine engine) { + ModdedDimensionManager.completeRestore(server, level, engine); + } + + @Override + public void abandon(ModdedDimensionManager.RestoringLevel level, Throwable failure) { + ModdedDimensionManager.abandonRestore(server, level, failure); + } + + @Override + public void discard(ModdedWorldEngines.PreparedPack prepared) { + prepared.close(); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 9bf710cf1..4b2a7e375 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -31,6 +31,7 @@ import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.EngineTarget; import art.arcane.iris.world.history.GenerationActivation; +import art.arcane.iris.world.history.GenerationAdmission; import art.arcane.iris.world.history.GenerationEpoch; import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationHistory; @@ -128,6 +129,43 @@ public final class ModdedWorldEngines { }); } + static PreparedPack preparePersistent( + NativeModdedServer server, + ModdedDimensionRegistryStore.PersistentDimension dimension + ) { + ModdedEngineBootstrap.bind(); + ModdedGenerationHistoryStorage.PreparedHistory prepared = ModdedGenerationHistoryStorage.prepareRestore( + server, + dimension.id(), + dimension.pack(), + dimension.dimension(), + dimension.seed() + ); + try { + ModdedDimensionMetadata.warmConfiguredPack(prepared.active().packRoot().toFile(), prepared.active().dimensionKey()); + return new PreparedPack(prepared.active(), prepared.lease(), loadPack(prepared.active())); + } catch (RuntimeException | Error failure) { + prepared.lease().close(); + throw failure; + } + } + + static Engine createPrepared(NativeWorld level, long seedOverride, PreparedPack prepared) { + ModdedEngineBootstrap.bind(); + long seed = seedOverride == Long.MIN_VALUE ? level.seed() : seedOverride; + try (prepared) { + return attachHistoryEngine(level, seed, prepared.active(), prepared.take()); + } catch (IOException failure) { + throw new IllegalStateException("Iris generation history could not initialize runtime routing for '" + + level.name() + "'.", failure); + } + } + + static void installPrepared(NativeWorld level, Engine engine) { + applyWorldBoundary(level, engine); + installReplacement(level, engine); + } + static void closeUnregistered(Engine engine) { close(engine); ENGINES.entrySet().removeIf((Map.Entry entry) -> entry.getValue() == engine); @@ -142,9 +180,13 @@ public final class ModdedWorldEngines { ModdedGenerationMode generationMode ) { Engine engine = create(level, pack, dimensionKey, seedOverride, generationMode); + applyWorldBoundary(level, engine); + return engine; + } + + private static void applyWorldBoundary(NativeWorld level, Engine engine) { try { engine.getPlatformHooks().applyWorldBoundary(engine); - return engine; } catch (Throwable failure) { try { close(engine); @@ -197,57 +239,63 @@ public final class ModdedWorldEngines { GenerationHistory history = opened.history(); try (GenerationAdmission.RuntimeLease startupAdmission = history.retainRuntime()) { history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - opened = ModdedGenerationHistoryStorage.resolveActive(history); - long openedActivationId = history.activeActivation().activationId(); - IrisEngine candidate = buildEngine(level, seed, opened); - boolean ready = false; - try { - GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory( + ModdedGenerationHistoryStorage.ActivePack active = ModdedGenerationHistoryStorage.resolveActive(history, null); + return attachHistoryEngine(level, seed, active, loadPack(active)); + } + } + + private static IrisEngine attachHistoryEngine( + NativeWorld level, + long seed, + ModdedGenerationHistoryStorage.ActivePack active, + LoadedPack loaded + ) throws IOException { + GenerationHistory history = active.history(); + long openedActivationId = history.activeActivation().activationId(); + IrisEngine candidate = buildEngine(level, seed, active, loaded); + boolean ready = false; + try { + GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory( + history, + IrisBoundarySignatureSampler.INSTANCE, + IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() + ); + router.preloadActiveRuntimes(); + if (history.activeActivation().activationId() != openedActivationId) { + close(candidate); + ModdedGenerationHistoryStorage.ActivePack promoted = + ModdedGenerationHistoryStorage.resolveActive(history, null); + candidate = buildEngine(level, seed, promoted, loadPack(promoted)); + router = GenerationHistoryRuntimeRouter.attach( + candidate, history, - IrisBoundarySignatureSampler.INSTANCE, - IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() + IrisBoundarySignatureSampler.INSTANCE ); router.preloadActiveRuntimes(); - if (history.activeActivation().activationId() != openedActivationId) { - close(candidate); - ModdedGenerationHistoryStorage.ActivePack promoted = - ModdedGenerationHistoryStorage.resolveActive(history); - candidate = buildEngine(level, seed, promoted); - router = GenerationHistoryRuntimeRouter.attach( - candidate, - history, - IrisBoundarySignatureSampler.INSTANCE - ); - router.preloadActiveRuntimes(); - } - requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE); - ready = true; - ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}", - level.name(), - candidate.getData().getDataFolder().getAbsolutePath(), - candidate.getDimension().getLoadKey(), - seed, - candidate.getDimension().getMinHeight(), - candidate.getDimension().getMaxHeight(), - history.activeActivation().activationId()); - return candidate; - } finally { - if (!ready) { - close(candidate); - } + } + requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE); + ready = true; + ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}", + level.name(), + candidate.getData().getDataFolder().getAbsolutePath(), + candidate.getDimension().getLoadKey(), + seed, + candidate.getDimension().getMinHeight(), + candidate.getDimension().getMaxHeight(), + history.activeActivation().activationId()); + return candidate; + } finally { + if (!ready) { + close(candidate); } } } - private static IrisEngine buildEngine( - NativeWorld level, - long seed, - ModdedGenerationHistoryStorage.ActivePack active - ) throws IOException { + private static LoadedPack loadPack(ModdedGenerationHistoryStorage.ActivePack active) { File packDir = active.packRoot().toFile(); PackValidationRegistry.requireLoadable(active.packRoot()); IrisData data = IrisData.openRuntime(packDir); - boolean runtimeOwnsData = false; + boolean loaded = false; try { IrisDimension dimension = data.getDimensionLoader().load(active.dimensionKey()); if (dimension == null) { @@ -256,7 +304,26 @@ public final class ModdedWorldEngines { throw new IllegalStateException("Iris dimension '" + active.dimensionKey() + "' missing from immutable pack " + packDir.getAbsolutePath()); } + data.generationPackFingerprint(); + loaded = true; + return new LoadedPack(data, dimension); + } finally { + if (!loaded) { + data.close(); + } + } + } + private static IrisEngine buildEngine( + NativeWorld level, + long seed, + ModdedGenerationHistoryStorage.ActivePack active, + LoadedPack loaded + ) throws IOException { + IrisData data = loaded.data(); + IrisDimension dimension = loaded.dimension(); + boolean runtimeOwnsData = false; + try { validateDimensionContract(active.dimensionContract(), dimension, level); File worldFolder = NativeModdedServer.forWorld(level).dimensionFolder(level.name()).toFile(); IrisWorld world = buildWorld(level, seed, dimension, worldFolder); @@ -428,6 +495,55 @@ public final class ModdedWorldEngines { } } + private record LoadedPack(IrisData data, IrisDimension dimension) { + private LoadedPack { + data = Objects.requireNonNull(data, "data"); + dimension = Objects.requireNonNull(dimension, "dimension"); + } + } + + static final class PreparedPack implements AutoCloseable { + private final ModdedGenerationHistoryStorage.ActivePack active; + private final GenerationAdmission.RuntimeLease lease; + private LoadedPack loaded; + + private PreparedPack( + ModdedGenerationHistoryStorage.ActivePack active, + GenerationAdmission.RuntimeLease lease, + LoadedPack loaded + ) { + this.active = Objects.requireNonNull(active, "active"); + this.lease = Objects.requireNonNull(lease, "lease"); + this.loaded = Objects.requireNonNull(loaded, "loaded"); + } + + ModdedGenerationHistoryStorage.ActivePack active() { + return active; + } + + private synchronized LoadedPack take() { + LoadedPack taken = loaded; + if (taken == null) { + throw new IllegalStateException("Iris prepared pack for " + active.packRoot() + " was already used"); + } + loaded = null; + return taken; + } + + @Override + public synchronized void close() { + LoadedPack unused = loaded; + loaded = null; + try { + if (unused != null) { + unused.data().close(); + } + } finally { + lease.close(); + } + } + } + public static void shutdown() { Throwable failure = null; for (Map.Entry entry : new ArrayList<>(ENGINES.entrySet())) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRestoreTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRestoreTest.java new file mode 100644 index 000000000..55755744f --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRestoreTest.java @@ -0,0 +1,276 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class ModdedDimensionRestoreTest { + private static final long STEP_TIMEOUT_SECONDS = 10L; + + @Test + public void everyDimensionPreparesConcurrentlyOffTheCallingThread() { + List dimensions = dimensions(4); + RecordingSteps steps = new RecordingSteps(dimensions.size()); + steps.prepareBarrier = new CountDownLatch(dimensions.size()); + + List results; + try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + results = restore.finish(); + } + + assertEquals(dimensions.size(), results.size()); + for (ModdedDimensionRestore.Result result : results) { + assertNull(result.failure()); + } + assertFalse(steps.prepareThreads.contains(Thread.currentThread())); + } + + @Test + public void engineBuildsRunConcurrentlyWhileLevelMutationStaysOnTheCallingThread() { + List dimensions = dimensions(3); + RecordingSteps steps = new RecordingSteps(dimensions.size()); + steps.buildBarrier = new CountDownLatch(dimensions.size()); + + try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + restore.finish(); + } + + assertFalse(steps.buildThreads.contains(Thread.currentThread())); + assertEquals(Set.of(Thread.currentThread()), steps.serverThreads); + assertEquals(List.of("level-0:engine-0", "level-1:engine-1", "level-2:engine-2"), steps.completed); + } + + @Test + public void aFailedPreparationIsReportedWhileTheOtherDimensionsStillRestore() { + List dimensions = dimensions(3); + RecordingSteps steps = new RecordingSteps(dimensions.size()); + IllegalStateException failure = new IllegalStateException("generation history is unusable"); + steps.prepareFailure = failure; + steps.failingIndex = 1; + + List results; + try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + results = restore.finish(); + } + + assertNull(results.get(0).failure()); + assertSame(failure, results.get(1).failure()); + assertNull(results.get(2).failure()); + assertSame(dimensions.get(1), results.get(1).dimension()); + assertEquals(List.of("level-0:engine-0", "level-2:engine-2"), steps.completed); + assertTrue(steps.begun.stream().noneMatch((String level) -> level.equals("level-1"))); + } + + @Test + public void aFailedEngineBuildAbandonsItsLevelWithoutCompletingIt() { + List dimensions = dimensions(2); + RecordingSteps steps = new RecordingSteps(dimensions.size()); + IllegalStateException failure = new IllegalStateException("engine refused"); + steps.buildFailure = failure; + steps.failingIndex = 0; + + List results; + try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + results = restore.finish(); + } + + assertSame(failure, results.get(0).failure()); + assertNull(results.get(1).failure()); + assertEquals(List.of("level-0"), steps.abandoned); + assertEquals(List.of("level-1:engine-1"), steps.completed); + } + + @Test + public void aLevelThatCannotBeCreatedDiscardsItsPreparation() { + List dimensions = dimensions(2); + RecordingSteps steps = new RecordingSteps(dimensions.size()); + IllegalStateException failure = new IllegalStateException("dimension type missing"); + steps.beginFailure = failure; + steps.failingIndex = 1; + + List results; + try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + results = restore.finish(); + } + + assertSame(failure, results.get(1).failure()); + assertEquals(List.of("prepared-1"), steps.discarded); + assertEquals(List.of("level-0:engine-0"), steps.completed); + } + + @Test + public void awaitingPreparationsReturnsOnlyOnceEveryDimensionIsPreparedAndCreatesNoLevel() { + List dimensions = dimensions(3); + RecordingSteps steps = new RecordingSteps(dimensions.size()); + CountDownLatch release = new CountDownLatch(1); + steps.prepareGate = release; + Thread.ofPlatform().daemon(true).start(() -> { + try { + Thread.sleep(200L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + release.countDown(); + }); + + try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + restore.awaitPreparations(); + + assertEquals(dimensions.size(), steps.prepared.get()); + assertTrue(steps.begun.isEmpty()); + restore.finish(); + } + + assertEquals(List.of("level-0:engine-0", "level-1:engine-1", "level-2:engine-2"), steps.completed); + assertEquals(0L, release.getCount()); + } + + @Test + public void closingAnUnfinishedRestoreDiscardsEveryPreparation() throws InterruptedException { + List dimensions = dimensions(3); + RecordingSteps steps = new RecordingSteps(dimensions.size()); + + ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps); + restore.close(); + + assertTrue(steps.discardedAll.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertTrue(steps.begun.isEmpty()); + assertEquals(Set.of("prepared-0", "prepared-1", "prepared-2"), Set.copyOf(steps.discarded)); + } + + private static List dimensions(int count) { + List dimensions = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + dimensions.add(new ModdedDimensionRegistryStore.PersistentDimension( + "iris:world" + index, "overworld", "overworld", 1000L + index)); + } + return dimensions; + } + + private static int index(String value) { + return Integer.parseInt(value.substring(value.lastIndexOf('-') + 1)); + } + + private static int index(ModdedDimensionRegistryStore.PersistentDimension dimension) { + return Integer.parseInt(dimension.id().substring("iris:world".length())); + } + + private static void await(CountDownLatch barrier) { + if (barrier == null) { + return; + } + barrier.countDown(); + try { + if (!barrier.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Restore steps did not run concurrently"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for concurrent restore steps", interrupted); + } + } + + private static void awaitGate(CountDownLatch gate) { + if (gate == null) { + return; + } + try { + if (!gate.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Preparation gate never opened"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the preparation gate", interrupted); + } + } + + private static final class RecordingSteps implements ModdedDimensionRestore.Steps { + private final Set prepareThreads = ConcurrentHashMap.newKeySet(); + private final Set buildThreads = ConcurrentHashMap.newKeySet(); + private final Set serverThreads = ConcurrentHashMap.newKeySet(); + private final List begun = Collections.synchronizedList(new ArrayList<>()); + private final List completed = Collections.synchronizedList(new ArrayList<>()); + private final List abandoned = Collections.synchronizedList(new ArrayList<>()); + private final List discarded = Collections.synchronizedList(new ArrayList<>()); + private final CountDownLatch discardedAll; + private final AtomicInteger prepared = new AtomicInteger(); + private CountDownLatch prepareGate; + private CountDownLatch prepareBarrier; + private CountDownLatch buildBarrier; + private RuntimeException prepareFailure; + private RuntimeException beginFailure; + private RuntimeException buildFailure; + private int failingIndex = -1; + + private RecordingSteps(int dimensions) { + this.discardedAll = new CountDownLatch(dimensions); + } + + @Override + public String prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) { + prepareThreads.add(Thread.currentThread()); + await(prepareBarrier); + awaitGate(prepareGate); + int index = index(dimension); + if (prepareFailure != null && index == failingIndex) { + throw prepareFailure; + } + prepared.incrementAndGet(); + return "prepared-" + index; + } + + @Override + public String begin(ModdedDimensionRegistryStore.PersistentDimension dimension, String prepared) { + serverThreads.add(Thread.currentThread()); + int index = index(dimension); + if (beginFailure != null && index == failingIndex) { + throw beginFailure; + } + String level = "level-" + index; + begun.add(level); + return level; + } + + @Override + public String build(String level, String prepared) { + buildThreads.add(Thread.currentThread()); + await(buildBarrier); + int index = index(level); + if (buildFailure != null && index == failingIndex) { + throw buildFailure; + } + return "engine-" + index; + } + + @Override + public void complete(String level, String engine) { + serverThreads.add(Thread.currentThread()); + completed.add(level + ":" + engine); + } + + @Override + public void abandon(String level, Throwable failure) { + serverThreads.add(Thread.currentThread()); + abandoned.add(level); + } + + @Override + public void discard(String prepared) { + discarded.add(prepared); + discardedAll.countDown(); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java index 5d1b120ca..03d146a34 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java @@ -24,6 +24,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.fail; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; public class ModdedPackValidationPublicationTest { @@ -41,8 +42,8 @@ public class ModdedPackValidationPublicationTest { PackValidationResult result = loadable(root); try (MockedStatic validator = mockStatic(PackValidator.class)) { validator.when(() -> PackValidator.validate(root)).thenReturn(result); - assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath())); - assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath())); + assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath(), null)); + assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath(), null)); validator.verify(() -> PackValidator.validate(root), times(1)); } } @@ -56,13 +57,13 @@ public class ModdedPackValidationPublicationTest { PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(), 1L); try (MockedStatic validator = mockStatic(PackValidator.class)) { validator.when(() -> PackValidator.validate(root)).thenReturn(loadable(root), broken); - ModdedGenerationHistoryStorage.validatePack(root.toPath()); + ModdedGenerationHistoryStorage.validatePack(root.toPath(), null); Files.writeString(content.toPath(), "wrong"); Files.setLastModifiedTime(content.toPath(), timestamp); assertThrows(BrokenPackException.class, - () -> ModdedGenerationHistoryStorage.validatePack(root.toPath())); + () -> ModdedGenerationHistoryStorage.validatePack(root.toPath(), null)); assertThrows(BrokenPackException.class, - () -> ModdedGenerationHistoryStorage.validatePack(root.toPath())); + () -> ModdedGenerationHistoryStorage.validatePack(root.toPath(), null)); validator.verify(() -> PackValidator.validate(root), times(2)); } } @@ -77,9 +78,9 @@ public class ModdedPackValidationPublicationTest { MockedStatic cache = mockStatic(PackValidationCache.class)) { cache.when(PackValidationCache::contextFingerprint).thenAnswer(ignored -> context.get()); validator.when(() -> PackValidator.validate(root)).thenReturn(first, second); - assertSame(first, ModdedGenerationHistoryStorage.validatePack(root.toPath())); + assertSame(first, ModdedGenerationHistoryStorage.validatePack(root.toPath(), null)); context.set("second"); - assertSame(second, ModdedGenerationHistoryStorage.validatePack(root.toPath())); + assertSame(second, ModdedGenerationHistoryStorage.validatePack(root.toPath(), null)); validator.verify(() -> PackValidator.validate(root), times(2)); } } @@ -189,6 +190,42 @@ public class ModdedPackValidationPublicationTest { } } + @Test + public void snapshotReusesTheValidationOfAnIdenticalValidatedSource() throws Exception { + File source = temporaryFolder.newFolder("overworld"); + Files.writeString(new File(source, "dimension.json").toPath(), "terrain"); + File snapshot = temporaryFolder.newFolder("epoch", "pack"); + Files.writeString(new File(snapshot, "dimension.json").toPath(), "terrain"); + PackValidationResult sourceResult = loadable(source); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(source)).thenReturn(sourceResult); + ModdedStartup.validatePack(source); + + assertSame(sourceResult, ModdedGenerationHistoryStorage.validatePack(snapshot.toPath(), source.toPath())); + + validator.verify(() -> PackValidator.validate(snapshot), never()); + assertSame(sourceResult, PackValidationRegistry.requireLoadable(snapshot.toPath())); + } + } + + @Test + public void snapshotThatDiffersFromItsSourceIsValidatedOnItsOwn() throws Exception { + File source = temporaryFolder.newFolder("overworld"); + Files.writeString(new File(source, "dimension.json").toPath(), "terrain"); + File snapshot = temporaryFolder.newFolder("epoch", "pack"); + Files.writeString(new File(snapshot, "dimension.json").toPath(), "older terrain"); + PackValidationResult snapshotResult = loadable(snapshot); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(source)).thenReturn(loadable(source)); + validator.when(() -> PackValidator.validate(snapshot)).thenReturn(snapshotResult); + ModdedStartup.validatePack(source); + + assertSame(snapshotResult, ModdedGenerationHistoryStorage.validatePack(snapshot.toPath(), source.toPath())); + + validator.verify(() -> PackValidator.validate(snapshot), times(1)); + } + } + private static PackValidationResult loadable(File root) { return new PackValidationResult(root.getName(), List.of(), List.of(), System.currentTimeMillis()); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java index d81288a87..6810a25c3 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java @@ -1,7 +1,6 @@ package art.arcane.iris.modded; import art.arcane.iris.pack.PackValidationRegistry; -import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import org.junit.After; import org.junit.Test; @@ -32,19 +31,12 @@ public class ModdedStartupReinjectFailureTest { @Test public void aDimensionThatFailsToReinjectIsBanneredAndReportedToOperatorsOnJoin() { - NativeModdedServer server = mock(NativeModdedServer.class); ModdedDimensionRegistryStore.PersistentDimension moon = new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L); IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'"); - try (MockedStatic store = mockStatic(ModdedDimensionRegistryStore.class); - MockedStatic manager = mockStatic(ModdedDimensionManager.class); - MockedStatic log = mockStatic(ModdedIrisLog.class)) { - store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(moon)); - manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L)) - .thenThrow(failure); - - ModdedStartup.reinjectPersistentDimensions(server); + try (MockedStatic log = mockStatic(ModdedIrisLog.class)) { + ModdedStartup.finishDimensionRestore(ModdedDimensionRestore.start(List.of(moon), new FailingSteps(failure))); log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon") && line.startsWith("Iris refused"))), times(1)); @@ -73,4 +65,35 @@ public class ModdedStartupReinjectFailureTest { ModdedStartup.warnStartupFailuresTo(laterOperator); verify(laterOperator, never()).sendMessage(any()); } + + private record FailingSteps(RuntimeException failure) implements ModdedDimensionRestore.Steps { + @Override + public String prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) { + throw failure; + } + + @Override + public String begin(ModdedDimensionRegistryStore.PersistentDimension dimension, String prepared) { + throw new AssertionError("A failed preparation must not create a level"); + } + + @Override + public String build(String level, String prepared) { + throw new AssertionError("A failed preparation must not build an engine"); + } + + @Override + public void complete(String level, String engine) { + throw new AssertionError("A failed preparation must not publish a level"); + } + + @Override + public void abandon(String level, Throwable failure) { + throw new AssertionError("A failed preparation has no level to abandon"); + } + + @Override + public void discard(String prepared) { + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java b/core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java index eafcaa673..000b2d1c4 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java @@ -30,9 +30,12 @@ record PreparedHydrologyCacheIdentity(String fingerprint) { if (context.isBlank()) { return null; } - String content = GenerationPackFingerprint.compute( - target.getData().getDataFolder().toPath(), GenerationPackFingerprint.CURRENT_VERSION); - String dependencies = target.getData().hasGenerationRegistryContract() ? "closed" + boolean immutable = target.getData().hasGenerationRegistryContract(); + String content = immutable + ? target.getData().generationPackFingerprint() + : GenerationPackFingerprint.compute( + target.getData().getDataFolder().toPath(), GenerationPackFingerprint.CURRENT_VERSION); + String dependencies = immutable ? "closed" : dependencyFingerprint(IrisPlatforms.get().packsFolderNoCreate()); if (dependencies.isBlank()) { return null; diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java index 89155f02e..4ba84853f 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.world.history.GenerationKernelRegistry; +import art.arcane.iris.world.history.GenerationPackFingerprint; import art.arcane.iris.world.history.TransitionGenerationPlan; import org.junit.Test; import org.junit.Rule; @@ -21,6 +22,7 @@ import static org.mockito.Mockito.mockStatic; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -121,6 +123,8 @@ public class PreparedHydrologyCacheIdentityTest { IrisData data = mock(IrisData.class); when(data.getDataFolder()).thenReturn(path.toFile()); when(data.hasGenerationRegistryContract()).thenReturn(true); + when(data.generationPackFingerprint()).thenAnswer(invocation -> + GenerationPackFingerprint.compute(path, GenerationPackFingerprint.CURRENT_VERSION)); IrisWorld world = mock(IrisWorld.class); when(world.minHeight()).thenReturn(-64); when(world.maxHeight()).thenReturn(320); @@ -133,6 +137,23 @@ public class PreparedHydrologyCacheIdentityTest { return target; } + @Test + public void immutableGenerationPackUsesTheDataFingerprintWithoutRereadingThePack() throws Exception { + Path missing = folder.getRoot().toPath().resolve("immutable-epoch-pack"); + EngineTarget target = target(missing); + IrisData data = target.getData(); + doReturn("verified-epoch").when(data).generationPackFingerprint(); + GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation"); + try (MockedStatic validation = mockStatic(PackValidationCache.class)) { + validation.when(PackValidationCache::contextFingerprint).thenReturn("platform"); + + PreparedHydrologyCacheIdentity captured = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false); + + assertEquals(PreparedHydrologyCacheIdentity.fromInputs( + "verified-epoch", "closed", "platform", kernel, null, -64, 320, 0), captured); + } + } + @Test public void identicalInputsShareRegardlessOfWorldLocation() { GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation");