Stabilized hydrology pregeneration

- Preserve exact river owner plans across cache eviction and persistence.
- Retry required hydrology plans without generating riverless terrain.
- Bound object plans and pregeneration residency by available heap.
- Release completed native chunks and avoid empty reclamation flushes.
- Cover persistence, admission, cancellation, and ownership regressions.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-28 02:13:17 -04:00
1 parent 9eb05fd987
commit 21f8440299
40 files changed
+2218 -620

No files matched your search

@@ -12,6 +12,16 @@ import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
public class IrisSettingsDefaultsTest {
@Test
public void regionBudgetLeavesHeapForHydrologyAndServerChunks() {
IrisSettings.IrisSettingsPregen settings = new IrisSettings.IrisSettingsPregen();
assertEquals(21, settings.effectiveResidentTectonicPlates(384, 4L << 30));
assertEquals(42, settings.effectiveResidentTectonicPlates(384, 8L << 30));
assertEquals(16, settings.effectiveResidentTectonicPlates(768, 4L << 30));
settings.setMaxResidentTectonicPlates(16);
assertEquals(16, settings.effectiveResidentTectonicPlates(384, 16L << 30));
}
@Test
public void concurrencyIsNotSerializedIntoIrisJson() {
String json = new Gson().toJson(new IrisSettings());
@@ -23,6 +23,7 @@ import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
@@ -107,6 +108,41 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
assertEquals(lazy, parallel);
}
@Test
public void repeatedDependencyRequestsKeepTheirContextOwnersAfterCacheEviction() {
HydrologyPlanner planner = planner();
List<HydrologyTileKey> keys = List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(-1, 0));
CrossTileResolutionContext context = new CrossTileResolutionContext(new HydrologyTileKey(-1, -1), 64L, 4096);
Map<HydrologyTileKey, CrossTileResolvedOwner> original = new LinkedHashMap<>();
Map<HydrologyTileKey, HydrologyForkJoin.Task<CrossTileResolvedOwner>> prepared = new LinkedHashMap<>();
for (HydrologyTileKey key : keys) {
CrossTileResolvedOwner owner = planner.resolveIndependentOwner(key);
original.put(key, owner);
context.remember(key, owner);
prepared.put(key, new HydrologyForkJoin.Task<>(() -> {
throw new AssertionError("A remembered owner must not be resolved again");
}));
}
planner.clearOwnerDrafts();
IrisSettings previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
try {
for (boolean bound : List.of(false, true)) {
IrisPlatforms.unbind();
if (bound) {
IrisPlatforms.bind(mock(IrisPlatform.class));
}
List<CrossTileResolvedOwner> owners = planner.resolveLowerRankOwners(keys, context, prepared);
for (int index = 0; index < keys.size(); index++) {
assertSame(original.get(keys.get(index)), owners.get(index));
}
}
} finally {
IrisPlatforms.unbind();
IrisSettings.settings = previousSettings;
}
}
@Test
public void colorRankBoundsTheCompleteOwnerDependencyGeometry() {
HydrologyPlanner planner = planner();
@@ -113,6 +113,50 @@ public class HydrologyPlannerTest {
assertTileContentsEqual(EARLY_OWNER_BASELINE, dependencyPlanner.plan(EARLY_OWNER_TILE));
}
@Test
public void restoredTilePreservesOwnerOrderingAndRejectedCoursesAfterEviction() {
HydrologyPlanner planner = new HydrologyPlanner(77L, EARLY_OWNER_SETTINGS, EARLY_OWNER_TERRAIN);
HydrologyTile tile = planner.plan(EARLY_OWNER_TILE);
CrossTileResolvedOwner original = tile.resolvedOwner();
assertNotNull(original);
assertNull(original.draft().footprintCompiler());
assertFalse(original.observedRejections().isEmpty());
CrossTileResolutionContext context = new CrossTileResolutionContext(EARLY_OWNER_TILE, 64L, 4096);
context.remember(EARLY_OWNER_TILE, original);
planner.clearOwnerDrafts();
planner.reuseResolvedTile(tile);
CrossTileResolvedOwner restored = planner.resolveIndependentOwner(EARLY_OWNER_TILE);
context.remember(EARLY_OWNER_TILE, restored);
assertEquals(original, restored);
assertTileContentsEqual(tile, planner.plan(EARLY_OWNER_TILE));
}
@Test
public void restoringPreparedTileKeepsExistingCanonicalOwner() {
HydrologyPlanner planner = new HydrologyPlanner(77L, EARLY_OWNER_SETTINGS, EARLY_OWNER_TERRAIN);
HydrologyTile tile = planner.plan(EARLY_OWNER_TILE);
CrossTileResolvedOwner existing = planner.resolveIndependentOwner(EARLY_OWNER_TILE);
planner.reuseResolvedTile(tile);
assertSame(existing, planner.resolveIndependentOwner(EARLY_OWNER_TILE));
}
@Test
public void finalTileWithoutOwnerMetadataCannotInventOwnerDraft() {
HydrologyPlanner planner = new HydrologyPlanner(77L, EARLY_OWNER_SETTINGS, EARLY_OWNER_TERRAIN);
HydrologyTile tile = new HydrologyTile(EARLY_OWNER_TILE, 77L, EARLY_OWNER_SETTINGS.fingerprint(),
EARLY_OWNER_SETTINGS.routing().tileSize(), List.of(), List.of(), List.of(), List.of(),
Set.of(), List.of(), List.of(), RiverFootprint.empty());
planner.reuseResolvedTile(tile);
assertNull(planner.resolvedOwners.getIfPresent(EARLY_OWNER_TILE));
assertTileContentsEqual(EARLY_OWNER_BASELINE, planner.plan(EARLY_OWNER_TILE));
}
@Test
public void collidingRoutingContextKeysCompileIndependently() throws Exception {
CountDownLatch entered = new CountDownLatch(1);
@@ -99,7 +99,6 @@ public class HydrologyPlanningAdmissionIntegrationTest {
() -> pending.get(2L, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof CancellationException);
verify(planner, never()).plan(any(HydrologyTileKey.class));
verify(planner, never()).emptyTile(any(HydrologyTileKey.class));
assertEquals(0, cache.size());
releaseRoots(held);
HydrologyTile tile = mock(HydrologyTile.class);
@@ -44,19 +44,33 @@ public final class HydrologyRegionalFallsTest {
.materializeFinalHydrology(new HydrologyCaveCourseFilter.Result(network.nodes(), network.edges(),
network.outlets(), network.courses(), plans), new ArrayList<>(), compiler, true);
HydrologyCaveCourseFilter.Result clipped = HydrologyCrossTileResolver.clipRegionalPlans(materialized.result(), network, bounds);
RiverCourse local = new RiverCourse(101L, RiverCourseType.SURFACE, regional.sourceNodeId(), regional.outletId(),
DrainageNode localNode = new DrainageNode(103L, 0, 0,
HydrologyTerrainSample.openLand(89, 0D, "parent"), 100D, 104L);
RiverOutlet localOutlet = new RiverOutlet(104L, HydrologyFeatureType.MOUTH, 103L,
new HydrologyPoint(0, 72, 0), new HydrologyPoint(1, 63, 0), 63, true);
RiverCourse local = new RiverCourse(101L, RiverCourseType.SURFACE, OptionalLong.of(103L), OptionalLong.of(104L),
"water", 1, List.of(), List.of(new HydraulicSegment(102L, 101L, HydrologyFeatureType.SURFACE_POOL,
72, 72, 4, 2, false, false, List.of(new HydrologyPoint(0, 72, 0), new HydrologyPoint(1, 72, 0)),
HydraulicChannelProfile.uniform(4, 2))));
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(),
SETTINGS.routing().tileSize(), network.nodes(), network.edges(), network.outlets(),
List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint());
HydrologyTileKey key = new HydrologyTileKey(0, 0);
CrossTileResolvedOwner owner = new CrossTileResolvedOwner(new HydrologyOwnerDraft(key,
new HydrologyCaveCourseFilter.Result(List.of(localNode), List.of(), List.of(localOutlet),
List.of(local), List.of()), List.of(), null), List.of());
ArrayList<DrainageNode> publishedNodes = new ArrayList<>(network.nodes());
publishedNodes.add(localNode);
ArrayList<RiverOutlet> publishedOutlets = new ArrayList<>(network.outlets());
publishedOutlets.add(localOutlet);
HydrologyTile original = new HydrologyTile(key, 1L, SETTINGS.fingerprint(),
SETTINGS.routing().tileSize(), publishedNodes, network.edges(), publishedOutlets,
List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint())
.withResolvedOwner(owner);
PreparedHydrologyTileStore store = new PreparedHydrologyTileStore(temporaryFolder.newFolder().toPath(),
new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()),
SETTINGS.routing().tileSize());
store.save(original);
HydrologyTile restored = store.load(original.key()).orElseThrow();
assertEquals(original, restored);
assertEquals(owner, restored.resolvedOwner());
assertFalse(restored.cavePlans().isEmpty());
HydrologyPlanner fresh = new HydrologyPlanner(1L, SETTINGS, (x, z) -> {
throw new AssertionError("Restoring final ownership must not sample terrain");
@@ -64,12 +78,14 @@ public final class HydrologyRegionalFallsTest {
fresh.reuseResolvedTile(restored);
HydrologyCaveCourseFilter.Result owned = fresh.resolvedOwners.getIfPresent(restored.key()).draft().result();
CrossTileResolvedOwner restoredOwner = fresh.resolveIndependentOwner(restored.key());
assertEquals(owner, restoredOwner);
HydrologyCaveCourseFilter.Result owned = restoredOwner.draft().result();
assertEquals(List.of(local), owned.courses());
assertTrue(owned.cavePlans().isEmpty());
assertEquals(restored.nodes(), owned.nodes());
assertEquals(restored.edges(), owned.edges());
assertEquals(restored.outlets(), owned.outlets());
assertEquals(List.of(localNode), owned.nodes());
assertTrue(owned.edges().isEmpty());
assertEquals(List.of(localOutlet), owned.outlets());
}
@Test
@@ -39,6 +39,7 @@ public class HydrologyRegionalPlannerTest {
});
restored.reuseResolvedTile(original);
assertEquals(original.resolvedOwner(), restored.resolveIndependentOwner(key));
planning.set(true);
assertEquals(expected, restored.plan(adjacent));
@@ -56,6 +56,32 @@ public class HydrologyTileCacheTest {
@Rule
public TemporaryFolder preparedFolder = new TemporaryFolder();
@Test
public void sharedTileReuseDoesNotDecodeThePersistedCopy() throws Exception {
HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class);
HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class);
when(firstPlanner.settings()).thenReturn(emptySettings());
when(secondPlanner.settings()).thenReturn(emptySettings());
HydrologyTileCache.SharedCacheScope scope = sharedScope();
HydrologyTileKey key = new HydrologyTileKey(10, -15);
HydrologyTile tile = preparedTile(key, scope);
when(firstPlanner.plan(key)).thenReturn(tile);
PreparedHydrologyTileStore store = mock(PreparedHydrologyTileStore.class);
when(store.contains(key)).thenReturn(true);
try (HydrologyTileCache first = new HydrologyTileCache(firstPlanner, 4, null, null, scope);
HydrologyTileCache second = new HydrologyTileCache(secondPlanner, 4, null, null, scope)) {
assertSame(tile, first.get(key));
Field persistentStore = HydrologyTileCache.class.getDeclaredField("persistentStore");
persistentStore.setAccessible(true);
persistentStore.set(second, store);
assertSame(tile, second.get(key));
verify(store).contains(key);
verify(store, never()).load(key);
verify(store, never()).save(tile);
verify(secondPlanner, never()).plan(key);
}
}
@Test
public void preparedPlansRestoreFarFromOriginWithoutTerrainPlanning() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
@@ -740,26 +766,56 @@ public class HydrologyTileCacheTest {
assertEquals(1, terrainPreparations.get());
}
@Test
public void failedPlansDoNotEnterTheSharedStudioCache() {
HydrologyPlanner failingPlanner = mock(HydrologyPlanner.class);
when(failingPlanner.settings()).thenReturn(emptySettings());
HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class);
when(succeedingPlanner.settings()).thenReturn(emptySettings());
HydrologyTile emptyTile = mock(HydrologyTile.class);
HydrologyTile plannedTile = mock(HydrologyTile.class);
HydrologyTileKey key = new HydrologyTileKey(-4, 5);
@Test(timeout = 10000L)
public void failedPlansRetryBeforePublishingToMemorySharedCacheOrDisk() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileCache.SharedCacheScope scope = sharedScope();
when(failingPlanner.plan(key)).thenThrow(new IllegalStateException("test failure"));
when(failingPlanner.emptyTile(key)).thenReturn(emptyTile);
when(succeedingPlanner.plan(key)).thenReturn(plannedTile);
HydrologyTileCache first = new HydrologyTileCache(failingPlanner, 4, null, null, scope);
HydrologyTileCache second = new HydrologyTileCache(succeedingPlanner, 4, null, null, scope);
assertSame(emptyTile, first.get(key));
assertSame(plannedTile, second.get(key));
verify(succeedingPlanner, times(1)).plan(key);
HydrologyTileKey key = new HydrologyTileKey(-4, 5);
HydrologyTile tile = preparedTile(key, scope);
AtomicInteger attempts = new AtomicInteger();
CountDownLatch rebuilding = new CountDownLatch(1);
CountDownLatch recovered = new CountDownLatch(1);
when(planner.plan(key)).thenAnswer(invocation -> {
if (attempts.incrementAndGet() <= 3) {
throw new IllegalStateException("Inconsistent owner metadata");
}
rebuilding.countDown();
assertTrue(recovered.await(5L, TimeUnit.SECONDS));
return tile;
});
PreparedHydrologyTileStore store = mock(PreparedHydrologyTileStore.class);
ExecutorService callers = Executors.newFixedThreadPool(2);
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4, null, null, scope)) {
Field persistentStore = HydrologyTileCache.class.getDeclaredField("persistentStore");
persistentStore.setAccessible(true);
persistentStore.set(cache, store);
Future<HydrologyTile> first = callers.submit(() -> cache.get(key));
Future<HydrologyTile> second = callers.submit(() -> cache.get(key));
assertTrue(rebuilding.await(5L, TimeUnit.SECONDS));
assertFalse(first.isDone());
assertFalse(second.isDone());
assertEquals(0, cache.size());
verify(store, never()).save(any(HydrologyTile.class));
recovered.countDown();
assertSame(tile, first.get(5L, TimeUnit.SECONDS));
assertSame(tile, second.get(5L, TimeUnit.SECONDS));
assertSame(tile, cache.get(key));
verify(planner, times(4)).plan(key);
verify(planner, times(3)).clearOwnerDrafts();
verify(store, times(1)).load(key);
verify(store, times(1)).save(tile);
HydrologyPlanner sharedPlanner = mock(HydrologyPlanner.class);
when(sharedPlanner.settings()).thenReturn(emptySettings());
try (HydrologyTileCache shared = new HydrologyTileCache(sharedPlanner, 4, null, null, scope)) {
assertSame(tile, shared.get(key));
verify(sharedPlanner, never()).plan(key);
}
} finally {
recovered.countDown();
callers.shutdownNow();
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
@@ -1243,35 +1299,139 @@ public class HydrologyTileCacheTest {
);
}
@Test
public void planningFailureFallsBackToTheEmptyTileAndIsCached() {
@Test(timeout = 10000L)
public void planningFailureRetriesAndCachesTheRecoveredTile() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTileKey key = new HydrologyTileKey(3, -2);
HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), Set.of(), List.of(), List.of(), RiverFootprint.empty());
when(planner.settings()).thenReturn(emptySettings());
when(planner.plan(key)).thenThrow(new IllegalStateException("Hydrology natural height was not finite at -66,-641"));
when(planner.emptyTile(key)).thenReturn(empty);
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
assertSame(empty, cache.get(key));
assertSame(empty, cache.get(key));
verify(planner, times(1)).plan(key);
assertTrue(cache.get(key).courses().isEmpty());
HydrologyPlanner canonical = new HydrologyPlanner(811L, featureSettings(), this::featureTerrain);
HydrologyTileKey key = new HydrologyTileKey(0, 0);
HydrologyTile tile = canonical.plan(key);
HydrologyColumnSample sample = tile.footprint().columns().values().stream()
.filter(HydrologyColumnSample::present).findFirst().orElseThrow();
when(planner.settings()).thenReturn(featureSettings());
when(planner.plan(any(HydrologyTileKey.class))).thenAnswer(invocation ->
canonical.plan(invocation.getArgument(0)));
when(planner.plan(key)).thenThrow(new IllegalStateException("Inconsistent owner metadata"))
.thenReturn(tile);
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 8)) {
assertSame(tile, cache.get(key));
assertSame(tile, cache.get(key));
assertFalse(tile.courses().isEmpty());
assertTrue(tile.footprint().size() > 0);
HydrologyColumnSample published = cache.columnAt(sample.x(), sample.z()).orElseThrow();
assertEquals(sample.x(), published.x());
assertEquals(sample.z(), published.z());
assertTrue(published.layers().containsAll(sample.layers()));
verify(planner, times(2)).plan(key);
verify(planner).clearOwnerDrafts();
}
}
@Test
public void plannerEmptyTileCarriesTheTileIdentityAndNoContent() {
HydrologyPlanner planner = new HydrologyPlanner(811L, featureSettings(), this::featureTerrain);
HydrologyTileKey key = new HydrologyTileKey(-4, 9);
@Test(timeout = 10000L)
public void clearingDuringRetryCompletesTheSnapshotWithoutPublishing() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileCache.SharedCacheScope scope = sharedScope();
HydrologyTileKey key = new HydrologyTileKey(3, -2);
HydrologyTile tile = preparedTile(key, scope);
CountDownLatch failed = new CountDownLatch(1);
doAnswer(invocation -> {
failed.countDown();
return null;
}).when(planner).clearOwnerDrafts();
when(planner.plan(key)).thenThrow(new IllegalStateException("Inconsistent owner metadata"))
.thenReturn(tile);
PreparedHydrologyTileStore store = mock(PreparedHydrologyTileStore.class);
ExecutorService caller = Executors.newSingleThreadExecutor();
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4, null, null, scope)) {
Field persistentStore = HydrologyTileCache.class.getDeclaredField("persistentStore");
persistentStore.setAccessible(true);
persistentStore.set(cache, store);
Future<HydrologyTile> pending = caller.submit(() -> cache.get(key));
assertTrue(failed.await(5L, TimeUnit.SECONDS));
cache.clear();
assertSame(tile, pending.get(5L, TimeUnit.SECONDS));
assertEquals(0, cache.size());
verify(store, never()).save(any(HydrologyTile.class));
HydrologyPlanner freshPlanner = mock(HydrologyPlanner.class);
when(freshPlanner.settings()).thenReturn(emptySettings());
when(freshPlanner.plan(key)).thenReturn(tile);
try (HydrologyTileCache fresh = new HydrologyTileCache(freshPlanner, 4, null, null, scope)) {
assertSame(tile, fresh.get(key));
verify(freshPlanner).plan(key);
}
} finally {
caller.shutdownNow();
assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS));
}
}
HydrologyTile tile = planner.emptyTile(key);
@Test(timeout = 10000L)
public void closingDuringRetryCancelsWithoutPublishing() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileKey key = new HydrologyTileKey(3, -2);
CountDownLatch failed = new CountDownLatch(1);
doAnswer(invocation -> {
failed.countDown();
return null;
}).when(planner).clearOwnerDrafts();
when(planner.plan(key)).thenThrow(new IllegalStateException("Inconsistent owner metadata"));
ExecutorService caller = Executors.newSingleThreadExecutor();
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
try {
Future<HydrologyTile> pending = caller.submit(() -> cache.get(key));
assertTrue(failed.await(5L, TimeUnit.SECONDS));
cache.close();
ExecutionException failure = assertThrows(ExecutionException.class,
() -> pending.get(2L, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof CancellationException);
assertEquals(0, cache.size());
verify(planner, times(1)).plan(key);
} finally {
caller.shutdownNow();
assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS));
cache.close();
}
}
assertEquals(key, tile.key());
assertEquals(featureSettings().routing().tileSize(), tile.tileSize());
assertTrue(tile.courses().isEmpty());
assertTrue(tile.outlets().isEmpty());
assertTrue(tile.columnAt(0, 0).isEmpty());
@Test(timeout = 10000L)
public void interruptionDuringRetryPreservesInterruptAndPublishesNothing() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileKey key = new HydrologyTileKey(3, -2);
AtomicReference<Thread> planningThread = new AtomicReference<>();
CountDownLatch failed = new CountDownLatch(1);
AtomicBoolean interruptPreserved = new AtomicBoolean();
when(planner.plan(key)).thenAnswer(invocation -> {
planningThread.set(Thread.currentThread());
throw new IllegalStateException("Inconsistent owner metadata");
});
doAnswer(invocation -> {
failed.countDown();
return null;
}).when(planner).clearOwnerDrafts();
ExecutorService caller = Executors.newSingleThreadExecutor();
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4)) {
Future<HydrologyTile> pending = caller.submit(() -> {
try {
return cache.get(key);
} catch (CancellationException failure) {
interruptPreserved.set(Thread.currentThread().isInterrupted());
throw failure;
}
});
assertTrue(failed.await(5L, TimeUnit.SECONDS));
planningThread.get().interrupt();
ExecutionException failure = assertThrows(ExecutionException.class,
() -> pending.get(2L, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof CancellationException);
assertTrue(interruptPreserved.get());
assertEquals(0, cache.size());
verify(planner, times(1)).plan(key);
} finally {
caller.shutdownNow();
assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
@@ -1794,7 +1954,6 @@ public class HydrologyTileCacheTest {
assertEquals(0, cache.size());
assertSame(tile, cache.get(key));
assertEquals(2, plans.get());
verify(planner, org.mockito.Mockito.never()).emptyTile(any(HydrologyTileKey.class));
}
@Test
@@ -0,0 +1,65 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveRejection;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class HydrologyTileTest {
@Test
public void retainedOwnerFreezesMutableDraftCollections() {
HydrologyTileKey key = new HydrologyTileKey(0, 0);
HydrologyPoint point = new HydrologyPoint(0, 72, 0);
HydraulicSegment segment = new HydraulicSegment(3L, 4L, HydrologyFeatureType.SURFACE_POOL,
72, 72, 2, 1, false, false, List.of(point), HydraulicChannelProfile.uniform(2D, 1D));
RiverCourse course = new RiverCourse(4L, RiverCourseType.SURFACE_POOL, OptionalLong.empty(),
OptionalLong.empty(), "water", 1, List.of(), List.of(segment));
HydrologyDiagnosticCandidate candidate = new HydrologyDiagnosticCandidate(9L,
HydrologyCandidateKind.SOURCE, HydrologyFeatureType.SURFACE_POOL, point,
HydrologyCandidateRejection.SOURCE_SPACING, 16);
List<DrainageNode> nodes = new ArrayList<>();
List<DrainageEdge> edges = new ArrayList<>();
List<RiverOutlet> outlets = new ArrayList<>();
List<RiverCourse> courses = new ArrayList<>(List.of(course));
List<HydrologyCavePlan> plans = new ArrayList<>();
List<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>(List.of(candidate));
HydrologyOwnerDraft draft = new HydrologyOwnerDraft(key,
new HydrologyCaveCourseFilter.Result(nodes, edges, outlets, courses, plans), diagnostics, null);
HydrologyTile tile = new HydrologyTile(key, 91L, 17L, 64, List.of(), List.of(), List.of(), List.of(),
Set.of(), List.of(), List.of(), RiverFootprint.empty())
.withResolvedOwner(new CrossTileResolvedOwner(draft, List.of()));
courses.clear();
diagnostics.clear();
nodes.add(new DrainageNode(1L, 0, 0, HydrologyTerrainSample.openLand(80, 0D, "plains"), 10D, 2L));
edges.add(new DrainageEdge(7L, 8L, 1L, 2L, 1D, 1, 0, List.of(point, new HydrologyPoint(1, 72, 0))));
outlets.add(new RiverOutlet(2L, HydrologyFeatureType.MOUTH, 1L, point,
new HydrologyPoint(1, 63, 0), 63, true));
CavePosition position = new CavePosition(0, 20, 0);
plans.add(new HydrologyCavePlan(new HydrologyCaveSource(4L, position, position, 20,
HydrologyCaveMode.CLOSED_COMPONENT), HydrologyCaveRejection.NONE,
Map.of(), Map.of(), OptionalLong.empty()));
HydrologyOwnerDraft retained = tile.resolvedOwner().draft();
assertEquals(List.of(course), retained.result().courses());
assertEquals(List.of(candidate), retained.diagnostics());
assertTrue(retained.result().nodes().isEmpty());
assertTrue(retained.result().edges().isEmpty());
assertTrue(retained.result().outlets().isEmpty());
assertTrue(retained.result().cavePlans().isEmpty());
assertThrows(UnsupportedOperationException.class, () -> retained.result().courses().clear());
assertThrows(UnsupportedOperationException.class, () -> retained.diagnostics().clear());
}
}
@@ -9,15 +9,19 @@ import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveRejection;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -26,12 +30,16 @@ import java.util.Set;
import java.util.Map;
import java.util.OptionalLong;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.stream.Stream;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -58,9 +66,73 @@ public class PreparedHydrologyTileStoreTest {
assertEquals(original.cavePlans(), restored.cavePlans());
assertEquals(original.localDiagnosticCandidates(), restored.localDiagnosticCandidates());
assertEquals(original.footprint(), restored.footprint());
assertNull(restored.resolvedOwner());
assertThrows(UnsupportedOperationException.class, () -> restored.regionalCourseIds().clear());
}
@Test
public void roundTripsExactOwnerOrderingAndUnpublishedCourses() throws Exception {
HydrologyTile original = tile();
HydrologyTerrainSample terrain = original.nodes().getFirst().terrain();
DrainageNode unpublishedNode = new DrainageNode(18L, 4, 0, terrain, 30D, 2L);
RiverCourse unpublishedCourse = surfaceCourse(44L);
HydrologyDiagnosticCandidate unpublishedDiagnostic = new HydrologyDiagnosticCandidate(42L,
HydrologyCandidateKind.SOURCE, HydrologyFeatureType.SURFACE_POOL,
new HydrologyPoint(4, 80, 0), HydrologyCandidateRejection.SOURCE_SPACING, 16);
HydrologyCaveCourseFilter.Result result = new HydrologyCaveCourseFilter.Result(
List.of(unpublishedNode, original.nodes().getLast(), original.nodes().getFirst()),
original.edges(), original.outlets(), List.of(unpublishedCourse, original.courses().getFirst()),
List.of());
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(
HydrologyPlannerSettings.defaults(), (x, z) -> terrain);
CrossTileResolvedOwner owner = new CrossTileResolvedOwner(new HydrologyOwnerDraft(original.key(),
result, List.of(unpublishedDiagnostic, original.localDiagnosticCandidates().getFirst()), compiler),
List.of(new CrossTileRejectedCourse(surfaceCourse(60L), 44L),
new CrossTileRejectedCourse(surfaceCourse(50L), 4L)));
HydrologyTile attached = original.withResolvedOwner(owner);
assertEquals(original, attached);
assertEquals(original.hashCode(), attached.hashCode());
assertSame(original.nodes(), attached.nodes());
assertSame(original.courses(), attached.courses());
assertSame(original.footprint(), attached.footprint());
assertSame(original.features(), attached.features());
assertSame(original.node(1L).orElseThrow(), attached.node(1L).orElseThrow());
assertNull(attached.resolvedOwner().draft().footprintCompiler());
assertEquals(HydrologyCacheWeights.tile(original) + HydrologyCacheWeights.owner(owner),
HydrologyCacheWeights.tile(attached));
PreparedHydrologyTileStore store = store();
store.save(attached);
HydrologyTile restored = store.load(original.key()).orElseThrow();
assertEquals(original, restored);
assertEquals(owner.withoutFootprintCompiler(), restored.resolvedOwner());
assertSame(restored.nodes().getLast(), restored.resolvedOwner().draft().result().nodes().get(1));
assertSame(restored.courses().getFirst(), restored.resolvedOwner().draft().result().courses().getLast());
assertThrows(UnsupportedOperationException.class,
() -> restored.resolvedOwner().draft().result().nodes().clear());
assertThrows(UnsupportedOperationException.class,
() -> restored.resolvedOwner().draft().diagnostics().clear());
CrossTileResolutionContext context = new CrossTileResolutionContext(original.key(), 1L, 9);
context.remember(original.key(), owner);
context.remember(restored.key(), restored.resolvedOwner());
}
@Test
public void invalidOwnerReferencesAreRejected() throws Exception {
HydrologyTile original = tile();
PreparedHydrologyTileStore store = store();
store.save(original);
int ownerOffset = readPayload(store.file(original.key())).length;
CrossTileResolvedOwner owner = new CrossTileResolvedOwner(new HydrologyOwnerDraft(original.key(),
new HydrologyCaveCourseFilter.Result(original.nodes(), original.edges(), original.outlets(),
original.courses(), original.cavePlans()), original.localDiagnosticCandidates(), null), List.of());
store.save(original.withResolvedOwner(owner));
byte[] bytes = readPayload(store.file(original.key()));
ByteBuffer.wrap(bytes).putInt(ownerOffset + Integer.BYTES, Integer.MAX_VALUE);
writePayload(store.file(original.key()), bytes);
assertTrue(store.load(original.key()).isEmpty());
}
@Test
public void roundTripsNonemptyCompactCavePlan() throws Exception {
CavePosition wet = new CavePosition(0, 20, 0);
@@ -93,44 +165,141 @@ public class PreparedHydrologyTileStoreTest {
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 91L, 17L, 64,
List.of(), List.of(), List.of(), List.of(course), Set.of(), List.of(plan), List.of(),
new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column)));
original = original.withResolvedOwner(new CrossTileResolvedOwner(new HydrologyOwnerDraft(original.key(),
new HydrologyCaveCourseFilter.Result(original.nodes(), original.edges(), original.outlets(),
original.courses(), original.cavePlans()), original.localDiagnosticCandidates(), null), List.of()));
PreparedHydrologyTileStore store = store();
store.save(original);
HydrologyTile restored = store.load(original.key()).orElseThrow();
assertEquals(original, restored);
assertEquals(original.resolvedOwner(), restored.resolvedOwner());
assertSame(restored.cavePlans().getFirst(), restored.resolvedOwner().draft().result().cavePlans().getFirst());
HydrologyCavePlan restoredPlan = restored.cavePlans().getFirst();
assertEquals(actions, restoredPlan.actions());
assertEquals(preconditions, restoredPlan.baselinePreconditions());
Comparator<CavePosition> persistedOrder = Comparator.comparingInt(CavePosition::x)
.thenComparingInt(CavePosition::z).thenComparingInt(CavePosition::y);
ArrayList<CavePosition> expectedActions = new ArrayList<>(actions.keySet());
expectedActions.sort(persistedOrder);
ArrayList<CavePosition> expectedPreconditions = new ArrayList<>(preconditions.keySet());
expectedPreconditions.sort(persistedOrder);
assertEquals(expectedActions, new ArrayList<>(restoredPlan.actions().keySet()));
assertEquals(expectedPreconditions, new ArrayList<>(restoredPlan.baselinePreconditions().keySet()));
assertEquals(new ArrayList<>(actions.keySet()), new ArrayList<>(restoredPlan.actions().keySet()));
assertEquals(new ArrayList<>(preconditions.keySet()), new ArrayList<>(restoredPlan.baselinePreconditions().keySet()));
assertThrows(UnsupportedOperationException.class, () -> restoredPlan.actions().clear());
assertThrows(UnsupportedOperationException.class, () -> restoredPlan.baselinePreconditions().remove(boundary));
ArrayList<CavePosition> published = new ArrayList<>();
restoredPlan.forEachActionIn(0, 0, 16, 16, (position, action) -> published.add(position));
assertEquals(List.of(wet, dry), published);
assertEquals(List.of(dry, wet), published);
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
plan.writeCells(new DataOutputStream(buffer));
byte[] valid = buffer.toByteArray();
byte[] flags = valid.clone();
flags[20] = (byte) 0x80;
byte[] duplicatePosition = valid.clone();
System.arraycopy(duplicatePosition, 8, duplicatePosition, 21, 12);
byte[] duplicateOrder = valid.clone();
System.arraycopy(duplicateOrder, 60, duplicateOrder, 64, 4);
byte[] invalidActionCount = valid.clone();
ByteBuffer.wrap(invalidActionCount).putInt(4, 5);
for (byte[] invalid : List.of(flags, duplicatePosition, duplicateOrder, invalidActionCount)) {
DataInputStream input = new DataInputStream(new ByteArrayInputStream(invalid));
int count = input.readInt();
assertThrows(IOException.class, () -> HydrologyCavePlan.readCells(
plan.source(), plan.rejection(), plan.arbitrationWinnerSourceId(), input, count));
}
}
@Test
public void entryRequiresRegionalOwnershipMetadata() throws Exception {
public void largeCavePlanUsesFixedSizeCellsWithoutLosingPreconditions() throws Exception {
int count = 100_000;
Map<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
Map<CavePosition, CaveVoxelPrecondition> preconditions = new LinkedHashMap<>();
CaveVoxelPrecondition baseline = new CaveVoxelPrecondition(CaveVoxel.SOLID, false);
for (int index = 0; index < count; index++) {
CavePosition position = new CavePosition(index % 256, index / 65536, index / 256 % 256);
preconditions.put(position, baseline);
if (index % 2 == 0) {
actions.put(position, HydrologyCaveAction.SEAL_GUARD);
}
}
HydrologyCaveSource source = new HydrologyCaveSource(4L, new CavePosition(0, 0, 0),
new CavePosition(1, 0, 0), 20, HydrologyCaveMode.CLOSED_COMPONENT);
HydrologyCavePlan original = new HydrologyCavePlan(source, HydrologyCaveRejection.NONE,
actions, preconditions, OptionalLong.empty());
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
original.writeCells(new DataOutputStream(buffer));
assertEquals(8 + 17 * count, buffer.size());
DataInputStream input = new DataInputStream(new ByteArrayInputStream(buffer.toByteArray()));
HydrologyCavePlan restored = HydrologyCavePlan.readCells(source, HydrologyCaveRejection.NONE,
OptionalLong.empty(), input, input.readInt());
assertEquals(original, restored);
assertEquals(-1, input.read());
}
@Test(timeout = 2000)
public void invalidCollectionLengthsFailBeforeAllocation() throws Exception {
HydrologyTile original = tile();
PreparedHydrologyTileStore store = store();
store.save(original);
Path file = store.file(original.key());
JsonObject persisted;
try (InputStream input = new GZIPInputStream(Files.newInputStream(file))) {
persisted = JsonParser.parseString(new String(input.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject();
}
persisted.remove("regionalCourseIds");
try (OutputStream output = new GZIPOutputStream(Files.newOutputStream(file))) {
output.write(persisted.toString().getBytes(StandardCharsets.UTF_8));
byte[] bytes = readPayload(file);
DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes));
input.readInt();
input.readInt();
input.readUTF();
input.skipNBytes(28);
int countOffset = bytes.length - input.available();
for (int count : new int[]{-1, Integer.MAX_VALUE, 1_000_000}) {
byte[] invalid = Arrays.copyOf(bytes, countOffset + Integer.BYTES);
ByteBuffer.wrap(invalid).putInt(countOffset, count);
writePayload(file, invalid);
assertTrue(store.load(original.key()).isEmpty());
}
}
@Test
public void truncatedAndTrailingPayloadsAreRejected() throws Exception {
HydrologyTile original = tile();
PreparedHydrologyTileStore store = store();
store.save(original);
Path file = store.file(original.key());
byte[] bytes = readPayload(file);
writePayload(file, Arrays.copyOf(bytes, bytes.length - 1));
assertTrue(store.load(original.key()).isEmpty());
writePayload(file, Arrays.copyOf(bytes, bytes.length + 1));
assertTrue(store.load(original.key()).isEmpty());
}
@Test
public void presenceCheckDoesNotDecodeTileContents() throws Exception {
HydrologyTile original = tile();
PreparedHydrologyTileStore store = store();
store.save(original);
Files.writeString(store.file(original.key()), "invalid", StandardCharsets.UTF_8);
assertTrue(store.contains(original.key()));
assertTrue(store.load(original.key()).isEmpty());
}
@Test
public void allocationLimitDoesNotReplaceAnExistingTile() throws Exception {
HydrologyTile original = tile();
PreparedHydrologyTileStore store = store();
store.save(original);
HydrologyTile oversized = new HydrologyTile(original.key(), original.worldSeed(),
original.settingsFingerprint(), original.tileSize(), original.nodes(), original.edges(),
original.outlets(), original.courses(), original.regionalCourseIds(), original.cavePlans(),
Collections.nCopies(600_000, original.localDiagnosticCandidates().getFirst()), original.footprint());
assertThrows(IOException.class, () -> store.save(oversized));
assertEquals(original, store.load(original.key()).orElseThrow());
try (Stream<Path> files = Files.list(store.file(original.key()).getParent())) {
assertEquals(List.of(store.file(original.key())), files.toList());
}
}
private byte[] readPayload(Path file) throws Exception {
try (InputStream input = new GZIPInputStream(Files.newInputStream(file))) {
return input.readAllBytes();
}
}
private void writePayload(Path file, byte[] bytes) throws Exception {
try (OutputStream output = new GZIPOutputStream(Files.newOutputStream(file))) {
output.write(bytes);
}
}
@Test
@@ -191,6 +360,12 @@ public class PreparedHydrologyTileStoreTest {
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0.25D, "plains")
.withSurfacePolicy(new SurfaceRiverPolicy("region:tropical", 8D, 160, 3, 3, 4, 128, 24));
DrainageNode node = new DrainageNode(1L, 0, 0, terrain, 10D, 2L);
DrainageNode upstream = new DrainageNode(8L, 2, 0, terrain, 20D, 2L);
DrainageEdge edge = new DrainageEdge(7L, 8L, 1L, 2L, 1D, 1, 0,
List.of(new HydrologyPoint(2, 80, 0), new HydrologyPoint(0, 80, 0)));
HydrologyDiagnosticCandidate candidate = new HydrologyDiagnosticCandidate(9L,
HydrologyCandidateKind.SOURCE, HydrologyFeatureType.SURFACE_POOL,
new HydrologyPoint(5, 80, 0), HydrologyCandidateRejection.SOURCE_SPACING, 16);
RiverOutlet outlet = new RiverOutlet(
2L,
HydrologyFeatureType.MOUTH,
@@ -220,7 +395,7 @@ public class PreparedHydrologyTileStoreTest {
OptionalLong.of(2L),
"default",
1,
List.of(),
List.of(edge),
List.of(segment)
);
HydrologyFeatureRef feature = new HydrologyFeatureRef(
@@ -270,14 +445,22 @@ public class PreparedHydrologyTileStoreTest {
91L,
17L,
64,
List.of(node),
List.of(),
List.of(node, upstream),
List.of(edge),
List.of(outlet),
List.of(course),
Set.of(course.id()),
List.of(),
List.of(),
List.of(candidate),
new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column))
);
}
private RiverCourse surfaceCourse(long id) {
HydraulicSegment segment = new HydraulicSegment(id + 1L, id, HydrologyFeatureType.SURFACE_POOL,
72, 72, 4, 2, false, false, List.of(new HydrologyPoint(0, 72, 0)),
HydraulicChannelProfile.uniform(4D, 2D));
return new RiverCourse(id, RiverCourseType.SURFACE, OptionalLong.of(1L), OptionalLong.of(2L),
"default", 1, List.of(), List.of(segment));
}
}
@@ -7,6 +7,7 @@ import art.arcane.iris.testsupport.Await;
import art.arcane.iris.testsupport.PlatformBinding;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.spi.LogLevel;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
@@ -46,6 +47,8 @@ import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -420,12 +423,21 @@ public class MatterGeneratorConcurrencyTest {
}
@Test
public void timedOutRunningComponentRetainsWriterAndSharedClaimUntilItExits() throws Exception {
public void slowRunningComponentRetainsWriterAndSharedClaimAndCompletesSuccessfully() throws Exception {
GeneratorFixture fixture = new GeneratorFixture(true);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch writersAcquired = new CountDownLatch(2);
CountDownLatch cancelled = new CountDownLatch(1);
CountDownLatch warned = new CountDownLatch(1);
CountDownLatch polls = new CountDownLatch(6);
AtomicInteger warnings = new AtomicInteger();
AtomicReference<String> diagnostic = new AtomicReference<>();
doAnswer(invocation -> {
warnings.incrementAndGet();
diagnostic.set(invocation.getArgument(1));
warned.countDown();
return null;
}).when(PLATFORM.platform()).log(eq(LogLevel.WARN), startsWith("Mantle component OBJECT at 0,0 is still pending"));
AtomicInteger runs = new AtomicInteger();
MantleChunk<Matter> chunk = fixture.mantle.getChunk(0, 0);
when(chunk.use()).thenAnswer(invocation -> {
@@ -448,9 +460,9 @@ public class MatterGeneratorConcurrencyTest {
MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 0, 0, ReservedFlag.OBJECT);
MatterGenerator.MatterComponentTask task = MatterGenerator.IN_FLIGHT_COMPONENTS.get(key);
Future<?> submission = mock(Future.class);
when(submission.cancel(true)).thenAnswer(invocation -> {
cancelled.countDown();
return true;
when(submission.isDone()).thenAnswer(invocation -> {
polls.countDown();
return false;
});
task.setSubmission(submission);
CompletableFuture<Void> second = MultiBurst.burst.completeValueAsync(() -> {
@@ -458,45 +470,86 @@ public class MatterGeneratorConcurrencyTest {
return null;
});
CompletableFuture<Void> timeout = CompletableFuture.runAsync(() -> task.await(0L));
CompletableFuture<Void> otherTimeout = CompletableFuture.runAsync(() -> task.await(0L));
try {
await(writersAcquired);
await(cancelled);
await(warned);
await(polls);
assertSame(task, MatterGenerator.IN_FLIGHT_COMPONENTS.get(key));
assertFalse(first.isDone());
assertFalse(second.isDone());
assertFalse(timeout.isDone());
assertFalse(otherTimeout.isDone());
assertEquals(1, warnings.get());
assertTrue(diagnostic.get().contains("Worker="));
assertTrue(diagnostic.get().contains("runningForMs="));
assertTrue(diagnostic.get().contains("\n\tat "));
verify(submission, never()).cancel(anyBoolean());
verify(chunk, never()).release();
assertEquals(1, runs.get());
} finally {
release.countDown();
}
assertThrows(ExecutionException.class, () -> timeout.get(5L, TimeUnit.SECONDS));
assertThrows(ExecutionException.class, () -> first.get(5L, TimeUnit.SECONDS));
assertThrows(ExecutionException.class, () -> second.get(5L, TimeUnit.SECONDS));
timeout.get(5L, TimeUnit.SECONDS);
otherTimeout.get(5L, TimeUnit.SECONDS);
first.get(5L, TimeUnit.SECONDS);
second.get(5L, TimeUnit.SECONDS);
verify(chunk, times(2)).release();
assertFalse(MatterGenerator.IN_FLIGHT_COMPONENTS.containsKey(key));
}
@Test
public void queuedTimeoutPreventsLateExecutionAndAllowsANewClaim() {
public void slowQueuedComponentCanStartAndCompleteSuccessfully() throws Exception {
GeneratorFixture fixture = new GeneratorFixture();
MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 0, 0, ReservedFlag.OBJECT);
MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 1, 0, ReservedFlag.OBJECT);
MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key);
MatterGenerator.IN_FLIGHT_COMPONENTS.put(key, task);
Future<?> submission = mock(Future.class);
task.setSubmission(submission);
CompletionException failure = assertThrows(CompletionException.class, () -> task.await(0L));
assertTrue(failure.getCause().getMessage().contains("did not complete"));
assertFalse(task.start());
CountDownLatch warned = new CountDownLatch(1);
AtomicReference<String> diagnostic = new AtomicReference<>();
doAnswer(invocation -> {
diagnostic.set(invocation.getArgument(1));
warned.countDown();
return null;
}).when(PLATFORM.platform()).log(eq(LogLevel.WARN), startsWith("Mantle component OBJECT at 1,0 is still pending"));
CompletableFuture<Void> completion = CompletableFuture.runAsync(() -> task.await(0L));
try {
await(warned);
assertTrue(diagnostic.get().contains("Task is queued"));
assertFalse(completion.isDone());
assertSame(task, MatterGenerator.IN_FLIGHT_COMPONENTS.get(key));
verify(submission, never()).cancel(anyBoolean());
assertTrue(task.start());
} finally {
task.finish(null);
}
completion.get(5L, TimeUnit.SECONDS);
assertFalse(MatterGenerator.IN_FLIGHT_COMPONENTS.containsKey(key));
verify(submission).cancel(true);
MatterGenerator.MatterComponentTask replacement = new MatterGenerator.MatterComponentTask(key);
assertNull(MatterGenerator.IN_FLIGHT_COMPONENTS.putIfAbsent(key, replacement));
task.finish(null);
assertSame(replacement, MatterGenerator.IN_FLIGHT_COMPONENTS.get(key));
replacement.finish(null);
}
@Test
public void slowComponentPreservesItsActualFailure() throws Exception {
GeneratorFixture fixture = new GeneratorFixture();
MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 2, 0, ReservedFlag.OBJECT);
MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key);
CountDownLatch warned = new CountDownLatch(1);
doAnswer(invocation -> {
warned.countDown();
return null;
}).when(PLATFORM.platform()).log(eq(LogLevel.WARN), startsWith("Mantle component OBJECT at 2,0 is still pending"));
IllegalStateException failure = new IllegalStateException("Object generation failed");
assertTrue(task.start());
CompletableFuture<Void> completion = CompletableFuture.runAsync(() -> task.await(0L));
try {
await(warned);
assertFalse(completion.isDone());
} finally {
task.finish(failure);
}
ExecutionException observed = assertThrows(ExecutionException.class,
() -> completion.get(5L, TimeUnit.SECONDS));
assertSame(failure, observed.getCause());
}
@Test
@@ -82,7 +82,7 @@ public class ObjectDestinationTransactionTest {
}
ObjectSourcePlan plan = source.sourcePlanSince(0);
assertEquals(16, source.mutationCheckpoint());
assertEquals(35, plan.mutationWeight());
assertEquals(Integer.MAX_VALUE, plan.estimatedRetainedBytes());
for (int[] position : positions) {
int x = position[0];
int z = position[1];
@@ -22,14 +22,14 @@ import static org.junit.Assert.assertTrue;
public class ObjectSourcePlanCacheTest {
@Test
public void defaultCapacityRetainsNeighboringSourceWorkingSetWithoutRebuilding() {
ObjectSourcePlanCache cache = new ObjectSourcePlanCache();
public void budgetRetainsNeighboringSourceWorkingSetWithoutRebuilding() {
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(1024L * 1024L);
AtomicInteger builds = new AtomicInteger();
ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(
0, 4, 0, String.class);
ObjectDestinationTransaction.Mutation mutation = new ObjectDestinationTransaction.SetMutation(key, "marker");
ObjectSourcePlan expected = new ObjectSourcePlan(Collections.nCopies(2048, mutation));
assertTrue(expected.mutationWeight() * 1024L > 1_048_576L);
ObjectSourcePlan expected = new ObjectSourcePlan(Collections.nCopies(2, mutation));
assertTrue(expected.estimatedRetainedBytes() * 1024L < 1_048_576L);
for (int sweep = 0; sweep < 3; sweep++) {
for (int x = -16; x < 16; x++) {
for (int z = -16; z < 16; z++) {
@@ -53,7 +53,7 @@ public class ObjectSourcePlanCacheTest {
@Test
public void unrelatedCoordinatesBuildWhileAnotherPlanIsPending() throws Exception {
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L);
ObjectSourcePlan first = planAt(1);
ObjectSourcePlan second = planAt(2);
CountDownLatch firstEntered = new CountDownLatch(1);
@@ -88,7 +88,7 @@ public class ObjectSourcePlanCacheTest {
@Test
public void concurrentRequestsBuildOneSourcePlan() throws Exception {
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L);
ObjectSourcePlan expected = new ObjectSourcePlan(List.of());
AtomicInteger builds = new AtomicInteger();
CountDownLatch ready = new CountDownLatch(8);
@@ -119,10 +119,10 @@ public class ObjectSourcePlanCacheTest {
}
@Test
public void cacheEvictsByMutationWeightAndCanBeCleared() {
public void cacheEvictsByRetainedBytesAndCanBeCleared() {
ObjectSourcePlan first = planAt(0);
ObjectSourcePlan second = planAt(1);
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.mutationWeight());
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.estimatedRetainedBytes());
cache.get(0, 0, () -> first);
cache.get(1, 0, () -> second);
@@ -133,13 +133,13 @@ public class ObjectSourcePlanCacheTest {
}
@Test
public void cacheRejectsNonPositiveMutationCapacity() {
public void cacheRejectsNonPositiveByteCapacity() {
assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlanCache(0L));
}
@Test
public void clearingPendingWorkKeepsNewGenerationIsolated() throws Exception {
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L);
ObjectSourcePlan old = planAt(1);
ObjectSourcePlan replacement = planAt(2);
CountDownLatch entered = new CountDownLatch(1);
@@ -164,7 +164,7 @@ public class ObjectSourcePlanCacheTest {
@Test
public void waitingForkJoinWorkerAllowsQueuedDependencyToRun() throws Exception {
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L);
ObjectSourcePlan expected = planAt(1);
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch waiting = new CountDownLatch(1);
@@ -194,7 +194,7 @@ public class ObjectSourcePlanCacheTest {
@Test
public void failedAndNullBuildsAreRetryableAndRecursionFailsImmediately() {
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L);
IllegalArgumentException failure = new IllegalArgumentException("Build failed");
assertSame(failure, assertThrows(IllegalArgumentException.class,
() -> cache.get(7, 8, () -> { throw failure; })));
@@ -205,6 +205,43 @@ public class ObjectSourcePlanCacheTest {
assertSame(expected, cache.get(7, 8, () -> expected));
}
@Test
public void byteBudgetScalesWithHeapAndHasAnUpperBound() {
assertEquals(64L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(4L * 1024L * 1024L * 1024L));
assertEquals(16L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(1024L * 1024L * 1024L));
assertEquals(256L * 1024L * 1024L, ObjectSourcePlanCache.retainedByteBudget(Long.MAX_VALUE));
}
@Test
public void emptyPlansConsumeTheByteBudget() {
ObjectSourcePlan empty = new ObjectSourcePlan(List.of());
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4L * empty.estimatedRetainedBytes());
for (int chunk = 0; chunk < 1000; chunk++) {
cache.get(chunk, 0, () -> empty);
}
assertTrue(cache.estimatedSize() <= 4L);
}
@Test
public void oversizedAndUnmeasuredPlansAreReturnedWithoutRetention() {
ObjectSourcePlan large = planAt(1);
ObjectSourcePlan unmeasured = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(
new ObjectDestinationTransaction.DataKey(0, 4, 0, Object.class), new Object())));
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(large.estimatedRetainedBytes() - 1L);
assertSame(large, cache.get(1, 0, () -> large));
assertSame(unmeasured, cache.get(2, 0, () -> unmeasured));
assertEquals(0L, cache.estimatedSize());
AtomicInteger builds = new AtomicInteger();
cache.get(1, 0, () -> {
builds.incrementAndGet();
return large;
});
assertEquals(1, builds.get());
ObjectSourcePlanCache roomy = new ObjectSourcePlanCache(Long.MAX_VALUE);
assertSame(unmeasured, roomy.get(2, 0, () -> unmeasured));
assertEquals(0L, roomy.estimatedSize());
}
private static void await(CountDownLatch latch) {
try {
assertTrue(latch.await(10L, TimeUnit.SECONDS));
@@ -45,11 +45,19 @@ public class ObjectSourcePlanTest {
ObjectSourcePlan compact = new ObjectSourcePlan(List.of(first, nearby));
ObjectSourcePlan scattered = new ObjectSourcePlan(List.of(first, remote));
assertTrue(compact.mutationWeight() > 1 + first.weight() + nearby.weight());
assertTrue(scattered.mutationWeight() > compact.mutationWeight());
assertTrue(compact.estimatedRetainedBytes() > new ObjectSourcePlan(List.of()).estimatedRetainedBytes());
assertTrue(scattered.estimatedRetainedBytes() > compact.estimatedRetainedBytes());
assertThrows(NullPointerException.class, () -> new ObjectSourcePlan(Arrays.asList(first, null)));
}
@Test
public void retainedBytesIncludeVariableLengthMarkers() {
ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class);
ObjectSourcePlan shortMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a")));
ObjectSourcePlan longMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a".repeat(4096))));
assertTrue(longMarker.estimatedRetainedBytes() - shortMarker.estimatedRetainedBytes() >= 8190);
}
private static ObjectDestinationTransaction.Mutation mutationAt(int x, int z) {
return new ObjectDestinationTransaction.SetMutation(
new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker");
@@ -16,6 +16,16 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class AsyncPregenMethodConcurrencyCapTest {
@Test
public void outstandingChunksReserveHeapForGenerationDependencies() {
assertEquals(16, AsyncPregenMethod.heapLimitedConcurrency(256, 4L << 30));
assertEquals(32, AsyncPregenMethod.heapLimitedConcurrency(256, 8L << 30));
assertEquals(64, AsyncPregenMethod.heapLimitedConcurrency(256, 16L << 30));
assertEquals(8, AsyncPregenMethod.heapLimitedConcurrency(8, 16L << 30));
assertEquals(1, AsyncPregenMethod.heapLimitedConcurrency(256, 0L));
assertEquals(256, AsyncPregenMethod.heapLimitedConcurrency(256, Long.MAX_VALUE));
}
@Test
public void pregenWorkerTargetExpandsOnlyOptedInHighHeapJobs() {
assertEquals(16, AsyncPregenMethod.pregenWorkerThreadTarget(16, (16L << 30) - 1L, true));
@@ -6,7 +6,6 @@ import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.collection.KSet;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.bukkit.plugin.Plugin;
@@ -31,7 +30,9 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
@@ -240,7 +241,7 @@ public class AsyncPregenServerSaveOwnershipTest {
try (SchedulingContext ignored = new SchedulingContext(fixture)) {
Future<?> flushed = executor.submit(() -> {
try (SchedulingContext context = new SchedulingContext(fixture)) {
invoke(fixture.method, "flushAllRemainingChunks");
fixture.method.reclaimMemory();
}
return null;
});
@@ -260,6 +261,57 @@ public class AsyncPregenServerSaveOwnershipTest {
}
}
@Test
public void emptyPublicReclaimDoesNotScheduleWorldIo() throws Exception {
Fixture fixture = new Fixture();
fixture.chunks.clear();
fixture.set("pendingEvictions", new ConcurrentLinkedQueue<CompletableFuture<Void>>());
try (SchedulingContext context = new SchedulingContext(fixture)) {
fixture.method.reclaimMemory();
context.scheduling.verifyNoInteractions();
verifyNoInteractions(fixture.binding);
}
}
@Test
public void publicReclaimFlushesCompletedChunksWithoutPriorEvictions() throws Exception {
Fixture fixture = new Fixture();
fixture.set("pendingEvictions", new ConcurrentLinkedQueue<CompletableFuture<Void>>());
try (SchedulingContext context = new SchedulingContext(fixture)) {
fixture.method.reclaimMemory();
assertTrue(fixture.chunks.isEmpty());
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
verify(fixture.binding, times(2)).flushChunkIO(fixture.world);
}
}
@Test
public void publicReclaimWaitsForEvictionsWithoutNewChunks() throws Exception {
Fixture fixture = new Fixture();
fixture.chunks.clear();
CountDownLatch started = new CountDownLatch(1);
ExecutorService executor = Executors.newSingleThreadExecutor();
try (SchedulingContext owner = new SchedulingContext(fixture)) {
Future<?> reclaimed = executor.submit(() -> {
try (SchedulingContext context = new SchedulingContext(fixture)) {
started.countDown();
fixture.method.reclaimMemory();
}
});
assertTrue(started.await(5L, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> reclaimed.get(30L, TimeUnit.MILLISECONDS));
verifyNoInteractions(fixture.binding);
fixture.pending.complete(null);
reclaimed.get(5L, TimeUnit.SECONDS);
owner.scheduling.verifyNoInteractions();
verify(fixture.binding).flushChunkIO(fixture.world);
} finally {
fixture.pending.complete(null);
executor.shutdownNow();
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
public void nativeFlushRunsOnThePregenFlushWorkerNotTheSharedIoPool() throws Exception {
Fixture fixture = new Fixture();
@@ -280,10 +332,110 @@ public class AsyncPregenServerSaveOwnershipTest {
}
}
@Test
public void completedRegionsUnloadWithoutWaitingForTheirNeighbors() throws Exception {
Fixture fixture = new Fixture();
fixture.chunks.clear();
try (SchedulingContext context = new SchedulingContext(fixture)) {
for (int region = 0; region < 128; region++) {
long key = (long) region << 32;
fixture.pendingRegions.put(key, new AtomicInteger(2));
invoke(fixture.method, "onChunkCompleted", region << 5, 0, fixture.chunk);
assertEquals(1, fixture.pendingRegions.get(key).get());
fixture.method.onRegionSubmitted(region, 0);
assertTrue(fixture.pendingRegions.isEmpty());
assertTrue(fixture.chunks.isEmpty());
}
verify(fixture.binding, times(128)).saveAndUnloadChunk(fixture.world, 2, 3);
}
}
@Test
public void partialRegionEvictionKeepsPendingSubmissionsAndAcceptsLaterCompletions() throws Exception {
Fixture fixture = new Fixture();
AtomicInteger pendingChunks = new AtomicInteger(2);
fixture.pendingRegions.put(0L, pendingChunks);
try (SchedulingContext context = new SchedulingContext(fixture)) {
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
eviction.get(5L, TimeUnit.SECONDS);
assertSame(pendingChunks, fixture.pendingRegions.get(0L));
assertEquals(2, pendingChunks.get());
assertTrue(fixture.chunks.isEmpty());
invoke(fixture.method, "onChunkCompleted", 2, 3, fixture.chunk);
assertEquals(1, pendingChunks.get());
assertSame(fixture.chunk, fixture.chunks.get(0L).peek());
fixture.method.onRegionSubmitted(0, 0);
assertTrue(fixture.pendingRegions.isEmpty());
assertTrue(fixture.chunks.isEmpty());
verify(fixture.binding, times(2)).saveAndUnloadChunk(fixture.world, 2, 3);
}
}
@Test
public void completionDuringHeapWaitEvictsWithoutWaitingForRegionSubmission() throws Exception {
Fixture fixture = new Fixture();
fixture.chunks.clear();
AtomicInteger pendingChunks = new AtomicInteger(2);
fixture.pendingRegions.put(0L, pendingChunks);
try (SchedulingContext context = new SchedulingContext(fixture);
MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) {
pressure.when(MantleHeapPressure::overHighWater).thenReturn(true);
invoke(fixture.method, "onChunkCompleted", 2, 3, fixture.chunk);
assertEquals(1, pendingChunks.get());
assertSame(pendingChunks, fixture.pendingRegions.get(0L));
assertTrue(fixture.chunks.isEmpty());
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
fixture.method.onRegionSubmitted(0, 0);
assertTrue(fixture.pendingRegions.isEmpty());
}
}
@Test
public void heapPressureStartingAfterInitialCheckStillReclaimsCompletedChunks() throws Exception {
Fixture fixture = new Fixture();
fixture.pending.complete(null);
fixture.set("closing", new AtomicBoolean(true));
PregenMantleBackpressure backpressure = mock(PregenMantleBackpressure.class);
fixture.set("backpressure", backpressure);
doAnswer(invocation -> {
BooleanSupplier pressure = invocation.getArgument(0);
Runnable reclaim = invocation.getArgument(1);
assertTrue(pressure.getAsBoolean());
reclaim.run();
return null;
}).when(backpressure).awaitHeapHeadroom(any(BooleanSupplier.class), any(Runnable.class));
try (SchedulingContext context = new SchedulingContext(fixture);
MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) {
pressure.when(MantleHeapPressure::overHighWater).thenReturn(false, true);
fixture.method.generateChunk(2, 3, mock(PregenListener.class));
assertTrue(fixture.chunks.isEmpty());
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
pressure.verify(MantleHeapPressure::requestPanicReclaim);
}
}
@Test
public void emptyPressurePollDoesNotScheduleWorldIo() throws Exception {
Fixture fixture = new Fixture();
fixture.chunks.clear();
fixture.set("pendingEvictions", new ConcurrentLinkedQueue<CompletableFuture<Void>>());
try (SchedulingContext context = new SchedulingContext(fixture);
MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) {
invoke(fixture.method, "reclaimHeapPressure");
verifyNoInteractions(fixture.binding);
context.scheduling.verifyNoInteractions();
pressure.verify(MantleHeapPressure::requestPanicReclaim);
}
}
private static Object invoke(AsyncPregenMethod target, String name, Object... arguments) throws Exception {
Method method = arguments.length == 0
? AsyncPregenMethod.class.getDeclaredMethod(name)
: AsyncPregenMethod.class.getDeclaredMethod(name, long.class);
Class<?>[] parameterTypes = new Class<?>[arguments.length];
for (int index = 0; index < arguments.length; index++) {
parameterTypes[index] = arguments[index] instanceof Integer ? int.class
: arguments[index] instanceof Long ? long.class : Chunk.class;
}
Method method = AsyncPregenMethod.class.getDeclaredMethod(name, parameterTypes);
method.setAccessible(true);
try {
return method.invoke(target, arguments);
@@ -299,6 +451,7 @@ public class AsyncPregenServerSaveOwnershipTest {
private final INMSBinding binding = mock(INMSBinding.class);
private final ConcurrentHashMap<Long, Queue<Chunk>> chunks = new ConcurrentHashMap<>();
private final CompletableFuture<Void> pending = new CompletableFuture<>();
private final ConcurrentHashMap<Long, AtomicInteger> pendingRegions = new ConcurrentHashMap<>();
private Fixture() throws Exception {
Queue<Chunk> region = new ConcurrentLinkedQueue<>();
@@ -308,9 +461,8 @@ public class AsyncPregenServerSaveOwnershipTest {
evictions.add(pending);
set("world", world);
set("regionChunks", chunks);
set("regionPending", new ConcurrentHashMap<>());
set("regionPending", pendingRegions);
set("pendingEvictions", evictions);
set("evictedRegions", new KSet<>());
set("chunkIoExecutor", (Executor) Runnable::run);
when(chunk.getX()).thenReturn(2);
when(chunk.getZ()).thenReturn(3);
@@ -106,7 +106,7 @@ public class CachedPregenMethodCompletionTest {
}
@Test
public void pregenStartReachesTheSelectedMethodThroughEveryWrapper() throws Exception {
public void pregenLifecycleHooksReachTheSelectedMethodThroughEveryWrapper() throws Exception {
Constructor<AsyncOrMedievalPregenMethod> selectorConstructor = AsyncOrMedievalPregenMethod.class
.getDeclaredConstructor(PregeneratorMethod.class);
selectorConstructor.setAccessible(true);
@@ -119,6 +119,9 @@ public class CachedPregenMethodCompletionTest {
new PregenSavedChunkStatus((x, z) -> false)));
wrapped.onPregenStart(123, -456);
wrapped.reclaimMemory();
assertEquals(1, underlying.reclaimCalls.get());
assertEquals(1, underlying.pregenStartCalls.get());
assertEquals(123, underlying.centerBlockX.get());
@@ -179,6 +182,7 @@ public class CachedPregenMethodCompletionTest {
private final AtomicInteger generateChunkCalls = new AtomicInteger();
private final AtomicInteger generateRegionCalls = new AtomicInteger();
private final AtomicInteger pregenStartCalls = new AtomicInteger();
private final AtomicInteger reclaimCalls = new AtomicInteger();
private final AtomicInteger centerBlockX = new AtomicInteger();
private final AtomicInteger centerBlockZ = new AtomicInteger();
private final AtomicReference<PregenListener> capturedListener = new AtomicReference<>();
@@ -224,6 +228,11 @@ public class CachedPregenMethodCompletionTest {
this.centerBlockZ.set(centerBlockZ);
}
@Override
public void reclaimMemory() {
reclaimCalls.incrementAndGet();
}
@Override
public Mantle getMantle() {
return null;
@@ -1,13 +1,18 @@
package art.arcane.iris.world.pregen;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.scheduling.ChronoLatch;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.math.Position2;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.lang.reflect.Method;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
@@ -18,6 +23,12 @@ import java.util.concurrent.atomic.AtomicReference;
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.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisPregeneratorInitTest {
@Test
@@ -164,6 +175,43 @@ public class IrisPregeneratorInitTest {
}
}
@Test
public void outerHeapReclaimReleasesServerChunksBeforeMantleAndSurvivesChunkFailure() throws Exception {
for (boolean failReclaim : new boolean[]{false, true}) {
PregeneratorMethod generator = mock(PregeneratorMethod.class);
Mantle mantle = mock(Mantle.class);
when(generator.getMantle()).thenReturn(mantle);
IllegalStateException failure = new IllegalStateException("chunk reclaim failed");
if (failReclaim) {
doThrow(failure).when(generator).reclaimMemory();
}
ChronoLatch latch = mock(ChronoLatch.class);
when(latch.flip()).thenReturn(true);
IrisPregenerator pregenerator = mock(IrisPregenerator.class, CALLS_REAL_METHODS);
Field generatorField = IrisPregenerator.class.getDeclaredField("generator");
generatorField.setAccessible(true);
generatorField.set(pregenerator, generator);
Field latchField = IrisPregenerator.class.getDeclaredField("heapReclaimLatch");
latchField.setAccessible(true);
latchField.set(pregenerator, latch);
Method reclaim = IrisPregenerator.class.getDeclaredMethod("reclaimHeapPressure");
reclaim.setAccessible(true);
try (MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
reclaim.invoke(pregenerator);
InOrder order = inOrder(generator, mantle);
order.verify(generator).reclaimMemory();
order.verify(generator).getMantle();
order.verify(mantle).saveOldestIdleTectonicPlate();
pressure.verify(MantleHeapPressure::requestPanicReclaim);
if (failReclaim) {
logging.verify(() -> IrisLogging.reportError(
"Pregen could not reclaim completed server chunks under heap pressure.", failure));
}
}
}
}
private static final class TrackingPregeneratorMethod implements PregeneratorMethod {
private final AtomicInteger initCalls = new AtomicInteger();
private final AtomicInteger saveCalls = new AtomicInteger();
@@ -7,12 +7,15 @@ import org.junit.Test;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -47,27 +50,47 @@ public class PregenMantleBackpressureTest {
}
@Test
public void residencyAtTwiceThePlateCapIsNotTrimmed() {
public void residencyAtTheConfiguredPlateCapIsNotTrimmed() {
Mantle mantle = mock(Mantle.class);
when(mantle.getLoadedRegionCount()).thenReturn(8);
when(mantle.getLoadedRegionCount()).thenReturn(4);
backpressure(() -> mantle, 4, 1_000L, () -> {
}).enforceMantleBudget();
verify(mantle, never()).trim(0L);
verify(mantle, never()).unloadTectonicPlate(0);
verify(mantle, never()).saveOldestIdleTectonicPlate();
}
@Test
public void residencyOverTheHardCapIsTrimmedUntilItFits() {
public void residencyOverTheConfiguredPlateCapIsTrimmedUntilItFits() {
Mantle mantle = mock(Mantle.class);
when(mantle.getLoadedRegionCount()).thenReturn(20, 20, 3);
when(mantle.getLoadedRegionCount()).thenReturn(5, 5, 4);
backpressure(() -> mantle, 4, 1_000L, () -> {
}).enforceMantleBudget();
verify(mantle, times(1)).trim(0L);
verify(mantle, times(1)).unloadTectonicPlate(0);
verify(mantle, never()).saveOldestIdleTectonicPlate();
}
@Test
public void idleDirtyPlatesArePersistedUntilTheConfiguredCapIsMetEvenPastTheDeadline() {
Mantle mantle = mock(Mantle.class);
AtomicInteger resident = new AtomicInteger(7);
AtomicInteger timeouts = new AtomicInteger();
when(mantle.getLoadedRegionCount()).thenAnswer(invocation -> resident.get());
when(mantle.saveOldestIdleTectonicPlate()).thenAnswer(invocation -> {
resident.decrementAndGet();
return true;
});
backpressure(() -> mantle, 4, 0L, timeouts::incrementAndGet).enforceMantleBudget();
assertEquals(4, resident.get());
assertEquals(0, timeouts.get());
verify(mantle, times(3)).saveOldestIdleTectonicPlate();
}
@Test
@@ -84,7 +107,7 @@ public class PregenMantleBackpressureTest {
}
@Test
public void anExhaustedBudgetReportsTheTimeoutOnceAndProceeds() {
public void pinnedResidencyReportsTheTimeoutOnceAndAllowsTheDependencyWorkingSet() {
Mantle mantle = mock(Mantle.class);
when(mantle.getLoadedRegionCount()).thenReturn(20);
AtomicInteger timeouts = new AtomicInteger();
@@ -92,6 +115,7 @@ public class PregenMantleBackpressureTest {
backpressure(() -> mantle, 4, 0L, timeouts::incrementAndGet).enforceMantleBudget();
assertEquals(1, timeouts.get());
verify(mantle).saveOldestIdleTectonicPlate();
}
@Test
@@ -185,34 +209,91 @@ public class PregenMantleBackpressureTest {
}
@Test
public void pinnedPlatesStillRespectThePressureWaitDeadline() {
public void pinnedPlatesKeepWaitingPastTheDeadlineUntilHeapPressureClears() {
Mantle mantle = mock(Mantle.class);
when(mantle.getLoadedRegionCount()).thenReturn(9);
when(mantle.saveOldestIdleTectonicPlate()).thenReturn(false);
AtomicInteger collections = new AtomicInteger();
AtomicInteger timeouts = new AtomicInteger();
AtomicBoolean pressured = new AtomicBoolean(true);
backpressure(() -> mantle, 16, 0L, timeouts::incrementAndGet)
.awaitHeapHeadroom(() -> true, collections::incrementAndGet);
.awaitHeapHeadroom(pressured::get, () -> {
if (collections.incrementAndGet() == 3) {
pressured.set(false);
}
});
verify(mantle, times(1)).saveOldestIdleTectonicPlate();
verify(mantle, times(3)).saveOldestIdleTectonicPlate();
verify(mantle, never()).unloadTectonicPlate(0);
assertEquals(3, collections.get());
assertEquals(1, timeouts.get());
}
@Test
public void failedPressureEvictionKeepsWaitingForHeapHeadroom() {
Mantle mantle = mock(Mantle.class);
doThrow(new IllegalStateException("plate write failed")).when(mantle).saveOldestIdleTectonicPlate();
AtomicInteger collections = new AtomicInteger();
AtomicInteger timeouts = new AtomicInteger();
AtomicBoolean pressured = new AtomicBoolean(true);
backpressure(() -> mantle, 16, 0L, timeouts::incrementAndGet)
.awaitHeapHeadroom(pressured::get, () -> {
if (collections.incrementAndGet() == 3) {
pressured.set(false);
}
});
assertEquals(3, collections.get());
assertEquals(1, timeouts.get());
}
@Test
public void persistentHeapPressureCanBeCancelledAfterTheDeadline() {
AtomicBoolean cancelled = new AtomicBoolean();
AtomicInteger timeouts = new AtomicInteger();
AtomicInteger collections = new AtomicInteger();
PregenMantleBackpressure backpressure = new PregenMantleBackpressure(
() -> null, 16, 0, 0L, () -> {
timeouts.incrementAndGet();
cancelled.set(true);
}, () -> "cancelled", cancelled::get);
backpressure.awaitHeapHeadroom(() -> true, collections::incrementAndGet);
assertEquals(1, collections.get());
assertEquals(1, timeouts.get());
}
@Test
public void failedPressureEvictionStillAttemptsGcAndRespectsTheDeadline() {
Mantle mantle = mock(Mantle.class);
doThrow(new IllegalStateException("plate write failed")).when(mantle).saveOldestIdleTectonicPlate();
AtomicInteger collections = new AtomicInteger();
public void persistentHeapPressureWaitsAndRetainsInterruptionAfterTheDeadline() throws Exception {
CountDownLatch warned = new CountDownLatch(1);
AtomicInteger timeouts = new AtomicInteger();
backpressure(() -> mantle, 16, 0L, timeouts::incrementAndGet)
.awaitHeapHeadroom(() -> true, collections::incrementAndGet);
assertEquals(1, collections.get());
assertEquals(1, timeouts.get());
AtomicInteger collections = new AtomicInteger();
AtomicBoolean interrupted = new AtomicBoolean();
PregenMantleBackpressure backpressure = new PregenMantleBackpressure(
() -> null, 16, 600_000, 0L, () -> {
timeouts.incrementAndGet();
warned.countDown();
}, () -> "interrupted");
Thread waiter = new Thread(() -> {
backpressure.awaitHeapHeadroom(() -> true, collections::incrementAndGet);
interrupted.set(Thread.currentThread().isInterrupted());
}, "pregen-heap-pressure-waiter");
waiter.start();
try {
assertTrue(warned.await(5L, TimeUnit.SECONDS));
Await.until("heap pressure waiter to park", () -> waiter.getState() == Thread.State.TIMED_WAITING);
assertTrue(waiter.isAlive());
assertEquals(1, collections.get());
assertEquals(1, timeouts.get());
} finally {
waiter.interrupt();
waiter.join(5_000L);
}
assertFalse(waiter.isAlive());
assertTrue(interrupted.get());
}
@Test
@@ -53,7 +53,6 @@ public class PregenSilentFailureContractTest {
String async = read("art/arcane/iris/world/pregen/AsyncPregenMethod.java");
assertTrue(method(async, "private int resolveWorkerPoolThreads()").contains("PregenDiagnostics.probeFailed("));
assertTrue(method(async, "private int evictionWindow()").contains("PregenDiagnostics.probeFailed("));
assertTrue(method(async, "private Engine resolveMetricsEngine()").contains("PregenDiagnostics.probeFailed("));
}