Fixed spawn prefetch startup

- Spawn hydrology checks existing chunks asynchronously without generating them
- Prefetch admission rejects closed or replaced generation runtimes
- Studio entry retains tracked hydrology preparation
- Admitted planning failures retain their stacktraces during shutdown
- Regression coverage checks startup admission and delayed completion
This commit is contained in:
Brian Neumann-Fopiano
2026-09-06 05:08:13 -04:00
parent 04471678b2
commit 03b0f6e801
3 changed files with 320 additions and 35 deletions
@@ -37,6 +37,7 @@ import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
@@ -52,6 +53,7 @@ import art.arcane.iris.engine.history.GenerationAdmission;
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.engine.history.IrisBoundarySignatureSampler;
import art.arcane.iris.engine.history.TransitionGenerationPlan;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisDimensionContractException;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
@@ -101,6 +103,7 @@ import java.util.Locale;
import java.util.Objects;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -258,41 +261,73 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
}
/** Starts nearest-first hydrology planning around a new world's initial spawn. */
private void prefetchSpawnHydrology(Engine engine, World world) {
private CompletableFuture<Void> prefetchSpawnHydrology(Engine engine, World world) {
if (usesFlatStudioTerrain()) {
return;
return CompletableFuture.completedFuture(null);
}
if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) {
return;
IrisComplex complex = engine.getComplex();
IrisHydrologyRuntime hydrology = complex == null ? null : complex.getHydrologyRuntime();
if (hydrology == null) {
return CompletableFuture.completedFuture(null);
}
Location spawn = getInitialSpawnLocation(world);
int spawnX = spawn.getBlockX();
int spawnZ = spawn.getBlockZ();
if (world.isChunkGenerated(spawnX >> 4, spawnZ >> 4)) {
return;
}
if (studio && engine.getDimension().getStudioMode() == StudioMode.NORMAL
&& engine instanceof IrisEngine irisEngine) {
irisEngine.startStudioEntryHydrology(spawnX, spawnZ);
return;
}
int reach = Math.max(16, hydrologyTileSize() / 2);
engine.getComplex().getHydrologyRuntime().prefetchArea(
spawnX - reach,
spawnZ - reach,
spawnX + reach - 1,
spawnZ + reach - 1,
spawnX,
spawnZ
);
return requestChunkAsync(world, spawn.getBlockX() >> 4, spawn.getBlockZ() >> 4, false)
.exceptionally(failure -> {
if (closing || this.engine != engine || engine.isClosing() || engine.isClosed()) {
return null;
}
throw new CompletionException(failure);
})
.thenAcceptAsync(chunk -> {
if (chunk == null) {
startSpawnHydrology(engine, hydrology, spawn);
}
})
.whenComplete((ignored, failure) -> {
if (failure != null) {
IrisLogging.reportError("Failed to prefetch spawn hydrology for " + world.getName(), failure);
}
});
}
private int hydrologyTileSize() {
Engine activeEngine = engine;
if (activeEngine == null || activeEngine.getComplex() == null || activeEngine.getComplex().getHydrologyRuntime() == null) {
return 0;
private void startSpawnHydrology(Engine expectedEngine, IrisHydrologyRuntime hydrology, Location spawn) {
if (closing) {
return;
}
GenerationStagePermit permit;
try {
permit = acquireGenerationStage("spawn_hydrology_prefetch");
} catch (IllegalStateException failure) {
if (closing && failure.getCause() == null) {
return;
}
throw failure;
}
try (permit) {
if (engine != expectedEngine || expectedEngine.isClosing() || expectedEngine.isClosed() || usesFlatStudioTerrain()) {
return;
}
IrisComplex complex = expectedEngine.getComplex();
if (complex == null || complex.getHydrologyRuntime() != hydrology) {
return;
}
int spawnX = spawn.getBlockX();
int spawnZ = spawn.getBlockZ();
if (studio && expectedEngine.getDimension().getStudioMode() == StudioMode.NORMAL
&& expectedEngine instanceof IrisEngine irisEngine) {
irisEngine.startStudioEntryHydrology(spawnX, spawnZ);
return;
}
int reach = Math.max(16, hydrology.settings().routing().tileSize() / 2);
hydrology.prefetchArea(
spawnX - reach,
spawnZ - reach,
spawnX + reach - 1,
spawnZ + reach - 1,
spawnX,
spawnZ
);
}
return activeEngine.getComplex().getHydrologyRuntime().settings().routing().tileSize();
}
private void updateSpawnLocation(World world) {
@@ -3,7 +3,9 @@ package art.arcane.iris.engine.platform;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import org.bukkit.Chunk;
import org.bukkit.World;
import art.arcane.iris.engine.object.StudioMode;
import art.arcane.iris.engine.object.IrisDimension;
@@ -14,11 +16,14 @@ import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -35,23 +40,31 @@ public class BukkitChunkGeneratorInitializationModeTest {
Field studio = BukkitChunkGenerator.class.getDeclaredField("studio");
studio.setAccessible(true);
studio.setBoolean(generator, true);
Field loadLock = BukkitChunkGenerator.class.getDeclaredField("loadLock");
loadLock.setAccessible(true);
loadLock.set(generator, new BukkitChunkGenerator.GenerationStageGate(2, generator::isClosing));
doReturn(false).when(generator).usesFlatStudioTerrain();
World world = mock(World.class);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getChunkAtAsync(0, 0, false)).thenReturn(CompletableFuture.completedFuture(null));
IrisEngine engine = mock(IrisEngine.class);
generator.setEngine(engine);
IrisDimension dimension = mock(IrisDimension.class);
when(engine.getDimension()).thenReturn(dimension);
when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL);
IrisComplex complex = mock(IrisComplex.class);
IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class);
HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class, RETURNS_DEEP_STUBS);
when(settings.routing().tileSize()).thenReturn(512);
when(hydrology.settings()).thenReturn(settings);
when(engine.getComplex()).thenReturn(complex);
when(complex.getHydrologyRuntime()).thenReturn(hydrology);
Method prefetch = BukkitChunkGenerator.class.getDeclaredMethod(
"prefetchSpawnHydrology", Engine.class, World.class);
prefetch.setAccessible(true);
prefetch.invoke(generator, engine, world);
((CompletableFuture<?>) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS);
verify(engine).startStudioEntryHydrology(0, 0);
verifyNoInteractions(hydrology);
@@ -60,21 +73,21 @@ public class BukkitChunkGeneratorInitializationModeTest {
assertEquals(0.5D, generator.getInitialSpawnLocation(world).getZ(), 0D);
when(dimension.getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_1x1);
prefetch.invoke(generator, engine, world);
((CompletableFuture<?>) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS);
verify(engine, times(1)).startStudioEntryHydrology(0, 0);
verify(hydrology).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL);
IrisEngine authoring = mock(IrisEngine.class);
doReturn(true).when(generator).usesFlatStudioTerrain();
prefetch.invoke(generator, authoring, world);
((CompletableFuture<?>) prefetch.invoke(generator, authoring, world)).get(5L, TimeUnit.SECONDS);
verifyNoInteractions(authoring);
IrisEngine generated = mock(IrisEngine.class);
when(generated.getComplex()).thenReturn(complex);
doReturn(false).when(generator).usesFlatStudioTerrain();
when(world.isChunkGenerated(0, 0)).thenReturn(true);
prefetch.invoke(generator, generated, world);
when(world.getChunkAtAsync(0, 0, false)).thenReturn(CompletableFuture.completedFuture(mock(Chunk.class)));
((CompletableFuture<?>) prefetch.invoke(generator, generated, world)).get(5L, TimeUnit.SECONDS);
verify(generated, never()).startStudioEntryHydrology(anyInt(), anyInt());
}
@@ -99,8 +112,8 @@ public class BukkitChunkGeneratorInitializationModeTest {
public void authoringStartupDoesNotPrefetchTerrainHydrology() throws IOException {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java"));
int start = source.indexOf("private void prefetchSpawnHydrology(");
int end = source.indexOf("private int hydrologyTileSize()", start);
int start = source.indexOf("private CompletableFuture<Void> prefetchSpawnHydrology(");
int end = source.indexOf("private void updateSpawnLocation(", start);
String prefetch = source.substring(start, end);
assertTrue(prefetch.indexOf("if (usesFlatStudioTerrain())") >= 0);
@@ -0,0 +1,237 @@
package art.arcane.iris.engine.platform;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.StudioMode;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.junit.Test;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class BukkitChunkGeneratorSpawnHydrologyTest {
@Test
public void startupWaitsAsynchronouslyWithoutHoldingGenerationAdmission() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
assertFalse(prefetch.isDone());
assertEquals(2, fixture.gate.availablePermits());
verifyNoInteractions(fixture.hydrology);
verify(fixture.world).getChunkAtAsync(0, 0, false);
verify(fixture.world, never()).isChunkGenerated(anyInt(), anyInt());
verify(fixture.world, never()).getChunkAtAsync(anyInt(), anyInt(), eq(true));
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verify(fixture.hydrology).prefetchArea(-256, -256, 255, 255, 0, 0);
assertEquals(2, fixture.gate.availablePermits());
}
@Test
public void existingSpawnDoesNotPrefetchHydrology() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
fixture.lookup.complete(mock(Chunk.class));
prefetch.get(5L, TimeUnit.SECONDS);
verifyNoInteractions(fixture.hydrology);
}
@Test
public void lateLookupDoesNotEnterClosingGenerator() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
setField(fixture.generator, "closing", true);
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verifyNoInteractions(fixture.hydrology);
assertEquals(2, fixture.gate.availablePermits());
}
@Test
public void closeRejectsCompletionAlreadyWaitingForAdmission() throws Exception {
Fixture fixture = new Fixture();
CountDownLatch acquiring = new CountDownLatch(1);
doAnswer(invocation -> {
acquiring.countDown();
return invocation.callRealMethod();
}).when(fixture.generator).acquireGenerationStage(anyString());
CompletableFuture<Void> prefetch = fixture.prefetch();
fixture.gate.acquireExclusive();
try {
fixture.lookup.complete(null);
assertTrue(acquiring.await(5L, TimeUnit.SECONDS));
setField(fixture.generator, "closing", true);
} finally {
fixture.gate.releaseExclusive();
}
prefetch.get(5L, TimeUnit.SECONDS);
verifyNoInteractions(fixture.hydrology);
assertEquals(2, fixture.gate.availablePermits());
}
@Test
public void lateLookupDoesNotEnterReplacementEngine() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
fixture.generator.setEngine(mock(Engine.class));
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verifyNoInteractions(fixture.hydrology);
assertEquals(2, fixture.gate.availablePermits());
}
@Test
public void lateLookupDoesNotEnterSealedEngine() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
when(fixture.engine.isClosing()).thenReturn(true);
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verifyNoInteractions(fixture.hydrology);
}
@Test
public void lateLookupDoesNotEnterReplacementHydrology() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
IrisHydrologyRuntime replacement = mock(IrisHydrologyRuntime.class);
when(fixture.complex.getHydrologyRuntime()).thenReturn(replacement);
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verifyNoInteractions(fixture.hydrology, replacement);
}
@Test
public void normalStudioUsesTrackedEntryPreparation() throws Exception {
Fixture fixture = new Fixture();
setField(fixture.generator, "studio", true);
CompletableFuture<Void> prefetch = fixture.prefetch();
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verify(fixture.engine).startStudioEntryHydrology(0, 0);
verifyNoInteractions(fixture.hydrology);
}
@Test
public void failedLookupDoesNotStartHydrology() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
IllegalStateException failure = new IllegalStateException("Native chunk lookup failed");
fixture.lookup.completeExceptionally(failure);
ExecutionException result = assertThrows(ExecutionException.class,
() -> prefetch.get(5L, TimeUnit.SECONDS));
assertSame(failure, result.getCause());
verifyNoInteractions(fixture.hydrology);
assertEquals(2, fixture.gate.availablePermits());
}
@Test
public void lateLookupFailureDoesNotFailClosedGenerator() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<Void> prefetch = fixture.prefetch();
setField(fixture.generator, "closing", true);
fixture.lookup.completeExceptionally(new IllegalStateException("World unloaded"));
prefetch.get(5L, TimeUnit.SECONDS);
verifyNoInteractions(fixture.hydrology);
}
@Test
public void admittedPlanningFailureSurvivesConcurrentClose() throws Exception {
Fixture fixture = new Fixture();
IllegalStateException failure = new IllegalStateException("Planner scheduling failed");
doAnswer(invocation -> {
setField(fixture.generator, "closing", true);
throw failure;
}).when(fixture.hydrology).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
CompletableFuture<Void> prefetch = fixture.prefetch();
fixture.lookup.complete(null);
ExecutionException result = assertThrows(ExecutionException.class,
() -> prefetch.get(5L, TimeUnit.SECONDS));
assertSame(failure, result.getCause());
assertEquals(2, fixture.gate.availablePermits());
}
private static void setField(BukkitChunkGenerator generator, String name, Object value)
throws ReflectiveOperationException {
Field field = BukkitChunkGenerator.class.getDeclaredField(name);
field.setAccessible(true);
field.set(generator, value);
}
private static final class Fixture {
private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
private final BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(2, generator::isClosing);
private final IrisEngine engine = mock(IrisEngine.class);
private final IrisComplex complex = mock(IrisComplex.class);
private final IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class);
private final World world = mock(World.class);
private final CompletableFuture<Chunk> lookup = new CompletableFuture<>();
private Fixture() throws ReflectiveOperationException {
setField(generator, "loadLock", gate);
generator.setEngine(engine);
doReturn(false).when(generator).usesFlatStudioTerrain();
when(engine.getComplex()).thenReturn(complex);
when(complex.getHydrologyRuntime()).thenReturn(hydrology);
HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class, RETURNS_DEEP_STUBS);
when(settings.routing().tileSize()).thenReturn(512);
when(hydrology.settings()).thenReturn(settings);
IrisDimension dimension = mock(IrisDimension.class);
when(engine.getDimension()).thenReturn(dimension);
when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getChunkAtAsync(0, 0, false)).thenReturn(lookup);
when(world.isChunkGenerated(anyInt(), anyInt()))
.thenThrow(new AssertionError("World initialization must not query chunk status synchronously"));
}
@SuppressWarnings("unchecked")
private CompletableFuture<Void> prefetch() throws ReflectiveOperationException {
Method prefetch = BukkitChunkGenerator.class.getDeclaredMethod(
"prefetchSpawnHydrology", Engine.class, World.class);
prefetch.setAccessible(true);
return (CompletableFuture<Void>) prefetch.invoke(generator, engine, world);
}
}
}