From d196ffe8533ccdad6443d56e7759701e63944d35 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sat, 15 Aug 2026 12:57:10 -0400 Subject: [PATCH] stabilize pregeneration --- .../iris/core/nms/v26_2_R1/NMSBinding.java | 12 +- ...dingCurrentPaperWorldDataContractTest.java | 7 + core/build.gradle | 3 + .../lifecycle/WorldReplacementFilesystem.java | 34 +- .../art/arcane/iris/core/loader/IrisData.java | 22 +- .../iris/core/loader/ResourceLoader.java | 118 +++++- .../PregenPerformanceProfile.java | 47 ++- .../methods/AsyncPregenMethod.java | 102 ++--- .../iris/core/service/EngineMaintenance.java | 14 +- .../iris/core/tools/IrisPackBenchmarking.java | 1 + .../arcane/iris/core/tools/IrisToolbelt.java | 22 ++ .../engine/data/cache/LazyBoundedCache.java | 52 +++ .../arcane/iris/engine/object/IrisBiome.java | 7 +- .../engine/object/IrisExpressionLoad.java | 13 +- .../engine/object/IrisGeneratorStyle.java | 8 +- .../engine/object/IrisObjectPlacement.java | 8 +- .../iris/engine/object/IrisObjectScale.java | 353 ++++++++++++++++-- .../engine/platform/BukkitChunkGenerator.java | 102 +++++ .../platform/PlatformChunkGenerator.java | 19 + .../iris/util/common/data/VectorMap.java | 31 +- .../stream/utility/CachedStream2D.java | 6 + .../WorldReplacementBootstrapTest.java | 84 +++++ .../WorldReplacementFilesystemTest.java | 40 +- .../loader/ResourceLoaderPrefetchTest.java | 247 ++++++++++++ .../iris/core/pack/PackDownloaderTest.java | 82 ++++ .../PregenPerformanceProfileTest.java | 115 ++++++ .../AsyncPregenMethodConcurrencyCapTest.java | 41 +- .../core/service/EngineMaintenanceTest.java | 8 + ...PackBenchmarkingLifecycleContractTest.java | 62 +++ .../data/cache/LazyBoundedCacheTest.java | 139 +++++++ .../object/IrisObjectScaleCacheTest.java | 314 ++++++++++++++++ ...ChunkGeneratorGenerationStageGateTest.java | 69 ++++ 32 files changed, 2013 insertions(+), 169 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java create mode 100644 core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderPrefetchTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/tools/IrisPackBenchmarkingLifecycleContractTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/data/cache/LazyBoundedCacheTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectScaleCacheTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index 021381f75..7b2837e52 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -1671,7 +1671,7 @@ public class NMSBinding implements INMSBinding { MinecraftServer server ) throws IOException { if (craftServer.isGlobalTickThread()) { - return createCurrentPaperLevelOverrides(server); + return createCurrentPaperLevelOverrides(craftServer, server); } if (J.isFolia() && J.isPrimaryThread()) { throw new IOException("Current Paper world data cannot be staged from a Folia region tick thread."); @@ -1680,7 +1680,7 @@ public class NMSBinding implements INMSBinding { CompletableFuture captured = new CompletableFuture<>(); boolean scheduled = J.runGlobal(() -> { try { - captured.complete(createCurrentPaperLevelOverrides(server)); + captured.complete(createCurrentPaperLevelOverrides(craftServer, server)); } catch (Throwable failure) { captured.completeExceptionally(failure); } @@ -1701,7 +1701,13 @@ public class NMSBinding implements INMSBinding { } } - private PaperLevelOverrides createCurrentPaperLevelOverrides(MinecraftServer server) throws IOException { + private PaperLevelOverrides createCurrentPaperLevelOverrides( + CraftServer craftServer, + MinecraftServer server + ) throws IOException { + if (!craftServer.isGlobalTickThread()) { + throw new IOException("Current Paper level data must be captured on the global tick thread."); + } if (!(server.getWorldData().overworldData() instanceof PrimaryLevelData primaryLevelData)) { throw new IOException("Paper primary level data is unavailable for current world data staging."); } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java index ed7347627..09dfdbec6 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java @@ -59,12 +59,19 @@ public class NMSBindingCurrentPaperWorldDataContractTest { assertTrue(capture.contains("craftServer.isGlobalTickThread()")); assertTrue(capture.contains("J.isFolia() && J.isPrimaryThread()")); assertTrue(capture.contains("J.runGlobal(")); + assertTrue(capture.contains("createCurrentPaperLevelOverrides(craftServer, server)")); assertTrue(capture.contains("captured.get(CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS")); assertTrue(capture.contains("Thread.currentThread().interrupt()")); + assertTrue(create.contains("if (!craftServer.isGlobalTickThread())")); + assertTrue(create.indexOf("if (!craftServer.isGlobalTickThread())") + < create.indexOf("server.getWorldData().overworldData()")); assertTrue(create.contains("PaperLevelOverrides.createFromLiveLevelData(primaryLevelData)")); assertFalse(capture.contains("WorldReplacementSeed")); assertFalse(capture.contains("SavedDataStorage")); assertFalse(capture.contains("Files.")); + assertFalse(create.contains("WorldReplacementSeed")); + assertFalse(create.contains("SavedDataStorage")); + assertFalse(create.contains("Files.")); } @Test diff --git a/core/build.gradle b/core/build.gradle index ec73dcbb6..9f2a2eb09 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -121,6 +121,9 @@ dependencies { tasks.named('test').configure { maxHeapSize = '1g' + systemProperty('iris.packBenchmarkingSource', file('src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java').absolutePath) + systemProperty('iris.irisToolbeltSource', file('src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java').absolutePath) + systemProperty('iris.bukkitChunkGeneratorSource', file('src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath) } java { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java index 1f84f0394..bf1ecd15b 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java @@ -6,7 +6,6 @@ import art.arcane.iris.core.pack.PackDirectoryResolver; import java.io.IOException; import java.io.InputStream; -import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; @@ -247,27 +246,29 @@ public final class WorldReplacementFilesystem { Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); requireDirectory(root, "pack root"); MessageDigest digest = sha256(); - List files; + List entries; try (Stream stream = Files.walk(root)) { - files = stream + entries = stream .filter(path -> !path.equals(root)) - .sorted(Comparator.comparing(path -> root.relativize(path).toString())) + .map(path -> fingerprintEntry(root, path)) + .sorted(Comparator.comparing(FingerprintEntry::relativeString)) .toList(); } - for (Path file : files) { - BasicFileAttributes attributes = requireSafeEntry(file); - Path relative = root.relativize(file); + String separator = root.getFileSystem().getSeparator(); + byte[] buffer = new byte[8192]; + for (FingerprintEntry entry : entries) { + BasicFileAttributes attributes = requireSafeEntry(entry.path()); + Path relative = entry.relative(); if (isGeneratedPackMetadata(relative, attributes)) { continue; } - update(digest, relative.toString().replace(file.getFileSystem().getSeparator(), "/")); + update(digest, entry.relativeString().replace(separator, "/")); digest.update((byte) (attributes.isDirectory() ? 1 : 0)); if (!attributes.isRegularFile()) { continue; } update(digest, Long.toString(attributes.size())); - try (InputStream input = Files.newInputStream(file)) { - byte[] buffer = new byte[8192]; + try (InputStream input = Files.newInputStream(entry.path())) { int read; while ((read = input.read(buffer)) >= 0) { digest.update(buffer, 0, read); @@ -277,6 +278,11 @@ public final class WorldReplacementFilesystem { return HexFormat.of().formatHex(digest.digest()); } + private static FingerprintEntry fingerprintEntry(Path root, Path path) { + Path relative = root.relativize(path); + return new FingerprintEntry(path, relative, relative.toString()); + } + private static State inspect(ReplacementPaths paths) throws IOException { return new State( directoryPresent(paths.target(), "replacement target"), @@ -405,7 +411,10 @@ public final class WorldReplacementFilesystem { private static void update(MessageDigest digest, String value) { byte[] bytes = value.getBytes(StandardCharsets.UTF_8); - digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + digest.update((byte) (bytes.length >>> 24)); + digest.update((byte) (bytes.length >>> 16)); + digest.update((byte) (bytes.length >>> 8)); + digest.update((byte) bytes.length); digest.update(bytes); } @@ -474,4 +483,7 @@ public final class WorldReplacementFilesystem { private record State(boolean targetPresent, boolean stagePresent, boolean backupPresent) { } + + private record FingerprintEntry(Path path, Path relative, String relativeString) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 68a0283d0..884004336 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -463,7 +463,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { public synchronized void hotloaded() { StructureGraphCatalog.invalidate(this); - IrisObjectScale.invalidate(); + IrisObjectScale.invalidate(this); closed = false; possibleSnippets = new KMap<>(); builder = new GsonBuilder() @@ -529,7 +529,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { public void dump() { StructureGraphCatalog.invalidate(this); - IrisObjectScale.invalidate(); + IrisObjectScale.invalidate(this); for (ResourceLoader i : loaders.values()) { i.clearCache(); } @@ -763,19 +763,13 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public void loadPrefetch(Engine engine) { - BurstExecutor b = MultiBurst.ioBurst.burst(loaders.size()); - - for (ResourceLoader i : loaders.values()) { - b.queue(() -> { - try { - i.loadFirstAccess(engine); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); + for (ResourceLoader loader : loaders.values()) { + try { + loader.loadFirstAccess(engine); + } catch (IOException exception) { + throw new RuntimeException(exception); + } } - - b.complete(); IrisLogging.debug("Loaded Prefetch Cache to reduce generation disk use."); } } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java index 5a51acf80..bc8838c99 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java @@ -52,12 +52,16 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; +import java.util.HexFormat; import java.util.Locale; import java.util.Objects; import java.util.Set; @@ -79,6 +83,11 @@ import java.util.zip.GZIPOutputStream; public class ResourceLoader implements MeteredCache { public static final AtomicDouble tlt = new AtomicDouble(0); private static final int CACHE_SIZE = 100000; + private static final int PREFETCH_MAGIC = 0x49504632; + private static final int PREFETCH_FORMAT_VERSION = 1; + private static final int PREFETCH_ENTRY_LIMIT = 1_024; + private static final int PREFETCH_KEY_LENGTH_LIMIT = 1_024; + private static final long PREFETCH_FILE_SIZE_LIMIT = 4L * 1_024L * 1_024L; private static volatile ExecutorService schemaBuildExecutor; private static final Set schemaBuildQueue = ConcurrentHashMap.newKeySet(); protected final AtomicCache> folderCache; @@ -93,6 +102,7 @@ public class ResourceLoader implements MeteredCache { protected IrisData manager; protected AtomicInteger loads; protected ChronoLatch sec; + private volatile boolean firstAccessOverflowed; private final Options options; public ResourceLoader( @@ -106,6 +116,7 @@ public class ResourceLoader implements MeteredCache { this.options = Objects.requireNonNull(options, "options"); this.manager = manager; firstAccess = new KSet<>(); + firstAccessOverflowed = false; folderCache = new AtomicCache<>(); sec = new ChronoLatch(5000); loads = new AtomicInteger(); @@ -499,13 +510,28 @@ public class ResourceLoader implements MeteredCache { KSet set = firstAccess; // contains-first: CHM.add takes the bin monitor even when the key is present, and // every generation thread hammers the same few keys through this line. - if (set != null && !set.contains(name)) set.add(name); + if (set != null && !set.contains(name)) { + if (set.size() < prefetchEntryLimit()) { + set.add(name); + } else { + firstAccessOverflowed = true; + } + } return loadCache.get(name); } private File prefetchFile(Engine engine) { - String id = "DIM" + Math.abs(engine.getSeedManager().getSeed() + engine.getDimension().getVersion() + engine.getDimension().getLoadKey().hashCode()); - return IrisPlatforms.get().dataFile("prefetch/" + id + "/" + Math.abs(getFolderName().hashCode()) + ".ipfch"); + String dimensionIdentity = digestIdentity( + root.toPath().toAbsolutePath().normalize().toString(), + Long.toString(engine.getSeedManager().getSeed()), + Integer.toString(engine.getDimension().getVersion()), + Objects.requireNonNullElse(engine.getDimension().getLoadKey(), "")); + String loaderIdentity = digestIdentity(getFolderName()); + return IrisPlatforms.get().dataFile( + "prefetch", + "v2", + dimensionIdentity, + loaderIdentity + ".ipfch"); } public void loadFirstAccess(Engine engine) throws IOException { @@ -515,33 +541,43 @@ public class ResourceLoader implements MeteredCache { return; } - KList s = new KList<>(); + if (file.length() > PREFETCH_FILE_SIZE_LIMIT) { + discardPrefetch(file, "file exceeds " + PREFETCH_FILE_SIZE_LIMIT + " bytes"); + return; + } + + KList entries = new KList<>(); try (FileInputStream fin = new FileInputStream(file); GZIPInputStream gzi = new GZIPInputStream(fin); DataInputStream din = new DataInputStream(gzi)) { - int m = din.readInt(); + int magic = din.readInt(); + int version = din.readInt(); + int count = din.readInt(); - if (m < 0) { - throw new IOException("Bad prefetch count " + m); + if (magic != PREFETCH_MAGIC || version != PREFETCH_FORMAT_VERSION) { + throw new IOException("unsupported prefetch format"); } - - for (int i = 0; i < m; i++) { - s.add(din.readUTF()); + if (count < 0 || count > prefetchEntryLimit()) { + throw new IOException("bad prefetch count " + count); + } + for (int index = 0; index < count; index++) { + String key = din.readUTF(); + if (key.isBlank() || key.length() > PREFETCH_KEY_LENGTH_LIMIT) { + throw new IOException("invalid prefetch key length " + key.length()); + } + entries.add(key); } } catch (IOException e) { - IrisLogging.warn("Discarding corrupt prefetch " + file.getPath() + ": " + e.getMessage()); - - if (!file.delete()) { - IrisLogging.warn("Couldn't delete corrupt prefetch " + file.getPath()); - } - + discardPrefetch(file, e.getMessage()); return; } - IrisLogging.info("Loading " + s.size() + " prefetch " + getFolderName()); + IrisLogging.info("Loading " + entries.size() + " prefetch " + getFolderName()); firstAccess = null; - loadAllParallel(s); + for (String entry : entries) { + load(entry); + } } public void saveFirstAccess(Engine engine) throws IOException { @@ -549,6 +585,18 @@ public class ResourceLoader implements MeteredCache { if (set == null) return; KList snapshot = new KList<>(set); File file = prefetchFile(engine); + if (firstAccessOverflowed || snapshot.size() > prefetchEntryLimit()) { + Files.deleteIfExists(file.toPath()); + firstAccess = null; + return; + } + for (String key : snapshot) { + if (key == null || key.isBlank() || key.length() > PREFETCH_KEY_LENGTH_LIMIT) { + Files.deleteIfExists(file.toPath()); + firstAccess = null; + return; + } + } File parent = file.getParentFile(); if (parent == null) { @@ -565,10 +613,12 @@ public class ResourceLoader implements MeteredCache { try (FileOutputStream fos = new FileOutputStream(temp); GZIPOutputStream gzo = new CustomOutputStream(fos, 9); DataOutputStream dos = new DataOutputStream(gzo)) { + dos.writeInt(PREFETCH_MAGIC); + dos.writeInt(PREFETCH_FORMAT_VERSION); dos.writeInt(snapshot.size()); - for (String i : snapshot) { - dos.writeUTF(i); + for (String key : snapshot) { + dos.writeUTF(key); } } @@ -584,6 +634,34 @@ public class ResourceLoader implements MeteredCache { firstAccess = null; } + private int prefetchEntryLimit() { + return (int) Math.min(loadCache.getMaxSize(), PREFETCH_ENTRY_LIMIT); + } + + private void discardPrefetch(File file, String reason) { + IrisLogging.warn("Discarding corrupt prefetch " + file.getPath() + ": " + reason); + if (!file.delete()) { + IrisLogging.warn("Couldn't delete corrupt prefetch " + file.getPath()); + } + } + + private static String digestIdentity(String... components) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (String component : components) { + byte[] value = Objects.requireNonNullElse(component, "").getBytes(StandardCharsets.UTF_8); + digest.update((byte) (value.length >>> 24)); + digest.update((byte) (value.length >>> 16)); + digest.update((byte) (value.length >>> 8)); + digest.update((byte) value.length); + digest.update(value); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + public KList getFolders() { return folderCache.aquire(() -> { KList fc = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java index 60393424d..a16cea5c2 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java @@ -20,11 +20,19 @@ package art.arcane.iris.core.pregenerator; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.utility.CachedStream2D; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public final class PregenPerformanceProfile { + private static final long HOTLOAD_ACQUISITION_TIMEOUT_SECONDS = 30L; private static final AtomicBoolean JVM_HINT_LOGGED = new AtomicBoolean(false); private PregenPerformanceProfile() { @@ -55,10 +63,43 @@ public final class PregenPerformanceProfile { } public static void apply(Engine engine) { - boolean changed = apply(); - if (changed && engine != null) { + apply(); + if (requiresNoiseCacheRefresh(engine)) { engine.hotloadComplex(); - IrisLogging.info("Pregen profile applied: noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize() + " iris.cache.fast=" + Boolean.getBoolean("iris.cache.fast")); + logApplied(); } } + + public static void applyToGenerator(PlatformChunkGenerator generator) { + apply(); + Engine engine = generator == null ? null : generator.getEngine(); + if (requiresNoiseCacheRefresh(engine)) { + generator.hotloadComplexAsync(HOTLOAD_ACQUISITION_TIMEOUT_SECONDS, TimeUnit.SECONDS).join(); + logApplied(); + } + } + + private static boolean requiresNoiseCacheRefresh(Engine engine) { + if (engine == null) { + return false; + } + IrisComplex complex = engine.getComplex(); + if (complex == null) { + return true; + } + ProceduralStream heightStream = complex.getHeightStream(); + if (!(heightStream instanceof MeteredCache cache)) { + return true; + } + ProceduralStream caveBiomeStream = complex.getCaveBiomeStream(); + if (!(caveBiomeStream instanceof CachedStream2D cachedStream) || !cachedStream.usesFastCache()) { + return true; + } + long configuredMaxSize = (long) IrisSettings.get().getPerformance().getNoiseCacheSize() * 256L; + return cache.getMaxSize() != configuredMaxSize; + } + + private static void logApplied() { + IrisLogging.info("Pregen profile applied: noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize() + " iris.cache.fast=" + Boolean.getBoolean("iris.cache.fast")); + } } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 7995826e8..e4c3090e0 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -48,6 +48,7 @@ import java.util.Queue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; @@ -62,7 +63,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { // land minutes after its replacement started) from shrinking the pool under the live job. private static final AtomicInteger THREAD_COUNT = new AtomicInteger(); private static final AtomicInteger BOOST_HOLDERS = new AtomicInteger(); - private static final int ADAPTIVE_TIMEOUT_STEP = 3; + private static final int ADAPTIVE_SLOW_REQUEST_STEP = 3; private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L; private static final long FLUSH_TIMEOUT_SECONDS = 120L; @@ -78,11 +79,12 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final Method directChunkAtAsyncUrgentMethod; private final Method directChunkAtAsyncMethod; private final String chunkAccessMode; - private final Executor executor; + private final ChunkRequestExecutor executor; + private final Executor slowRequestExecutor; private final Semaphore semaphore; private final int threads; - private final int timeoutSeconds; - private final int timeoutWarnIntervalMs; + private final int slowRequestWarningSeconds; + private final int slowRequestWarnIntervalMs; private final boolean urgent; private final ConcurrentHashMap regionPending; private final ConcurrentHashMap> regionChunks; @@ -95,10 +97,10 @@ public class AsyncPregenMethod implements PregeneratorMethod { private volatile int boundsMaxRegionZ; private final AtomicInteger adaptiveInFlightLimit; private final int adaptiveMinInFlightLimit; - private final AtomicInteger timeoutStreak = new AtomicInteger(); - private final AtomicLong lastTimeoutLogAt = new AtomicLong(0L); + private final AtomicInteger slowRequestStreak = new AtomicInteger(); + private final AtomicLong lastSlowRequestLogAt = new AtomicLong(0L); private final AtomicLong lastFailedReleaseLogAt = new AtomicLong(0L); - private final AtomicInteger suppressedTimeoutLogs = new AtomicInteger(); + private final AtomicInteger suppressedSlowRequestLogs = new AtomicInteger(); private final AtomicLong lastAdaptiveLogAt = new AtomicLong(0L); private final AtomicInteger inFlight = new AtomicInteger(); private final AtomicLong submitted = new AtomicLong(); @@ -158,8 +160,9 @@ public class AsyncPregenMethod implements PregeneratorMethod { this.effectiveWorkerThreads = workerThreadsForCap; this.recommendedRuntimeConcurrencyCap = configuredThreads; this.semaphore = new Semaphore(this.threads, true); - this.timeoutSeconds = pregen.getChunkLoadTimeoutSeconds(); - this.timeoutWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); + this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds(); + this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS); + this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); this.urgent = false; this.regionPending = new ConcurrentHashMap<>(); this.regionChunks = new ConcurrentHashMap<>(); @@ -266,8 +269,8 @@ public class AsyncPregenMethod implements PregeneratorMethod { long now = M.ms(); long last = lastFailedReleaseLogAt.get(); - if (now - last >= timeoutWarnIntervalMs && lastFailedReleaseLogAt.compareAndSet(last, now)) { - IrisLogging.warn("Released region slot for failed or timed out chunk at " + x + "," + z + ". " + metricsSnapshot()); + if (now - last >= slowRequestWarnIntervalMs && lastFailedReleaseLogAt.compareAndSet(last, now)) { + IrisLogging.warn("Released region slot for failed chunk at " + x + "," + z + ". " + metricsSnapshot()); } } @@ -457,16 +460,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private Chunk onChunkFutureFailure(int x, int z, Throwable throwable) { try { - Throwable root = throwable; - while (root.getCause() != null) { - root = root.getCause(); - } - - if (root instanceof TimeoutException) { - onTimeout(x, z); - } else { - IrisLogging.warn("Failed async pregen chunk load at " + x + "," + z + ". " + metricsSnapshot()); - } + IrisLogging.warn("Failed async pregen chunk load at " + x + "," + z + ". " + metricsSnapshot()); IrisLogging.reportError(throwable); } catch (Throwable e) { @@ -505,32 +499,36 @@ public class AsyncPregenMethod implements PregeneratorMethod { } } - private void onTimeout(int x, int z) { - int streak = timeoutStreak.incrementAndGet(); - if (streak % ADAPTIVE_TIMEOUT_STEP == 0) { + private void onSlowRequest(int x, int z) { + if (closing.get()) { + return; + } + + int streak = slowRequestStreak.incrementAndGet(); + if (streak % ADAPTIVE_SLOW_REQUEST_STEP == 0) { lowerAdaptiveInFlightLimit(); } long now = M.ms(); - long last = lastTimeoutLogAt.get(); - if (now - last < timeoutWarnIntervalMs || !lastTimeoutLogAt.compareAndSet(last, now)) { - suppressedTimeoutLogs.incrementAndGet(); + long last = lastSlowRequestLogAt.get(); + if (now - last < slowRequestWarnIntervalMs || !lastSlowRequestLogAt.compareAndSet(last, now)) { + suppressedSlowRequestLogs.incrementAndGet(); return; } - int suppressed = suppressedTimeoutLogs.getAndSet(0); + int suppressed = suppressedSlowRequestLogs.getAndSet(0); String suppressedText = suppressed <= 0 ? "" : " suppressed=" + suppressed; - IrisLogging.warn("Timed out async pregen chunk load at " + x + "," + z - + " after " + timeoutSeconds + "s." + IrisLogging.warn("Async pregen chunk load at " + x + "," + z + + " is still pending after " + slowRequestWarningSeconds + "s." + " adaptiveLimit=" + adaptiveInFlightLimit.get() + suppressedText + " " + metricsSnapshot()); } private void onSuccess() { - int streak = timeoutStreak.get(); + int streak = slowRequestStreak.get(); if (streak > 0) { int newStreak = Math.max(0, streak - 2); - timeoutStreak.compareAndSet(streak, newStreak); + slowRequestStreak.compareAndSet(streak, newStreak); if (newStreak > 0) { return; } @@ -606,14 +604,23 @@ public class AsyncPregenMethod implements PregeneratorMethod { } static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) { - int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads); if (detectedWorkerPoolThreads > 0) { - return Math.max(detectedWorkerPoolThreads, provisionedWorkerThreads); + return detectedWorkerPoolThreads; } + int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads); return Math.max(provisionedWorkerThreads, detectedCpuThreads); } + static CompletableFuture observeSlowRequest(CompletableFuture future, Executor executor, Runnable onSlowRequest) { + executor.execute(() -> { + if (!future.isDone()) { + onSlowRequest.run(); + } + }); + return future; + } + static int computeFoliaRecommendedCap(int workerThreads) { int normalizedWorkers = Math.max(1, workerThreads); int recommendedCap = normalizedWorkers * 8; @@ -742,7 +749,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { + ", effectiveWorkerThreads=" + effectiveWorkerThreads + ", recommendedCap=" + recommendedRuntimeConcurrencyCap + ", urgent=" + urgent - + ", timeout=" + timeoutSeconds + "s"); + + ", slowWarning=" + slowRequestWarningSeconds + "s"); if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) { acquireWorkerThreadBoost(); } @@ -892,6 +899,10 @@ public class AsyncPregenMethod implements PregeneratorMethod { return CompletableFuture.failedFuture(new IllegalStateException("Failed to request async chunk " + x + "," + z + " in world " + world.getName(), failure)); } + private CompletableFuture requestMonitoredChunkAsync(int x, int z) { + return observeSlowRequest(requestChunkAsync(x, z), slowRequestExecutor, () -> onSlowRequest(x, z)); + } + @SuppressWarnings("unchecked") private CompletableFuture invokeChunkFuture(Method method, int x, int z, boolean generate, boolean urgentRequest) throws Throwable { Object result; @@ -1016,17 +1027,16 @@ public class AsyncPregenMethod implements PregeneratorMethod { }); } - private interface Executor { + private interface ChunkRequestExecutor { void generate(int x, int z, PregenListener listener); default void shutdown() {} } - private class FoliaRegionExecutor implements Executor { + private class FoliaRegionExecutor implements ChunkRequestExecutor { @Override public void generate(int x, int z, PregenListener listener) { try { - requestChunkAsync(x, z) - .orTimeout(timeoutSeconds, TimeUnit.SECONDS) + requestMonitoredChunkAsync(x, z) .whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable)); return; } catch (Throwable ignored) { @@ -1034,8 +1044,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { Runnable regionTask = () -> { try { - requestChunkAsync(x, z) - .orTimeout(timeoutSeconds, TimeUnit.SECONDS) + requestMonitoredChunkAsync(x, z) .whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable)); } catch (Throwable e) { completeChunk(x, z, listener, null, e); @@ -1049,16 +1058,14 @@ public class AsyncPregenMethod implements PregeneratorMethod { } } - private class ServiceExecutor implements Executor { + private class ServiceExecutor implements ChunkRequestExecutor { private final ExecutorService service = new MultiBurst("Iris Async Pregen"); public void generate(int x, int z, PregenListener listener) { try { service.submit(() -> { try { - Chunk i = requestChunkAsync(x, z) - .orTimeout(timeoutSeconds, TimeUnit.SECONDS) - .get(); + Chunk i = requestMonitoredChunkAsync(x, z).get(); completeChunk(x, z, listener, i, null); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -1078,12 +1085,11 @@ public class AsyncPregenMethod implements PregeneratorMethod { } } - private class TicketExecutor implements Executor { + private class TicketExecutor implements ChunkRequestExecutor { @Override public void generate(int x, int z, PregenListener listener) { try { - requestChunkAsync(x, z) - .orTimeout(timeoutSeconds, TimeUnit.SECONDS) + requestMonitoredChunkAsync(x, z) .whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable)); } catch (Throwable e) { completeChunk(x, z, listener, null, e); diff --git a/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java b/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java index 913b9c591..88530b8eb 100644 --- a/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java +++ b/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java @@ -31,8 +31,10 @@ public final class EngineMaintenance { } public static boolean shouldRun(Engine engine) { - if (engine.isStudio() - && !IrisSettings.get().getPerformance().isTrimMantleInStudio()) { + if (!shouldRunStudioMaintenance( + engine.isStudio(), + IrisSettings.get().getPerformance().isTrimMantleInStudio(), + engine.isStudio() && MantleHeapPressure.overHighWater())) { return false; } if (GoldenHashEngine.isActive()) { @@ -43,6 +45,14 @@ public final class EngineMaintenance { || !WorldMaintenance.isWorldMaintenanceActive(engine.getWorld().identity()); } + static boolean shouldRunStudioMaintenance( + boolean studio, + boolean trimMantleInStudio, + boolean heapPressure + ) { + return !studio || trimMantleInStudio || heapPressure; + } + public static int workerParallelism() { return IrisSettings.get().getPerformance().getEngineSVC().getParallelism(); } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java b/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java index fb743b72a..10b801b74 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java @@ -51,6 +51,7 @@ public class IrisPackBenchmarking { .name("PackBenchmarking") .start(() -> { IrisLogging.info("Setting up benchmark environment "); + IrisToolbelt.applyPregenPerformanceProfile(); IO.delete(IrisWorldStorage.dimensionRoot("benchmark")); createBenchmark(); while (!IrisToolbelt.isIrisWorld(benchmarkWorld())) { diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index 0145aa097..20b351d12 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -338,9 +338,31 @@ public class IrisToolbelt { } public static void applyPregenPerformanceProfile(Engine engine) { + PlatformChunkGenerator generator = resolvePlatformGenerator(engine); + if (generator != null) { + PregenPerformanceProfile.applyToGenerator(generator); + return; + } PregenPerformanceProfile.apply(engine); } + private static PlatformChunkGenerator resolvePlatformGenerator(Engine engine) { + if (engine == null) { + return null; + } + World world = BukkitWorldBinding.world(engine.getWorld()); + if (world == null) { + return null; + } + if (!(world.getGenerator() instanceof PlatformChunkGenerator generator)) { + throw new IllegalStateException("Live Iris engine is not bound to its platform chunk generator."); + } + if (generator.getEngine() != engine) { + throw new IllegalStateException("Live Iris engine does not match its platform chunk generator runtime."); + } + return generator; + } + public static boolean supportsStrictSerialPregeneration() { return PaperLib.isPaper(); } diff --git a/core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java b/core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java new file mode 100644 index 000000000..4e1a0bbe2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java @@ -0,0 +1,52 @@ +package art.arcane.iris.engine.data.cache; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Objects; +import java.util.function.Function; + +public final class LazyBoundedCache { + private final int maximumSize; + private transient LinkedHashMap entries; + + public LazyBoundedCache(int maximumSize) { + if (maximumSize <= 0) { + throw new IllegalArgumentException("Maximum cache size must be positive"); + } + this.maximumSize = maximumSize; + } + + public synchronized V computeIfAbsent(K key, Function mappingFunction) { + K requiredKey = Objects.requireNonNull(key, "Cache key"); + Function requiredMapping = Objects.requireNonNull(mappingFunction, "Cache mapping function"); + if (entries != null) { + V cached = entries.get(requiredKey); + if (cached != null) { + return cached; + } + } + + V computed = requiredMapping.apply(requiredKey); + if (computed == null) { + return null; + } + if (entries == null) { + entries = new LinkedHashMap<>(maximumSize, 1F, true); + } + entries.put(requiredKey, computed); + if (entries.size() > maximumSize) { + Iterator iterator = entries.keySet().iterator(); + iterator.next(); + iterator.remove(); + } + return computed; + } + + synchronized boolean isInitialized() { + return entries != null; + } + + synchronized int size() { + return entries == null ? 0 : entries.size(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 9b2105248..12dbdccfc 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.data.cache.LazyBoundedCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.DependsOn; @@ -39,7 +40,6 @@ import art.arcane.iris.util.common.data.DataProvider; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.context.IrisContext; -import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import lombok.AccessLevel; import lombok.Data; import lombok.EqualsAndHashCode; @@ -81,9 +81,8 @@ public class IrisBiome extends IrisRegistrant implements IRare { private final transient AtomicCache cacheColorDepositLoad = new AtomicCache<>(); private final transient AtomicCache childrenCell = new AtomicCache<>(); @Getter(AccessLevel.NONE) - private final transient ConcurrentLinkedHashMap biomeGenerators = new ConcurrentLinkedHashMap.Builder() - .maximumWeightedCapacity(BIOME_GENERATOR_CACHE_SIZE) - .build(); + private final transient LazyBoundedCache biomeGenerators = + new LazyBoundedCache<>(BIOME_GENERATOR_CACHE_SIZE); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private transient volatile SeededBiomeGenerator recentBiomeGenerator; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java b/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java index a75c8e24a..769b4cb34 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.data.cache.LazyBoundedCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.Required; @@ -27,7 +28,6 @@ import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.stream.ProceduralStream; -import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; @@ -73,10 +73,8 @@ public class IrisExpressionLoad { Collections.synchronizedMap(new IdentityHashMap<>()); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) - private transient final ConcurrentLinkedHashMap standaloneStyleCache = - new ConcurrentLinkedHashMap.Builder() - .maximumWeightedCapacity(STYLE_CACHE_SIZE) - .build(); + private transient final LazyBoundedCache standaloneStyleCache = + new LazyBoundedCache<>(STYLE_CACHE_SIZE); public double getValue(RNG rng, IrisData data, double x, double z) { if (engineValue != null) { @@ -155,10 +153,7 @@ public class IrisExpressionLoad { private static final class EngineCache { private final AtomicCache> stream = new AtomicCache<>(); private final AtomicCache value = new AtomicCache<>(); - private final ConcurrentLinkedHashMap styles = - new ConcurrentLinkedHashMap.Builder() - .maximumWeightedCapacity(STYLE_CACHE_SIZE) - .build(); + private final LazyBoundedCache styles = new LazyBoundedCache<>(STYLE_CACHE_SIZE); } private static final class StandaloneStyleKey { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java index 65d085979..6da231aff 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.LazyBoundedCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; @@ -30,7 +31,6 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.ExpressionNoise; import art.arcane.iris.util.project.noise.ImageNoise; -import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; @@ -54,10 +54,8 @@ public class IrisGeneratorStyle { private static final ConcurrentHashMap ACTIVE_CACHE_KEYS = new ConcurrentHashMap<>(); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) - private final transient ConcurrentLinkedHashMap generatorCache = - new ConcurrentLinkedHashMap.Builder() - .maximumWeightedCapacity(GENERATOR_CACHE_SIZE) - .build(); + private final transient LazyBoundedCache generatorCache = + new LazyBoundedCache<>(GENERATOR_CACHE_SIZE); @Desc("The base noise style. Used when neither expression nor imageMap is set; a failed expression also falls back to this style.") private NoiseStyle style = NoiseStyle.FLAT; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index e878a377b..d070edd45 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -22,6 +22,7 @@ import art.arcane.iris.platform.bukkit.BukkitBlockResolution; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.data.cache.LazyBoundedCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.ArrayType; @@ -38,7 +39,6 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.data.DataProvider; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; -import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import com.google.gson.annotations.SerializedName; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -69,10 +69,8 @@ public class IrisObjectPlacement { private static final int SURFACE_WARP_CACHE_SIZE = 8; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) - private final transient ConcurrentLinkedHashMap surfaceWarpCache = - new ConcurrentLinkedHashMap.Builder() - .maximumWeightedCapacity(SURFACE_WARP_CACHE_SIZE) - .build(); + private final transient LazyBoundedCache surfaceWarpCache = + new LazyBoundedCache<>(SURFACE_WARP_CACHE_SIZE); @RegistryListResource(IrisObject.class) @Required @ArrayType(min = 1, type = String.class) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java index a9a6839d5..5e0bc41a6 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java @@ -18,18 +18,24 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.Snippet; -import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.util.common.math.Vector3i; import art.arcane.volmlib.util.math.RNG; -import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + @Snippet("object-scale") @Accessors(chain = true) @NoArgsConstructor @@ -37,23 +43,16 @@ import lombok.experimental.Accessors; @Desc("Scale objects") @Data public class IrisObjectScale { - private static final int CACHE_INITIAL_CAPACITY = 256; - private static final int CACHE_MAX_WEIGHT = 8192; - private static final ConcurrentLinkedHashMap> cache - = new ConcurrentLinkedHashMap.Builder>() - .initialCapacity(CACHE_INITIAL_CAPACITY) - .maximumWeightedCapacity(CACHE_MAX_WEIGHT) - .concurrencyLevel(32) - .build(); + private static final long CACHE_MIN_ESTIMATED_BYTES = 16L * 1024L * 1024L; + private static final long CACHE_MAX_ESTIMATED_BYTES = 64L * 1024L * 1024L; + private static final long CACHE_ENTRY_ESTIMATED_BYTES = 512L; + private static final long CACHE_VARIANT_ESTIMATED_BYTES = 256L; + private static final long CACHE_ESTIMATED_BYTES_PER_VOXEL = 128L; + private static final int CACHE_LOAD_LOCK_COUNT = 256; + private static final ScaleCache CACHE = new ScaleCache(resolveCacheMaximumEstimatedBytes()); - /** - * Coarse flush wired into IrisData dump/hotload: keys hold strong IrisObject references - * (and through them the owning IrisData), so without this every hotload or world unload - * pinned the previous pack graph for the process lifetime. Entries are pure derived data, - * so a flushed still-live entry just recomputes. - */ - public static void invalidate() { - cache.clear(); + public static void invalidate(IrisData owner) { + CACHE.invalidate(owner); } @MinNumber(0.01) @@ -104,37 +103,315 @@ public class IrisObjectScale { if (origin == null) { return null; } - if (!shouldScale()) { + + ScaleRequest request = snapshotRequest(); + if (!request.shouldScale()) { return origin; } - CacheKey key = new CacheKey(origin, size, minimumScale, maximumScale, variations, interpolation); - return cache.computeIfAbsent(key, (k) -> { - KList c = new KList<>(); + int variantIndex = request.selectVariant(rng); + CacheKey key = CACHE.key(origin, request, variantIndex); + CacheLookup lookup = CACHE.lookup(key); + if (lookup.variant() != null) { + return lookup.variant(); + } - if (size != 1) { - c.add(origin.scaled(size, interpolation)); - return c; + synchronized (CACHE.loadLock(key)) { + CacheLookup afterWait = CACHE.lookup(key); + if (afterWait.variant() != null) { + return afterWait.variant(); } - if (minimumScale == maximumScale) { - c.add(origin.scaled(minimumScale, interpolation)); - return c; - } - - int vs = Math.max(1, Math.min(variations, 32)); - double step = (maximumScale - minimumScale) / (double) vs; - for (int v = 0; v < vs; v++) { - c.add(origin.scaled(minimumScale + step * v, interpolation)); - } - return c; - }).getRandom(rng); + IrisObject variant = origin.scaled(request.scaleAt(variantIndex), request.interpolation()); + long estimatedBytes = estimateVariant(variant); + return CACHE.putIfCurrent(key, variant, estimatedBytes, lookup.generation()); + } } public boolean canScaleBeyond() { return shouldScale() && getMaxScale() > 1; } - private record CacheKey(IrisObject origin, double size, double minimumScale, double maximumScale, int variations, IrisObjectPlacementScaleInterpolator interpolation) { + static int cacheEntryCount() { + return CACHE.size(); + } + + static long cacheEstimatedBytes() { + return CACHE.estimatedBytes(); + } + + static boolean isOriginCached(IrisObject origin) { + return CACHE.containsOrigin(origin); + } + + private ScaleRequest snapshotRequest() { + IrisObjectPlacementScaleInterpolator configuredInterpolation = interpolation == null + ? IrisObjectPlacementScaleInterpolator.NONE + : interpolation; + return new ScaleRequest(size, minimumScale, maximumScale, variations, configuredInterpolation); + } + + private static long estimateVariant(IrisObject variant) { + long width = Math.max(1L, variant.getW()); + long height = Math.max(1L, variant.getH()); + long depth = Math.max(1L, variant.getD()); + long volume = saturatedMultiply(saturatedMultiply(width, height), depth); + long populatedVoxels = saturatedAdd(variant.getBlocks().size(), variant.getStates().size()); + long voxelBytes = saturatedMultiply(Math.max(volume, populatedVoxels), CACHE_ESTIMATED_BYTES_PER_VOXEL); + return saturatedAdd(CACHE_ENTRY_ESTIMATED_BYTES, + saturatedAdd(CACHE_VARIANT_ESTIMATED_BYTES, voxelBytes)); + } + + private static long saturatedAdd(long first, long second) { + if (first >= Long.MAX_VALUE - second) { + return Long.MAX_VALUE; + } + return first + second; + } + + private static long saturatedMultiply(long first, long second) { + if (first == 0L || second == 0L) { + return 0L; + } + if (first > Long.MAX_VALUE / second) { + return Long.MAX_VALUE; + } + return first * second; + } + + private static long resolveCacheMaximumEstimatedBytes() { + long heapShare = Runtime.getRuntime().maxMemory() / 32L; + return Math.max(CACHE_MIN_ESTIMATED_BYTES, Math.min(CACHE_MAX_ESTIMATED_BYTES, heapShare)); + } + + record ScaleRequest(double size, double minimumScale, double maximumScale, int variations, + IrisObjectPlacementScaleInterpolator interpolation) { + boolean shouldScale() { + if (size != 1D) { + return true; + } + return variations > 0 && (minimumScale != 1D || maximumScale != 1D); + } + + int variantCount() { + if (size != 1D || minimumScale == maximumScale) { + return 1; + } + return Math.max(1, Math.min(variations, 32)); + } + + int selectVariant(RNG rng) { + int count = variantCount(); + return count == 1 ? 0 : rng.nextInt(count); + } + + double scaleAt(int index) { + if (size != 1D) { + return size; + } + if (minimumScale == maximumScale) { + return minimumScale; + } + return minimumScale + (((maximumScale - minimumScale) / variantCount()) * index); + } + } + + static final class CacheKey extends WeakReference { + private final IrisData owner; + private final ScaleRequest request; + private final int variantIndex; + private final int originRevision; + private final int loadHashCode; + private final int hashCode; + + CacheKey(IrisObject origin, ScaleRequest request, int variantIndex) { + this(origin, request, variantIndex, null); + } + + CacheKey(IrisObject origin, ScaleRequest request, int variantIndex, + ReferenceQueue collectedOrigins) { + super(origin, collectedOrigins); + this.owner = origin.getLoader(); + this.request = request; + this.variantIndex = variantIndex; + this.originRevision = originRevision(origin); + int result = System.identityHashCode(origin); + result = (31 * result) + System.identityHashCode(owner); + result = (31 * result) + originRevision; + result = (31 * result) + request.hashCode(); + this.loadHashCode = result; + this.hashCode = (31 * result) + variantIndex; + } + + boolean belongsTo(IrisData candidate) { + return owner == candidate; + } + + boolean hasOrigin(IrisObject candidate) { + return get() == candidate; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof CacheKey other)) { + return false; + } + IrisObject origin = get(); + return origin != null + && origin == other.get() + && owner == other.owner + && originRevision == other.originRevision + && request.equals(other.request) + && variantIndex == other.variantIndex; + } + + @Override + public int hashCode() { + return hashCode; + } + + int loadHashCode() { + return loadHashCode; + } + + private static int originRevision(IrisObject origin) { + origin.readLock.lock(); + try { + int result = origin.getW(); + result = (31 * result) + origin.getH(); + result = (31 * result) + origin.getD(); + result = (31 * result) + System.identityHashCode(origin.getBlocks()); + result = (31 * result) + System.identityHashCode(origin.getStates()); + result = (31 * result) + Long.hashCode(origin.getBlocks().modificationRevision()); + result = (31 * result) + Long.hashCode(origin.getStates().modificationRevision()); + Vector3i center = origin.getCenter(); + if (center != null) { + result = (31 * result) + center.getX(); + result = (31 * result) + center.getY(); + result = (31 * result) + center.getZ(); + } + return result; + } finally { + origin.readLock.unlock(); + } + } + } + + record CacheLookup(IrisObject variant, long generation) { + } + + static final class ScaleCache { + private final long maximumEstimatedBytes; + private final LinkedHashMap entries; + private final Object[] loadLocks; + private final ReferenceQueue collectedOrigins; + private long estimatedBytes; + private long generation; + + ScaleCache(long maximumEstimatedBytes) { + if (maximumEstimatedBytes <= 0L) { + throw new IllegalArgumentException("maximumEstimatedBytes must be positive"); + } + this.maximumEstimatedBytes = maximumEstimatedBytes; + this.entries = new LinkedHashMap<>(256, 0.75F, true); + this.loadLocks = new Object[CACHE_LOAD_LOCK_COUNT]; + this.collectedOrigins = new ReferenceQueue<>(); + for (int index = 0; index < loadLocks.length; index++) { + loadLocks[index] = new Object(); + } + } + + CacheKey key(IrisObject origin, ScaleRequest request, int variantIndex) { + return new CacheKey(origin, request, variantIndex, collectedOrigins); + } + + Object loadLock(CacheKey key) { + return loadLocks[key.loadHashCode() & (loadLocks.length - 1)]; + } + + synchronized CacheLookup lookup(CacheKey key) { + drainCollectedOrigins(); + CacheValue value = entries.get(key); + return new CacheLookup(value == null ? null : value.variant(), generation); + } + + synchronized IrisObject putIfCurrent(CacheKey key, IrisObject variant, + long entryEstimatedBytes, long expectedGeneration) { + drainCollectedOrigins(); + CacheValue present = entries.get(key); + if (present != null) { + return present.variant(); + } + if (generation != expectedGeneration || entryEstimatedBytes > maximumEstimatedBytes) { + return variant; + } + + entries.put(key, new CacheValue(variant, entryEstimatedBytes)); + estimatedBytes += entryEstimatedBytes; + evictToBudget(); + return variant; + } + + synchronized void invalidate(IrisData owner) { + drainCollectedOrigins(); + generation++; + Iterator> iterator = entries.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getKey().belongsTo(owner)) { + estimatedBytes -= entry.getValue().estimatedBytes(); + iterator.remove(); + } + } + } + + synchronized int size() { + drainCollectedOrigins(); + return entries.size(); + } + + synchronized long estimatedBytes() { + drainCollectedOrigins(); + return estimatedBytes; + } + + long maximumEstimatedBytes() { + return maximumEstimatedBytes; + } + + synchronized boolean containsOrigin(IrisObject origin) { + drainCollectedOrigins(); + for (CacheKey key : entries.keySet()) { + if (key.hasOrigin(origin)) { + return true; + } + } + return false; + } + + private void evictToBudget() { + Iterator> iterator = entries.entrySet().iterator(); + while (estimatedBytes > maximumEstimatedBytes && iterator.hasNext()) { + Map.Entry entry = iterator.next(); + estimatedBytes -= entry.getValue().estimatedBytes(); + iterator.remove(); + } + } + + private void drainCollectedOrigins() { + CacheKey collected; + while ((collected = (CacheKey) collectedOrigins.poll()) != null) { + CacheValue removed = entries.remove(collected); + if (removed != null) { + estimatedBytes -= removed.estimatedBytes(); + } + } + } + } + + record CacheValue(IrisObject variant, long estimatedBytes) { } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 292d94dae..42568364e 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -85,9 +85,12 @@ import java.io.File; import java.io.PrintWriter; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Random; import java.util.UUID; +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; @@ -524,6 +527,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun withExclusiveControl(() -> getEngine().hotload()); } + @Override + public CompletableFuture hotloadComplexAsync(long acquisitionTimeout, TimeUnit unit) { + Engine activeEngine = getEngine(); + if (activeEngine == null) { + return CompletableFuture.completedFuture(null); + } + return withExclusiveControlFuture(activeEngine::hotloadComplex, acquisitionTimeout, unit); + } + static boolean shouldRunStudioHotload(boolean studio, boolean closing, boolean jigsawStudioActive) { return studio && !closing && !jigsawStudioActive; } @@ -634,6 +646,21 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return future; } + CompletableFuture withExclusiveControlFuture( + Runnable operation, + long acquisitionTimeout, + TimeUnit unit + ) { + CompletableFuture future = new CompletableFuture<>(); + J.a(() -> completeExclusiveControlFuture( + loadLock, + operation, + future, + acquisitionTimeout, + unit)); + return future; + } + static void completeExclusiveControlFuture( GenerationStageGate gate, Runnable operation, @@ -666,6 +693,55 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + static void completeExclusiveControlFuture( + GenerationStageGate gate, + Runnable operation, + CompletableFuture future, + long acquisitionTimeout, + TimeUnit unit + ) { + GenerationStageGate activeGate = Objects.requireNonNull(gate, "Exclusive control gate"); + Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation"); + CompletableFuture outward = Objects.requireNonNull(future, "Exclusive control future"); + TimeUnit activeUnit = Objects.requireNonNull(unit, "Exclusive control timeout unit"); + boolean acquired = false; + Throwable failure = null; + try { + acquired = activeGate.tryAcquireExclusive( + acquisitionTimeout, + activeUnit, + outward::isCancelled); + if (!acquired) { + if (!outward.isCancelled()) { + failure = new TimeoutException("Timed out waiting for exclusive Iris generation control after " + + acquisitionTimeout + " " + activeUnit.name().toLowerCase(Locale.ROOT) + "."); + } + } else if (!outward.isCancelled()) { + activeOperation.run(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + if (!outward.isCancelled()) { + failure = e; + } + } catch (Throwable e) { + failure = e; + } finally { + if (acquired) { + activeGate.releaseExclusive(); + } + } + + if (outward.isCancelled()) { + return; + } + if (failure == null) { + outward.complete(null); + } else { + outward.completeExceptionally(failure); + } + } + public void touch(World world) { getEngine(world); } @@ -935,6 +1011,32 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun permits.acquire(permitCount); } + boolean tryAcquireExclusive( + long timeout, + TimeUnit unit, + BooleanSupplier cancelled + ) throws InterruptedException { + if (timeout <= 0L) { + throw new IllegalArgumentException("Exclusive generation control timeout must be positive."); + } + TimeUnit activeUnit = Objects.requireNonNull(unit, "Exclusive generation control timeout unit"); + BooleanSupplier cancellation = Objects.requireNonNull(cancelled, "Exclusive generation control cancellation"); + long timeoutNanos = activeUnit.toNanos(timeout); + long started = System.nanoTime(); + long remainingNanos = timeoutNanos; + long cancellationPollNanos = TimeUnit.MILLISECONDS.toNanos(50L); + while (!cancellation.getAsBoolean() && remainingNanos > 0L) { + if (permits.tryAcquire( + permitCount, + Math.min(remainingNanos, cancellationPollNanos), + TimeUnit.NANOSECONDS)) { + return true; + } + remainingNanos = timeoutNanos - (System.nanoTime() - started); + } + return false; + } + void releaseExclusive() { permits.release(permitCount); } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java index feb9b0535..2a8c12851 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java @@ -26,7 +26,9 @@ import art.arcane.iris.util.common.data.DataProvider; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; public interface PlatformChunkGenerator extends Hotloadable, DataProvider { @Nullable @@ -47,6 +49,23 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider { return CompletableFuture.completedFuture(null); } + default CompletableFuture hotloadComplexAsync(long acquisitionTimeout, TimeUnit unit) { + if (acquisitionTimeout <= 0L) { + throw new IllegalArgumentException("Complex hotload acquisition timeout must be positive."); + } + Objects.requireNonNull(unit, "Complex hotload acquisition timeout unit"); + Engine activeEngine = getEngine(); + if (activeEngine == null) { + return CompletableFuture.completedFuture(null); + } + try { + activeEngine.hotloadComplex(); + return CompletableFuture.completedFuture(null); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + boolean isStudio(); default boolean isClosing() { diff --git a/core/src/main/java/art/arcane/iris/util/common/data/VectorMap.java b/core/src/main/java/art/arcane/iris/util/common/data/VectorMap.java index fa07ecce0..770f8753a 100644 --- a/core/src/main/java/art/arcane/iris/util/common/data/VectorMap.java +++ b/core/src/main/java/art/arcane/iris/util/common/data/VectorMap.java @@ -14,6 +14,11 @@ import java.util.function.Function; public class VectorMap implements Iterable> { private final Map> map = new KMap<>(); + private transient volatile long modificationRevision; + + public long modificationRevision() { + return modificationRevision; + } public int size() { return map.values().stream().mapToInt(Map::size).sum(); @@ -40,13 +45,23 @@ public class VectorMap implements Iterable> { } public @Nullable T put(@NonNull IrisBlockVector vector, @NonNull T value) { - return map.computeIfAbsent(chunk(vector), k -> new KMap<>()) + T previous = map.computeIfAbsent(chunk(vector), k -> new KMap<>()) .put(relative(vector), value); + modificationRevision++; + return previous; } public @Nullable T computeIfAbsent(@NonNull IrisBlockVector vector, @NonNull Function<@NonNull IrisBlockVector, @NonNull T> mappingFunction) { - return map.computeIfAbsent(chunk(vector), k -> new KMap<>()) - .computeIfAbsent(relative(vector), $ -> mappingFunction.apply(vector)); + boolean[] inserted = new boolean[1]; + T value = map.computeIfAbsent(chunk(vector), key -> new KMap<>()) + .computeIfAbsent(relative(vector), key -> { + inserted[0] = true; + return mappingFunction.apply(vector); + }); + if (inserted[0]) { + modificationRevision++; + } + return value; } @SuppressWarnings("unchecked") @@ -61,6 +76,10 @@ public class VectorMap implements Iterable> { return chunk.isEmpty() ? null : chunk; }); + if (removed[0] != null) { + modificationRevision++; + } + return (T) removed[0]; } @@ -69,6 +88,9 @@ public class VectorMap implements Iterable> { } public void clear() { + if (!map.isEmpty()) { + modificationRevision++; + } map.clear(); } @@ -193,6 +215,7 @@ public class VectorMap implements Iterable> { public void remove() { if (relativeIterator == null) throw new IllegalStateException("No element to remove"); relativeIterator.remove(); + modificationRevision++; } } @@ -230,6 +253,7 @@ public class VectorMap implements Iterable> { public void remove() { if (relativeIterator == null) throw new IllegalStateException("No element to remove"); relativeIterator.remove(); + modificationRevision++; } @Override @@ -267,6 +291,7 @@ public class VectorMap implements Iterable> { public void remove() { if (relativeIterator == null) throw new IllegalStateException("No element to remove"); relativeIterator.remove(); + modificationRevision++; } @Override diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java index 19edb4b16..4c074919e 100644 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java +++ b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java @@ -31,12 +31,14 @@ public class CachedStream2D extends BasicStream implements ProceduralStrea private final ProceduralStream stream; private final WorldCache2D cache; private final Engine engine; + private final boolean fastCache; private final boolean chunked = true; public CachedStream2D(String name, Engine engine, ProceduralStream stream, int size) { super(); this.stream = stream; this.engine = engine; + this.fastCache = Boolean.getBoolean("iris.cache.fast"); cache = WorldCache2D.ofInts(stream::get, size, () -> new ChunkCache2D<>("iris")); IrisServices.get(PreservationRegistry.class).registerCache(this); } @@ -76,6 +78,10 @@ public class CachedStream2D extends BasicStream implements ProceduralStrea return cache.getMaxSize(); } + public boolean usesFastCache() { + return fastCache; + } + @Override public boolean isClosed() { return engine.isClosed(); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index 96df8a7a3..af4d7d104 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -289,6 +289,90 @@ public class WorldReplacementBootstrapTest { assertTrue(published.stream().allMatch(transaction -> transaction.phase() == Phase.PUBLISHED)); } + @Test + public void publishesPreparedOverworldAndUnderworldIntoExactVanillaSlotsInOneColdReconcile() + throws Exception { + long overworldSeed = 81818181L; + WorldSlotKey overworldKey = WorldSlotKey.minecraft("overworld"); + ExactWorldSlotPathPolicy.Target overworldTarget = ExactWorldSlotPathPolicy.resolve(levelRoot, overworldKey); + WorldGeneratorSnapshot overworldOriginal = BukkitWorldConfiguration.snapshot( + bukkitConfiguration.toFile(), + "world" + ); + UUID overworldId = UUID.randomUUID(); + ReplacementPaths overworldPaths = WorldReplacementFilesystem.paths(overworldTarget, overworldId); + writeOriginalTarget(overworldPaths, "overworld-original"); + Path overworldDimension = overworldPaths.stage().resolve("iris/pack/dimensions/overworld.json"); + Files.createDirectories(overworldDimension.getParent()); + Files.writeString(overworldDimension, "overworld-replacement"); + String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack( + overworldPaths.stage().resolve("iris/pack") + ); + Transaction overworld = new Transaction( + overworldId, + overworldKey, + "world", + overworldTarget.levelRoot(), + "overworld", + overworldSeed, + overworldFingerprint, + overworldOriginal, + true, + Phase.ARMED + ); + WorldReplacementJournal.write(dataDirectory, overworld); + configureReplacement(overworld); + + Transaction nether = stagedTransaction(Phase.ARMED, true, "nether-original"); + configureReplacement(nether); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(2, result.transactions()); + assertEquals(2, result.published()); + assertEquals(0, result.rolledBack()); + assertEquals(0, result.retained()); + assertEquals(0, result.skipped()); + assertEquals( + levelRoot.toRealPath().resolve("dimensions/minecraft/overworld"), + overworldTarget.worldDirectory() + ); + assertEquals( + levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether"), + target.worldDirectory() + ); + assertEquals("overworld-replacement", Files.readString( + overworldTarget.worldDirectory().resolve("iris/pack/dimensions/overworld.json") + )); + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals("overworld-original", Files.readString(overworldPaths.backup().resolve("original.txt"))); + assertEquals("nether-original", Files.readString(backup(nether).resolve("original.txt"))); + + WorldGeneratorSnapshot overworldConfiguration = BukkitWorldConfiguration.snapshot( + bukkitConfiguration.toFile(), + "world" + ); + WorldGeneratorSnapshot netherConfiguration = BukkitWorldConfiguration.snapshot( + bukkitConfiguration.toFile(), + "world_nether" + ); + assertEquals("Iris:overworld", overworldConfiguration.generator()); + assertEquals(Long.valueOf(overworldSeed), overworldConfiguration.seed()); + assertEquals("Iris:underworld", netherConfiguration.generator()); + assertEquals(Long.valueOf(SEED), netherConfiguration.seed()); + + List published = WorldReplacementJournal.load(dataDirectory, levelRoot); + assertEquals(2, published.size()); + assertTrue(published.stream().anyMatch(transaction -> + transaction.worldKey().equals(overworldKey) + && transaction.dimension().equals("overworld") + && transaction.phase() == Phase.PUBLISHED)); + assertTrue(published.stream().anyMatch(transaction -> + transaction.worldKey().equals(WORLD_KEY) + && transaction.dimension().equals("underworld") + && transaction.phase() == Phase.PUBLISHED)); + } + @Test public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception { for (String generator : List.of("", " ")) { diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java index 671020f90..2e1bd7f5f 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java @@ -322,8 +322,9 @@ public class WorldReplacementFilesystemTest { public void rejectsSymlinkInsidePackAndNonDirectoryArtifacts() throws Exception { WorldReplacementFilesystem.ReplacementPaths linkedPaths = paths("inside-pack-link", TRANSACTION_ID); Path pack = Files.createDirectories(linkedPaths.stage().resolve("iris/pack")); + Path nestedObjects = Files.createDirectories(pack.resolve("objects/oak")); Path outside = temporaryFolder.newFile("outside-pack.txt").toPath(); - Files.createSymbolicLink(pack.resolve("linked.json"), outside); + Files.createSymbolicLink(nestedObjects.resolve("linked.json"), outside); assertThrows(IOException.class, () -> WorldReplacementFilesystem.fingerprintPack(pack)); @@ -335,6 +336,20 @@ public class WorldReplacementFilesystemTest { ); } + @Test + public void fingerprintRemainsCompatibleAcrossNestedCreationOrder() throws Exception { + Path firstPack = temporaryFolder.newFolder("fingerprint-order-first").toPath(); + Path secondPack = temporaryFolder.newFolder("fingerprint-order-second").toPath(); + writeFingerprintFixture(firstPack, false); + writeFingerprintFixture(secondPack, true); + + String firstFingerprint = WorldReplacementFilesystem.fingerprintPack(firstPack); + String secondFingerprint = WorldReplacementFilesystem.fingerprintPack(secondPack); + + assertEquals("215206c4a5e74c7731fbd8d2da9265332bfb06a0f74b32e47d02dab3dd878b4c", firstFingerprint); + assertEquals(firstFingerprint, secondFingerprint); + } + @Test public void ignoresGeneratedAuthoringMetadataWithoutIgnoringNestedPackContent() throws Exception { Path pack = temporaryFolder.newFolder("generated-metadata").toPath(); @@ -448,6 +463,29 @@ public class WorldReplacementFilesystemTest { Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation"); } + private void writeFingerprintFixture(Path pack, boolean reverseOrder) throws Exception { + if (reverseOrder) { + Files.createDirectories(pack.resolve("regions")); + Files.writeString(pack.resolve("regions/default.json"), "{}\n"); + Files.createDirectories(pack.resolve("empty")); + Files.createDirectories(pack.resolve("objects/oak")); + Files.write(pack.resolve("objects/oak/tree.iob"), new byte[]{0, 1, 2, (byte) 255}); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/overworld.json"), "{\"name\":\"world\"}"); + } else { + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/overworld.json"), "{\"name\":\"world\"}"); + Files.createDirectories(pack.resolve("objects/oak")); + Files.write(pack.resolve("objects/oak/tree.iob"), new byte[]{0, 1, 2, (byte) 255}); + Files.createDirectories(pack.resolve("empty")); + Files.createDirectories(pack.resolve("regions")); + Files.writeString(pack.resolve("regions/default.json"), "{}\n"); + } + Files.createDirectories(pack.resolve(".iris/schema")); + Files.writeString(pack.resolve(".iris/schema/dimensions-schema.json"), "generated"); + Files.writeString(pack.resolve("editor.code-workspace"), "generated"); + } + private String readPackContent(Path worldDirectory) throws Exception { return Files.readString(packContent(worldDirectory)); } diff --git a/core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderPrefetchTest.java b/core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderPrefetchTest.java new file mode 100644 index 000000000..563f759fa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderPrefetchTest.java @@ -0,0 +1,247 @@ +package art.arcane.iris.core.loader; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.collection.KSet; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Answers; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.GZIPOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ResourceLoaderPrefetchTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisPlatform previousPlatform; + private IrisSettings previousSettings; + private File platformRoot; + private File packRoot; + private IrisData manager; + private Engine engine; + + @Before + public void setUp() throws Exception { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + previousSettings = IrisSettings.settings; + IrisPlatforms.unbind(); + platformRoot = temporaryFolder.newFolder("platform"); + packRoot = temporaryFolder.newFolder("pack"); + IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(platformRoot); + when(platform.dataFile(any(String[].class))).thenAnswer(invocation -> { + File file = platformRoot; + for (Object segment : invocation.getArguments()) { + file = new File(file, String.valueOf(segment)); + } + return file; + }); + IrisPlatforms.bind(platform); + IrisSettings.settings = new IrisSettings(); + + manager = mock(IrisData.class); + when(manager.getId()).thenReturn(7); + when(manager.getDataFolder()).thenReturn(packRoot); + + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("overworld"); + dimension.setVersion(9); + engine = mock(Engine.class); + when(engine.getSeedManager()).thenReturn(new SeedManager(42L)); + when(engine.getDimension()).thenReturn(dimension); + } + + @After + public void tearDown() { + IrisSettings.settings = previousSettings; + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void oversizedHistoricalSetIsNotAdmittedOnReopen() throws Exception { + CountingLoader writer = loader(32); + KSet history = new KSet<>(); + for (int index = 0; index < 4_096; index++) { + history.add("resource-" + index); + } + writer.setFirstAccess(history); + + writer.saveFirstAccess(engine); + CountingLoader reader = loader(32); + reader.loadFirstAccess(engine); + + assertTrue(reader.loadedKeys().isEmpty()); + } + + @Test + public void liveHistoryStopsGrowingAtTheAdmissionLimit() { + CountingLoader loader = loader(4_096); + + for (int index = 0; index < 4_096; index++) { + loader.load("resource-" + index, false); + } + + assertEquals(1_024, loader.getFirstAccess().size()); + assertTrue(loader.isFirstAccessOverflowed()); + } + + @Test + public void admittedHistoryLoadsWithoutPerEntryTaskFanout() throws Exception { + CountingLoader writer = loader(32); + KSet history = new KSet<>(); + for (int index = 0; index < 32; index++) { + history.add("resource-" + index); + } + writer.setFirstAccess(history); + writer.saveFirstAccess(engine); + + CountingLoader reader = loader(32); + String loadingThread = Thread.currentThread().getName(); + reader.loadFirstAccess(engine); + + assertEquals(32, reader.loadedKeys().size()); + assertEquals(Set.of(loadingThread), reader.loadingThreads()); + } + + @Test + public void dataManagerLoadsHistoriesOnTheCallingThread() throws Exception { + CountingLoader writer = loader(32); + writer.setFirstAccess(new KSet<>("resource")); + writer.saveFirstAccess(engine); + CountingLoader reader = loader(32); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(TestRegistrant.class, reader); + IrisData data = mock(IrisData.class, Answers.CALLS_REAL_METHODS); + data.setLoaders(loaders); + String loadingThread = Thread.currentThread().getName(); + + data.loadPrefetch(engine); + + assertEquals(List.of("resource"), reader.loadedKeys()); + assertEquals(Set.of(loadingThread), reader.loadingThreads()); + } + + @Test + public void legacyIdentityIsNotRead() throws Exception { + String identity = "DIM" + Math.abs(42L + 9L + "overworld".hashCode()); + File legacy = new File(platformRoot, + "prefetch/" + identity + "/" + Math.abs("test-resources".hashCode()) + ".ipfch"); + assertTrue(legacy.getParentFile().mkdirs()); + try (FileOutputStream output = new FileOutputStream(legacy); + GZIPOutputStream gzip = new GZIPOutputStream(output); + DataOutputStream data = new DataOutputStream(gzip)) { + data.writeInt(1); + data.writeUTF("legacy-resource"); + } + + CountingLoader reader = loader(32); + reader.loadFirstAccess(engine); + + assertTrue(reader.loadedKeys().isEmpty()); + } + + @Test + public void equalDimensionIdentitiesDoNotCrossPackRoots() throws Exception { + CountingLoader writer = loader(32); + writer.setFirstAccess(new KSet<>("source-only")); + writer.saveFirstAccess(engine); + File otherRoot = temporaryFolder.newFolder("other-pack"); + IrisData otherManager = mock(IrisData.class); + when(otherManager.getId()).thenReturn(8); + when(otherManager.getDataFolder()).thenReturn(otherRoot); + + CountingLoader reader = loader(otherRoot, otherManager, 32); + reader.loadFirstAccess(engine); + + assertTrue(reader.loadedKeys().isEmpty()); + } + + @Test + public void binaryObjectLoaderDoesNotContributeToJsonPrefetchHistory() { + ObjectResourceLoader loader = new ObjectResourceLoader( + packRoot, + manager, + "objects", + "Object", + ResourceLoader.Options.datapackCompiler()); + + IrisObject object = loader.load("missing", false); + + assertNull(object); + assertTrue(loader.getFirstAccess().isEmpty()); + } + + private CountingLoader loader(int cacheSize) { + return loader(packRoot, manager, cacheSize); + } + + private CountingLoader loader(File root, IrisData data, int cacheSize) { + return new CountingLoader( + root, + data, + new ResourceLoader.Options(cacheSize, false, true)); + } + + private static final class CountingLoader extends ResourceLoader { + private final List loaded = Collections.synchronizedList(new ArrayList<>()); + private final Set threads = ConcurrentHashMap.newKeySet(); + + private CountingLoader(File root, IrisData manager, Options options) { + super(root, manager, "test-resources", "Test Resource", TestRegistrant.class, options); + } + + @Override + public TestRegistrant load(String name) { + loaded.add(name); + threads.add(Thread.currentThread().getName()); + return new TestRegistrant(); + } + + private List loadedKeys() { + return List.copyOf(loaded); + } + + private Set loadingThreads() { + return Set.copyOf(threads); + } + } + + public static final class TestRegistrant extends IrisRegistrant { + @Override + public String getFolderName() { + return "test-resources"; + } + + @Override + public String getTypeName() { + return "Test Resource"; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index b95e31a29..4607e26b3 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -20,6 +20,8 @@ package art.arcane.iris.core.pack; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisEnvironment; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformStructureHooks; @@ -120,6 +122,69 @@ public class PackDownloaderTest { assertTrue(PackDownloader.isBuiltInPack("underworld")); } + @Test + public void sequentialBuiltInPackInstallsPublishBothBootstrapResultsBeforeRestart() throws Exception { + File packsFolder = IrisPlatforms.get().packsFolder(); + File overworldSource = writePack( + temp.newFolder("shipping-overworld-source").toPath(), + "overworld", + "overworld", + IrisEnvironment.NORMAL + ); + File underworldSource = writePack( + temp.newFolder("shipping-underworld-source").toPath(), + "underworld", + "underworld", + IrisEnvironment.NETHER + ); + + PackDownloader.PackInstallResult overworld = PackDownloader.installExtractedPack( + packsFolder, + overworldSource, + false, + "overworld", + ignored -> { + } + ); + PackDownloader.PackInstallResult underworld = PackDownloader.installExtractedPack( + packsFolder, + underworldSource, + false, + "underworld", + ignored -> { + } + ); + + assertNotNull(overworld); + assertEquals("overworld", overworld.key()); + assertTrue(overworld.changed()); + assertTrue(overworld.restartRequired()); + assertNotNull(underworld); + assertEquals("underworld", underworld.key()); + assertTrue(underworld.changed()); + assertTrue(underworld.restartRequired()); + assertTrue(Files.isRegularFile(packsFolder.toPath().resolve("overworld/dimensions/overworld.json"))); + assertTrue(Files.isRegularFile(packsFolder.toPath().resolve("underworld/dimensions/underworld.json"))); + assertTrue(PackValidationRegistry.requireLoadable("overworld").isLoadable()); + assertTrue(PackValidationRegistry.requireLoadable("underworld").isLoadable()); + IrisData overworldData = IrisData.openDatapackCompiler(new File(packsFolder, "overworld")); + IrisData underworldData = IrisData.openDatapackCompiler(new File(packsFolder, "underworld")); + try { + IrisDimension overworldDimension = overworldData.getDimensionLoader().load("overworld"); + IrisDimension underworldDimension = underworldData.getDimensionLoader().load("underworld"); + assertNotNull(overworldDimension); + assertEquals("overworld", overworldDimension.getLoadKey()); + assertEquals(IrisEnvironment.NORMAL, overworldDimension.getEnvironment()); + assertNotNull(underworldDimension); + assertEquals("underworld", underworldDimension.getLoadKey()); + assertEquals(IrisEnvironment.NETHER, underworldDimension.getEnvironment()); + } finally { + overworldData.close(); + underworldData.close(); + } + assertTransactionStateClean(packsFolder); + } + @Test public void recognizesOnlyHttpZipUrlsAsDirectSources() { assertTrue(PackDownloader.isDirectZipUrl("https://packs.example.test/overworld.zip")); @@ -654,6 +719,23 @@ public class PackDownloaderTest { return root.toFile(); } + private static File writePack( + Path root, + String key, + String state, + IrisEnvironment environment + ) throws IOException { + File pack = writePack(root, key, state); + Files.writeString( + root.resolve("dimensions/" + key + ".json"), + "{\"name\":\"" + key + "\",\"environment\":\"" + environment.name() + + "\",\"regions\":[\"local\"],\"logicalHeight\":256," + + "\"dimensionHeight\":{\"min\":-64,\"max\":320}}", + StandardCharsets.UTF_8 + ); + return pack; + } + private static void writeDimension(Path root, String key) throws IOException { Files.writeString( root.resolve("dimensions/" + key + ".json"), diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java new file mode 100644 index 000000000..6fe0e0537 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java @@ -0,0 +1,115 @@ +package art.arcane.iris.core.pregenerator; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; +import art.arcane.iris.util.project.stream.utility.CachedStream2D; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class PregenPerformanceProfileTest { + @Test + public void liveGeneratorUsesItsPlatformHotloadBoundary() { + IrisSettings previousSettings = IrisSettings.settings; + String previousFastCache = System.getProperty("iris.cache.fast"); + IrisSettings.settings = new IrisSettings(); + System.clearProperty("iris.cache.fast"); + Engine engine = engineWithProfile(1_024, false); + PlatformChunkGenerator generator = generatorFor(engine); + try { + PregenPerformanceProfile.applyToGenerator(generator); + + assertEquals(4_096, IrisSettings.get().getPerformance().getNoiseCacheSize()); + assertTrue(Boolean.getBoolean("iris.cache.fast")); + verify(generator).hotloadComplexAsync(30L, TimeUnit.SECONDS); + verify(engine, never()).hotloadComplex(); + } finally { + restore(previousSettings, previousFastCache); + } + } + + @Test + public void eachExistingEngineRefreshesAgainstTheAppliedProfile() { + IrisSettings previousSettings = IrisSettings.settings; + String previousFastCache = System.getProperty("iris.cache.fast"); + IrisSettings.settings = new IrisSettings(); + IrisSettings.get().getPerformance().setNoiseCacheSize(4_096); + System.clearProperty("iris.cache.fast"); + Engine overworldEngine = engineWithProfile(4_096, false); + Engine netherEngine = engineWithProfile(4_096, false); + PlatformChunkGenerator overworldGenerator = generatorFor(overworldEngine); + PlatformChunkGenerator netherGenerator = generatorFor(netherEngine); + try { + PregenPerformanceProfile.applyToGenerator(overworldGenerator); + PregenPerformanceProfile.applyToGenerator(netherGenerator); + + verify(overworldGenerator).hotloadComplexAsync(30L, TimeUnit.SECONDS); + verify(netherGenerator).hotloadComplexAsync(30L, TimeUnit.SECONDS); + } finally { + restore(previousSettings, previousFastCache); + } + } + + @Test + public void profileAppliedBeforeWorldCreationAvoidsLiveHotload() { + IrisSettings previousSettings = IrisSettings.settings; + String previousFastCache = System.getProperty("iris.cache.fast"); + IrisSettings.settings = new IrisSettings(); + System.clearProperty("iris.cache.fast"); + Engine engine = engineWithProfile(4_096, true); + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + when(generator.getEngine()).thenReturn(engine); + try { + PregenPerformanceProfile.apply(); + PregenPerformanceProfile.applyToGenerator(generator); + + verify(generator, never()).hotloadComplexAsync(30L, TimeUnit.SECONDS); + verify(engine, never()).hotloadComplex(); + } finally { + restore(previousSettings, previousFastCache); + } + } + + @SuppressWarnings("unchecked") + private static Engine engineWithProfile(int cacheSize, boolean fastCache) { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + CachedDoubleStream2D heightStream = mock(CachedDoubleStream2D.class); + CachedStream2D caveBiomeStream = mock(CachedStream2D.class); + when(heightStream.getMaxSize()).thenReturn((long) cacheSize * 256L); + when(caveBiomeStream.usesFastCache()).thenReturn(fastCache); + when(complex.getHeightStream()).thenReturn(heightStream); + when(complex.getCaveBiomeStream()).thenReturn(caveBiomeStream); + when(engine.getComplex()).thenReturn(complex); + return engine; + } + + private static PlatformChunkGenerator generatorFor(Engine engine) { + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + when(generator.getEngine()).thenReturn(engine); + when(generator.hotloadComplexAsync(30L, TimeUnit.SECONDS)) + .thenReturn(CompletableFuture.completedFuture(null)); + return generator; + } + + private static void restore(IrisSettings settings, String fastCache) { + IrisSettings.settings = settings; + if (fastCache == null) { + System.clearProperty("iris.cache.fast"); + } else { + System.setProperty("iris.cache.fast", fastCache); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java index 27e2a4901..02275805b 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java @@ -2,7 +2,12 @@ package art.arcane.iris.core.pregenerator.methods; import org.junit.Test; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; public class AsyncPregenMethodConcurrencyCapTest { @Test @@ -23,11 +28,13 @@ public class AsyncPregenMethodConcurrencyCapTest { } @Test - public void paperLikeConcurrencyProvisionsForWorldGenThreadBump() { - assertEquals(32, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 32)); - assertEquals(16, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 16)); + public void paperLikeConcurrencyUsesDetectedWorkerPoolWhenAvailable() { + assertEquals(4, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 32)); + assertEquals(4, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 16)); assertEquals(24, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 24)); assertEquals(16, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 8)); + assertEquals(32, AsyncPregenMethod.computePaperLikeRecommendedCap( + AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 16))); } @Test @@ -42,4 +49,32 @@ public class AsyncPregenMethodConcurrencyCapTest { assertEquals(128, AsyncPregenMethod.selectConcurrencyCap(128, false)); assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(0, false)); } + + @Test + public void slowRequestObservationDoesNotCompleteOrReplacePendingFuture() { + CompletableFuture request = new CompletableFuture<>(); + AtomicInteger slowRequests = new AtomicInteger(); + + CompletableFuture observed = AsyncPregenMethod.observeSlowRequest( + request, + Runnable::run, + slowRequests::incrementAndGet); + + assertSame(request, observed); + assertFalse(request.isDone()); + assertEquals(1, slowRequests.get()); + request.complete("generated"); + assertEquals("generated", observed.join()); + } + + @Test + public void slowRequestObservationIgnoresCompletedFuture() { + CompletableFuture request = CompletableFuture.completedFuture("generated"); + AtomicInteger slowRequests = new AtomicInteger(); + + AsyncPregenMethod.observeSlowRequest(request, Runnable::run, slowRequests::incrementAndGet); + + assertEquals(0, slowRequests.get()); + assertEquals("generated", request.join()); + } } diff --git a/core/src/test/java/art/arcane/iris/core/service/EngineMaintenanceTest.java b/core/src/test/java/art/arcane/iris/core/service/EngineMaintenanceTest.java index b678bec5b..fa37e9b28 100644 --- a/core/src/test/java/art/arcane/iris/core/service/EngineMaintenanceTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/EngineMaintenanceTest.java @@ -53,6 +53,14 @@ public class EngineMaintenanceTest { assertFalse(plan.heapPressure()); } + @Test + public void studioDisablesOnlyRoutineMaintenance() { + assertFalse(EngineMaintenance.shouldRunStudioMaintenance(true, false, false)); + assertTrue(EngineMaintenance.shouldRunStudioMaintenance(true, false, true)); + assertTrue(EngineMaintenance.shouldRunStudioMaintenance(true, true, false)); + assertTrue(EngineMaintenance.shouldRunStudioMaintenance(false, false, false)); + } + @Test public void nestedMantleClosedFailureIsRecognized() { IllegalStateException cause = new IllegalStateException("Mantle is closed"); diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisPackBenchmarkingLifecycleContractTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisPackBenchmarkingLifecycleContractTest.java new file mode 100644 index 000000000..9eef84aa8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisPackBenchmarkingLifecycleContractTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.core.tools; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class IrisPackBenchmarkingLifecycleContractTest { + @Test + public void benchmarkAppliesProfileBeforeCreatingItsWorld() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.packBenchmarkingSource"))); + String runBenchmark = method(source, "private void runBenchmark()"); + + assertBefore(runBenchmark, "IrisToolbelt.applyPregenPerformanceProfile()", "createBenchmark()"); + } + + @Test + public void liveEngineProfileUsesTheBoundPlatformGenerator() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.irisToolbeltSource"))); + String applyProfile = method(source, "public static void applyPregenPerformanceProfile(Engine engine)"); + + assertBefore(applyProfile, "PregenPerformanceProfile.applyToGenerator(generator)", "PregenPerformanceProfile.apply(engine)"); + } + + @Test + public void bukkitComplexHotloadUsesExclusiveGenerationControl() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource"))); + String hotload = method(source, "public CompletableFuture hotloadComplexAsync(long acquisitionTimeout, TimeUnit unit)"); + + assertTrue(hotload.contains("withExclusiveControlFuture(activeEngine::hotloadComplex, acquisitionTimeout, unit)")); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue(firstIndex >= 0); + assertTrue(secondIndex > firstIndex); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue(start >= 0); + int openingBrace = source.indexOf('{', start); + assertTrue(openingBrace >= 0); + int depth = 0; + for (int index = openingBrace; index < source.length(); index++) { + char character = source.charAt(index); + if (character == '{') { + depth++; + } else if (character == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new AssertionError("Method body did not close: " + signature); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/data/cache/LazyBoundedCacheTest.java b/core/src/test/java/art/arcane/iris/engine/data/cache/LazyBoundedCacheTest.java new file mode 100644 index 000000000..9bd86afff --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/data/cache/LazyBoundedCacheTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.engine.data.cache; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class LazyBoundedCacheTest { + @Test + public void backingStorageIsLazy() { + LazyBoundedCache cache = new LazyBoundedCache<>(8); + + assertFalse(cache.isInitialized()); + assertEquals(0, cache.size()); + assertNull(cache.computeIfAbsent("missing", ignored -> null)); + assertFalse(cache.isInitialized()); + + Object expected = new Object(); + assertSame(expected, cache.computeIfAbsent("present", ignored -> expected)); + assertTrue(cache.isInitialized()); + assertEquals(1, cache.size()); + } + + @Test + public void capacityUsesAccessOrder() { + LazyBoundedCache cache = new LazyBoundedCache<>(8); + Object first = new Object(); + cache.computeIfAbsent(0, ignored -> first); + for (int key = 1; key < 8; key++) { + cache.computeIfAbsent(key, ignored -> new Object()); + } + + assertSame(first, cache.computeIfAbsent(0, ignored -> new Object())); + cache.computeIfAbsent(8, ignored -> new Object()); + + AtomicInteger reloads = new AtomicInteger(); + cache.computeIfAbsent(1, ignored -> { + reloads.incrementAndGet(); + return new Object(); + }); + assertEquals(1, reloads.get()); + assertEquals(8, cache.size()); + assertSame(first, cache.computeIfAbsent(0, ignored -> new Object())); + } + + @Test + public void concurrentSameKeyComputesOnce() throws Exception { + int threadCount = 16; + LazyBoundedCache cache = new LazyBoundedCache<>(8); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch resolverEntered = new CountDownLatch(1); + CountDownLatch releaseResolver = new CountDownLatch(1); + AtomicInteger computations = new AtomicInteger(); + Object expected = new Object(); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(threadCount); + try { + for (int index = 0; index < threadCount; index++) { + futures.add(executor.submit(() -> { + start.await(); + return cache.computeIfAbsent("shared", ignored -> { + computations.incrementAndGet(); + resolverEntered.countDown(); + await(releaseResolver); + return expected; + }); + })); + } + start.countDown(); + assertTrue(resolverEntered.await(5L, TimeUnit.SECONDS)); + releaseResolver.countDown(); + for (Future future : futures) { + assertSame(expected, future.get(5L, TimeUnit.SECONDS)); + } + } finally { + releaseResolver.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + + assertEquals(1, computations.get()); + assertEquals(1, cache.size()); + } + + @Test + public void keysRetainIdentityEqualitySemantics() { + Object firstIdentity = new String("same"); + Object secondIdentity = new String("same"); + LazyBoundedCache cache = new LazyBoundedCache<>(8); + Object firstValue = new Object(); + + assertSame(firstValue, cache.computeIfAbsent(new IdentityKey(firstIdentity), ignored -> firstValue)); + assertSame(firstValue, cache.computeIfAbsent(new IdentityKey(firstIdentity), ignored -> new Object())); + assertNotSame(firstValue, cache.computeIfAbsent(new IdentityKey(secondIdentity), ignored -> new Object())); + assertEquals(2, cache.size()); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5L, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for cache test latch"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static final class IdentityKey { + private final Object identity; + + private IdentityKey(Object identity) { + this.identity = identity; + } + + @Override + public boolean equals(Object other) { + return other instanceof IdentityKey key && identity == key.identity; + } + + @Override + public int hashCode() { + return System.identityHashCode(identity); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectScaleCacheTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectScaleCacheTest.java new file mode 100644 index 000000000..ffd1c0f3a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectScaleCacheTest.java @@ -0,0 +1,314 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.math.RNG; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class IrisObjectScaleCacheTest { + private static final IrisObjectScale.ScaleRequest REQUEST = new IrisObjectScale.ScaleRequest( + 2D, + 1D, + 1D, + 7, + IrisObjectPlacementScaleInterpolator.NONE + ); + + @Before + public void setUp() { + IrisObjectScale.invalidate(null); + } + + @After + public void tearDown() { + IrisObjectScale.invalidate(null); + } + + @Test + public void mutatedOriginCreatesStableReplacementAndCanBeReleased() { + IrisObject origin = new IrisObject(2, 2, 2); + IrisObjectScale scale = new IrisObjectScale().setSize(2D); + + IrisObject first = scale.get(new RNG(1L), origin); + IrisObjectScale.ScaleCache keyCache = new IrisObjectScale.ScaleCache(10L); + IrisObjectScale.CacheKey originalKey = keyCache.key(origin, REQUEST, 0); + int originalHash = originalKey.hashCode(); + + origin.setW(3); + IrisObject second = scale.get(new RNG(1L), origin); + + assertEquals(originalHash, originalKey.hashCode()); + assertNotEquals(originalKey, keyCache.key(origin, REQUEST, 0)); + assertNotSame(first, second); + assertNotEquals(first.getW(), second.getW()); + assertEquals(2, IrisObjectScale.cacheEntryCount()); + + IrisObjectScale.invalidate(null); + + assertEquals(0, IrisObjectScale.cacheEntryCount()); + assertEquals(0L, IrisObjectScale.cacheEstimatedBytes()); + assertFalse(IrisObjectScale.isOriginCached(origin)); + } + + @Test + public void voxelMutationReplacesCachedVariants() { + IrisObject origin = new IrisObject(2, 2, 2); + IrisObjectScale scale = new IrisObjectScale().setSize(2D); + IrisObject empty = scale.get(new RNG(1L), origin); + + origin.setUnsigned(0, 0, 0, mock(PlatformBlockState.class)); + IrisObject populated = scale.get(new RNG(1L), origin); + + assertEquals(0, empty.getBlocks().size()); + assertTrue(populated.getBlocks().size() > 0); + assertNotSame(empty, populated); + assertEquals(2, IrisObjectScale.cacheEntryCount()); + } + + @Test + public void rangedScaleBuildsSelectedVariantsLazilyWithStableSequence() { + IrisObject origin = new IrisObject(10, 10, 10); + IrisObjectScale scale = new IrisObjectScale() + .setVariations(4) + .setMinimumScale(1D) + .setMaximumScale(2D); + SequenceRng rng = new SequenceRng(); + + IrisObject first = scale.get(rng, origin); + IrisObject second = scale.get(rng, origin); + IrisObject third = scale.get(rng, origin); + IrisObject fourth = scale.get(rng, origin); + IrisObject repeated = scale.get(rng, origin); + + assertSame(first, repeated); + assertNotEquals(first.getW(), second.getW()); + assertNotEquals(second.getW(), third.getW()); + assertNotEquals(third.getW(), fourth.getW()); + assertEquals(4, IrisObjectScale.cacheEntryCount()); + assertTrue(IrisObjectScale.cacheEstimatedBytes() > 0L); + } + + @Test + public void overweightScaledVariantIsNotRetained() { + OverweightScaleObject origin = new OverweightScaleObject(); + IrisObjectScale scale = new IrisObjectScale().setSize(2D); + + IrisObject result = scale.get(new RNG(1L), origin); + + assertEquals(1_000, result.getW()); + assertEquals(1, origin.scaleCalls.get()); + assertEquals(0, IrisObjectScale.cacheEntryCount()); + assertEquals(0L, IrisObjectScale.cacheEstimatedBytes()); + } + + @Test + public void concurrentSameVariantScalesOnlyOnce() throws Exception { + BlockingScaleObject origin = new BlockingScaleObject(); + IrisObjectScale scale = new IrisObjectScale().setSize(2D); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch secondInvoked = new CountDownLatch(1); + AtomicReference secondThread = new AtomicReference<>(); + + try { + Future first = executor.submit(() -> scale.get(new RNG(1L), origin)); + assertTrue(origin.firstScaleEntered.await(5L, TimeUnit.SECONDS)); + Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + secondInvoked.countDown(); + return scale.get(new RNG(1L), origin); + }); + assertTrue(secondInvoked.await(5L, TimeUnit.SECONDS)); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + while (secondThread.get().getState() != Thread.State.BLOCKED + && origin.scaleCalls.get() == 1 + && System.nanoTime() < deadline) { + LockSupport.parkNanos(100_000L); + } + + assertEquals(1, origin.scaleCalls.get()); + assertEquals(Thread.State.BLOCKED, secondThread.get().getState()); + origin.releaseScale.countDown(); + assertSame(first.get(5L, TimeUnit.SECONDS), second.get(5L, TimeUnit.SECONDS)); + } finally { + origin.releaseScale.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void weightedEvictionKeepsStrictBudgetAndUsesRecentAccess() { + IrisObjectScale.ScaleCache cache = new IrisObjectScale.ScaleCache(10L); + IrisObject firstOrigin = new IrisObject(1, 1, 1); + IrisObject secondOrigin = new IrisObject(1, 1, 1); + IrisObject thirdOrigin = new IrisObject(1, 1, 1); + IrisObjectScale.CacheKey firstKey = key(firstOrigin); + IrisObjectScale.CacheKey secondKey = key(secondOrigin); + IrisObjectScale.CacheKey thirdKey = key(thirdOrigin); + + put(cache, firstKey, new IrisObject(1, 1, 1), 4L); + put(cache, secondKey, new IrisObject(1, 1, 1), 4L); + assertTrue(cache.lookup(firstKey).variant() != null); + put(cache, thirdKey, new IrisObject(1, 1, 1), 4L); + + assertEquals(2, cache.size()); + assertEquals(8L, cache.estimatedBytes()); + assertTrue(cache.estimatedBytes() <= cache.maximumEstimatedBytes()); + assertTrue(cache.lookup(firstKey).variant() != null); + assertNull(cache.lookup(secondKey).variant()); + assertTrue(cache.lookup(thirdKey).variant() != null); + } + + @Test + public void overweightEntryIsReturnedWithoutRetention() { + IrisObjectScale.ScaleCache cache = new IrisObjectScale.ScaleCache(10L); + IrisObject origin = new IrisObject(1, 1, 1); + IrisObjectScale.CacheKey key = key(origin); + IrisObject variant = new IrisObject(1, 1, 1); + IrisObjectScale.CacheLookup lookup = cache.lookup(key); + + IrisObject result = cache.putIfCurrent(key, variant, 11L, lookup.generation()); + + assertSame(variant, result); + assertEquals(0, cache.size()); + assertEquals(0L, cache.estimatedBytes()); + } + + @Test + public void invalidationReleasesOnlyTheRequestedRuntime() { + IrisObjectScale.ScaleCache cache = new IrisObjectScale.ScaleCache(20L); + IrisData firstOwner = mock(IrisData.class); + IrisData secondOwner = mock(IrisData.class); + IrisObject firstOrigin = new IrisObject(1, 1, 1); + IrisObject secondOrigin = new IrisObject(1, 1, 1); + firstOrigin.setLoader(firstOwner); + secondOrigin.setLoader(secondOwner); + IrisObjectScale.CacheKey firstKey = key(firstOrigin); + IrisObjectScale.CacheKey secondKey = key(secondOrigin); + + put(cache, firstKey, new IrisObject(1, 1, 1), 5L); + put(cache, secondKey, new IrisObject(1, 1, 1), 5L); + + cache.invalidate(firstOwner); + + assertNull(cache.lookup(firstKey).variant()); + assertTrue(cache.lookup(secondKey).variant() != null); + assertEquals(1, cache.size()); + assertEquals(5L, cache.estimatedBytes()); + } + + @Test + public void invalidationRejectsAnInFlightStaleInsertion() { + IrisObjectScale.ScaleCache cache = new IrisObjectScale.ScaleCache(10L); + IrisData owner = mock(IrisData.class); + IrisObject origin = new IrisObject(1, 1, 1); + origin.setLoader(owner); + IrisObjectScale.CacheKey key = key(origin); + IrisObjectScale.CacheLookup lookup = cache.lookup(key); + + cache.invalidate(owner); + cache.putIfCurrent(key, new IrisObject(1, 1, 1), 5L, lookup.generation()); + + assertEquals(0, cache.size()); + assertEquals(0L, cache.estimatedBytes()); + assertNull(cache.lookup(key).variant()); + } + + @Test + public void collectedOriginReleasesScaledValueWithOwnerReference() { + IrisObjectScale.ScaleCache cache = new IrisObjectScale.ScaleCache(20L); + IrisData owner = mock(IrisData.class); + IrisObject origin = new IrisObject(1, 1, 1); + IrisObject variant = new IrisObject(1, 1, 1); + origin.setLoader(owner); + variant.setLoader(owner); + IrisObjectScale.CacheKey collectedKey = cache.key(origin, REQUEST, 0); + put(cache, collectedKey, variant, 5L); + + collectedKey.clear(); + assertTrue(collectedKey.enqueue()); + + assertEquals(0, cache.size()); + assertEquals(0L, cache.estimatedBytes()); + } + + private static IrisObjectScale.CacheKey key(IrisObject origin) { + return new IrisObjectScale.CacheKey(origin, REQUEST, 0); + } + + private static void put(IrisObjectScale.ScaleCache cache, IrisObjectScale.CacheKey key, + IrisObject variant, long estimatedBytes) { + IrisObjectScale.CacheLookup lookup = cache.lookup(key); + cache.putIfCurrent(key, variant, estimatedBytes, lookup.generation()); + } + + private static final class BlockingScaleObject extends IrisObject { + private final AtomicInteger scaleCalls = new AtomicInteger(); + private final CountDownLatch firstScaleEntered = new CountDownLatch(1); + private final CountDownLatch releaseScale = new CountDownLatch(1); + + private BlockingScaleObject() { + super(2, 2, 2); + } + + @Override + public IrisObject scaled(double scale, IrisObjectPlacementScaleInterpolator interpolation) { + scaleCalls.incrementAndGet(); + firstScaleEntered.countDown(); + try { + if (!releaseScale.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to release scale computation"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + return new IrisObject(4, 4, 4); + } + } + + private static final class OverweightScaleObject extends IrisObject { + private final AtomicInteger scaleCalls = new AtomicInteger(); + + private OverweightScaleObject() { + super(2, 2, 2); + } + + @Override + public IrisObject scaled(double scale, IrisObjectPlacementScaleInterpolator interpolation) { + scaleCalls.incrementAndGet(); + return new IrisObject(1_000, 1_000, 1_000); + } + } + + private static final class SequenceRng extends RNG { + private int next; + + @Override + public int nextInt(int bound) { + int value = next % bound; + next++; + return value; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java index f33fc52c6..398f6a35b 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java @@ -16,6 +16,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; 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; @@ -23,6 +24,7 @@ import java.util.concurrent.locks.LockSupport; import java.util.function.Supplier; 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; @@ -311,6 +313,73 @@ public class BukkitChunkGeneratorGenerationStageGateTest { } } + @Test + public void timedExclusiveControlDoesNotReleaseAnUnacquiredPermit() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, closing::get); + BukkitChunkGenerator.GenerationStagePermit stage = gate.acquireStage("timeout-holder"); + CompletableFuture outward = new CompletableFuture<>(); + AtomicBoolean operationRan = new AtomicBoolean(false); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future operation = executor.submit(() -> + BukkitChunkGenerator.completeExclusiveControlFuture( + gate, + () -> operationRan.set(true), + outward, + 50L, + TimeUnit.MILLISECONDS)); + + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> outward.get(2L, TimeUnit.SECONDS)); + operation.get(2L, TimeUnit.SECONDS); + + assertTrue(failure.getCause() instanceof TimeoutException); + assertFalse(operationRan.get()); + assertEquals(0, gate.availablePermits()); + } finally { + stage.close(); + assertEquals(1, gate.availablePermits()); + executor.shutdownNow(); + } + } + + @Test + public void cancelledExclusiveControlStopsWaitingWithoutReleasingAStagePermit() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, closing::get); + BukkitChunkGenerator.GenerationStagePermit stage = gate.acquireStage("cancellation-holder"); + CompletableFuture outward = new CompletableFuture<>(); + AtomicBoolean operationRan = new AtomicBoolean(false); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future operation = executor.submit(() -> + BukkitChunkGenerator.completeExclusiveControlFuture( + gate, + () -> operationRan.set(true), + outward, + 30L, + TimeUnit.SECONDS)); + awaitQueueLength(gate, 1); + + assertTrue(outward.cancel(true)); + operation.get(2L, TimeUnit.SECONDS); + + assertTrue(outward.isCancelled()); + assertFalse(operationRan.get()); + assertEquals(0, gate.availablePermits()); + } finally { + stage.close(); + assertEquals(1, gate.availablePermits()); + executor.shutdownNow(); + } + } + @Test public void queuedStageIsRejectedAfterCloseBegins() throws Exception { AtomicBoolean closing = new AtomicBoolean(false);