stabilize pregeneration

This commit is contained in:
Brian Neumann-Fopiano
2026-08-15 12:57:10 -04:00
parent 4535bf3eed
commit d196ffe853
32 changed files with 2013 additions and 169 deletions
@@ -1671,7 +1671,7 @@ public class NMSBinding implements INMSBinding {
MinecraftServer server MinecraftServer server
) throws IOException { ) throws IOException {
if (craftServer.isGlobalTickThread()) { if (craftServer.isGlobalTickThread()) {
return createCurrentPaperLevelOverrides(server); return createCurrentPaperLevelOverrides(craftServer, server);
} }
if (J.isFolia() && J.isPrimaryThread()) { if (J.isFolia() && J.isPrimaryThread()) {
throw new IOException("Current Paper world data cannot be staged from a Folia region tick thread."); 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<PaperLevelOverrides> captured = new CompletableFuture<>(); CompletableFuture<PaperLevelOverrides> captured = new CompletableFuture<>();
boolean scheduled = J.runGlobal(() -> { boolean scheduled = J.runGlobal(() -> {
try { try {
captured.complete(createCurrentPaperLevelOverrides(server)); captured.complete(createCurrentPaperLevelOverrides(craftServer, server));
} catch (Throwable failure) { } catch (Throwable failure) {
captured.completeExceptionally(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)) { if (!(server.getWorldData().overworldData() instanceof PrimaryLevelData primaryLevelData)) {
throw new IOException("Paper primary level data is unavailable for current world data staging."); throw new IOException("Paper primary level data is unavailable for current world data staging.");
} }
@@ -59,12 +59,19 @@ public class NMSBindingCurrentPaperWorldDataContractTest {
assertTrue(capture.contains("craftServer.isGlobalTickThread()")); assertTrue(capture.contains("craftServer.isGlobalTickThread()"));
assertTrue(capture.contains("J.isFolia() && J.isPrimaryThread()")); assertTrue(capture.contains("J.isFolia() && J.isPrimaryThread()"));
assertTrue(capture.contains("J.runGlobal(")); 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("captured.get(CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS"));
assertTrue(capture.contains("Thread.currentThread().interrupt()")); 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)")); assertTrue(create.contains("PaperLevelOverrides.createFromLiveLevelData(primaryLevelData)"));
assertFalse(capture.contains("WorldReplacementSeed")); assertFalse(capture.contains("WorldReplacementSeed"));
assertFalse(capture.contains("SavedDataStorage")); assertFalse(capture.contains("SavedDataStorage"));
assertFalse(capture.contains("Files.")); assertFalse(capture.contains("Files."));
assertFalse(create.contains("WorldReplacementSeed"));
assertFalse(create.contains("SavedDataStorage"));
assertFalse(create.contains("Files."));
} }
@Test @Test
+3
View File
@@ -121,6 +121,9 @@ dependencies {
tasks.named('test').configure { tasks.named('test').configure {
maxHeapSize = '1g' 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 { java {
@@ -6,7 +6,6 @@ import art.arcane.iris.core.pack.PackDirectoryResolver;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.AtomicMoveNotSupportedException;
@@ -247,27 +246,29 @@ public final class WorldReplacementFilesystem {
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
requireDirectory(root, "pack root"); requireDirectory(root, "pack root");
MessageDigest digest = sha256(); MessageDigest digest = sha256();
List<Path> files; List<FingerprintEntry> entries;
try (Stream<Path> stream = Files.walk(root)) { try (Stream<Path> stream = Files.walk(root)) {
files = stream entries = stream
.filter(path -> !path.equals(root)) .filter(path -> !path.equals(root))
.sorted(Comparator.comparing(path -> root.relativize(path).toString())) .map(path -> fingerprintEntry(root, path))
.sorted(Comparator.comparing(FingerprintEntry::relativeString))
.toList(); .toList();
} }
for (Path file : files) { String separator = root.getFileSystem().getSeparator();
BasicFileAttributes attributes = requireSafeEntry(file); byte[] buffer = new byte[8192];
Path relative = root.relativize(file); for (FingerprintEntry entry : entries) {
BasicFileAttributes attributes = requireSafeEntry(entry.path());
Path relative = entry.relative();
if (isGeneratedPackMetadata(relative, attributes)) { if (isGeneratedPackMetadata(relative, attributes)) {
continue; continue;
} }
update(digest, relative.toString().replace(file.getFileSystem().getSeparator(), "/")); update(digest, entry.relativeString().replace(separator, "/"));
digest.update((byte) (attributes.isDirectory() ? 1 : 0)); digest.update((byte) (attributes.isDirectory() ? 1 : 0));
if (!attributes.isRegularFile()) { if (!attributes.isRegularFile()) {
continue; continue;
} }
update(digest, Long.toString(attributes.size())); update(digest, Long.toString(attributes.size()));
try (InputStream input = Files.newInputStream(file)) { try (InputStream input = Files.newInputStream(entry.path())) {
byte[] buffer = new byte[8192];
int read; int read;
while ((read = input.read(buffer)) >= 0) { while ((read = input.read(buffer)) >= 0) {
digest.update(buffer, 0, read); digest.update(buffer, 0, read);
@@ -277,6 +278,11 @@ public final class WorldReplacementFilesystem {
return HexFormat.of().formatHex(digest.digest()); 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 { private static State inspect(ReplacementPaths paths) throws IOException {
return new State( return new State(
directoryPresent(paths.target(), "replacement target"), directoryPresent(paths.target(), "replacement target"),
@@ -405,7 +411,10 @@ public final class WorldReplacementFilesystem {
private static void update(MessageDigest digest, String value) { private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8); 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); digest.update(bytes);
} }
@@ -474,4 +483,7 @@ public final class WorldReplacementFilesystem {
private record State(boolean targetPresent, boolean stagePresent, boolean backupPresent) { private record State(boolean targetPresent, boolean stagePresent, boolean backupPresent) {
} }
private record FingerprintEntry(Path path, Path relative, String relativeString) {
}
} }
@@ -463,7 +463,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public synchronized void hotloaded() { public synchronized void hotloaded() {
StructureGraphCatalog.invalidate(this); StructureGraphCatalog.invalidate(this);
IrisObjectScale.invalidate(); IrisObjectScale.invalidate(this);
closed = false; closed = false;
possibleSnippets = new KMap<>(); possibleSnippets = new KMap<>();
builder = new GsonBuilder() builder = new GsonBuilder()
@@ -529,7 +529,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public void dump() { public void dump() {
StructureGraphCatalog.invalidate(this); StructureGraphCatalog.invalidate(this);
IrisObjectScale.invalidate(); IrisObjectScale.invalidate(this);
for (ResourceLoader<?> i : loaders.values()) { for (ResourceLoader<?> i : loaders.values()) {
i.clearCache(); i.clearCache();
} }
@@ -763,19 +763,13 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
} }
public void loadPrefetch(Engine engine) { public void loadPrefetch(Engine engine) {
BurstExecutor b = MultiBurst.ioBurst.burst(loaders.size()); for (ResourceLoader<?> loader : loaders.values()) {
for (ResourceLoader<?> i : loaders.values()) {
b.queue(() -> {
try { try {
i.loadFirstAccess(engine); loader.loadFirstAccess(engine);
} catch (IOException e) { } catch (IOException exception) {
throw new RuntimeException(e); throw new RuntimeException(exception);
} }
});
} }
b.complete();
IrisLogging.debug("Loaded Prefetch Cache to reduce generation disk use."); IrisLogging.debug("Loaded Prefetch Cache to reduce generation disk use.");
} }
} }
@@ -52,12 +52,16 @@ import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.HexFormat;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
@@ -79,6 +83,11 @@ import java.util.zip.GZIPOutputStream;
public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache { public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
public static final AtomicDouble tlt = new AtomicDouble(0); public static final AtomicDouble tlt = new AtomicDouble(0);
private static final int CACHE_SIZE = 100000; 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 volatile ExecutorService schemaBuildExecutor;
private static final Set<String> schemaBuildQueue = ConcurrentHashMap.newKeySet(); private static final Set<String> schemaBuildQueue = ConcurrentHashMap.newKeySet();
protected final AtomicCache<KList<File>> folderCache; protected final AtomicCache<KList<File>> folderCache;
@@ -93,6 +102,7 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
protected IrisData manager; protected IrisData manager;
protected AtomicInteger loads; protected AtomicInteger loads;
protected ChronoLatch sec; protected ChronoLatch sec;
private volatile boolean firstAccessOverflowed;
private final Options options; private final Options options;
public ResourceLoader( public ResourceLoader(
@@ -106,6 +116,7 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
this.options = Objects.requireNonNull(options, "options"); this.options = Objects.requireNonNull(options, "options");
this.manager = manager; this.manager = manager;
firstAccess = new KSet<>(); firstAccess = new KSet<>();
firstAccessOverflowed = false;
folderCache = new AtomicCache<>(); folderCache = new AtomicCache<>();
sec = new ChronoLatch(5000); sec = new ChronoLatch(5000);
loads = new AtomicInteger(); loads = new AtomicInteger();
@@ -499,13 +510,28 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
KSet<String> set = firstAccess; KSet<String> set = firstAccess;
// contains-first: CHM.add takes the bin monitor even when the key is present, and // 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. // 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); return loadCache.get(name);
} }
private File prefetchFile(Engine engine) { private File prefetchFile(Engine engine) {
String id = "DIM" + Math.abs(engine.getSeedManager().getSeed() + engine.getDimension().getVersion() + engine.getDimension().getLoadKey().hashCode()); String dimensionIdentity = digestIdentity(
return IrisPlatforms.get().dataFile("prefetch/" + id + "/" + Math.abs(getFolderName().hashCode()) + ".ipfch"); 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 { public void loadFirstAccess(Engine engine) throws IOException {
@@ -515,33 +541,43 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
return; return;
} }
KList<String> s = new KList<>(); if (file.length() > PREFETCH_FILE_SIZE_LIMIT) {
discardPrefetch(file, "file exceeds " + PREFETCH_FILE_SIZE_LIMIT + " bytes");
return;
}
KList<String> entries = new KList<>();
try (FileInputStream fin = new FileInputStream(file); try (FileInputStream fin = new FileInputStream(file);
GZIPInputStream gzi = new GZIPInputStream(fin); GZIPInputStream gzi = new GZIPInputStream(fin);
DataInputStream din = new DataInputStream(gzi)) { DataInputStream din = new DataInputStream(gzi)) {
int m = din.readInt(); int magic = din.readInt();
int version = din.readInt();
int count = din.readInt();
if (m < 0) { if (magic != PREFETCH_MAGIC || version != PREFETCH_FORMAT_VERSION) {
throw new IOException("Bad prefetch count " + m); throw new IOException("unsupported prefetch format");
} }
if (count < 0 || count > prefetchEntryLimit()) {
for (int i = 0; i < m; i++) { throw new IOException("bad prefetch count " + count);
s.add(din.readUTF()); }
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) { } catch (IOException e) {
IrisLogging.warn("Discarding corrupt prefetch " + file.getPath() + ": " + e.getMessage()); discardPrefetch(file, e.getMessage());
if (!file.delete()) {
IrisLogging.warn("Couldn't delete corrupt prefetch " + file.getPath());
}
return; return;
} }
IrisLogging.info("Loading " + s.size() + " prefetch " + getFolderName()); IrisLogging.info("Loading " + entries.size() + " prefetch " + getFolderName());
firstAccess = null; firstAccess = null;
loadAllParallel(s); for (String entry : entries) {
load(entry);
}
} }
public void saveFirstAccess(Engine engine) throws IOException { public void saveFirstAccess(Engine engine) throws IOException {
@@ -549,6 +585,18 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
if (set == null) return; if (set == null) return;
KList<String> snapshot = new KList<>(set); KList<String> snapshot = new KList<>(set);
File file = prefetchFile(engine); 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(); File parent = file.getParentFile();
if (parent == null) { if (parent == null) {
@@ -565,10 +613,12 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
try (FileOutputStream fos = new FileOutputStream(temp); try (FileOutputStream fos = new FileOutputStream(temp);
GZIPOutputStream gzo = new CustomOutputStream(fos, 9); GZIPOutputStream gzo = new CustomOutputStream(fos, 9);
DataOutputStream dos = new DataOutputStream(gzo)) { DataOutputStream dos = new DataOutputStream(gzo)) {
dos.writeInt(PREFETCH_MAGIC);
dos.writeInt(PREFETCH_FORMAT_VERSION);
dos.writeInt(snapshot.size()); dos.writeInt(snapshot.size());
for (String i : snapshot) { for (String key : snapshot) {
dos.writeUTF(i); dos.writeUTF(key);
} }
} }
@@ -584,6 +634,34 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
firstAccess = null; 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<File> getFolders() { public KList<File> getFolders() {
return folderCache.aquire(() -> { return folderCache.aquire(() -> {
KList<File> fc = new KList<>(); KList<File> fc = new KList<>();
@@ -20,11 +20,19 @@ package art.arcane.iris.core.pregenerator;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.IrisSettings; 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.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; import java.util.concurrent.atomic.AtomicBoolean;
public final class PregenPerformanceProfile { public final class PregenPerformanceProfile {
private static final long HOTLOAD_ACQUISITION_TIMEOUT_SECONDS = 30L;
private static final AtomicBoolean JVM_HINT_LOGGED = new AtomicBoolean(false); private static final AtomicBoolean JVM_HINT_LOGGED = new AtomicBoolean(false);
private PregenPerformanceProfile() { private PregenPerformanceProfile() {
@@ -55,10 +63,43 @@ public final class PregenPerformanceProfile {
} }
public static void apply(Engine engine) { public static void apply(Engine engine) {
boolean changed = apply(); apply();
if (changed && engine != null) { if (requiresNoiseCacheRefresh(engine)) {
engine.hotloadComplex(); engine.hotloadComplex();
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<Double> heightStream = complex.getHeightStream();
if (!(heightStream instanceof MeteredCache cache)) {
return true;
}
ProceduralStream<IrisBiome> 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")); IrisLogging.info("Pregen profile applied: noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize() + " iris.cache.fast=" + Boolean.getBoolean("iris.cache.fast"));
} }
}
} }
@@ -48,6 +48,7 @@ import java.util.Queue;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; 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. // 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 THREAD_COUNT = new AtomicInteger();
private static final AtomicInteger BOOST_HOLDERS = 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 int ADAPTIVE_RECOVERY_INTERVAL = 8;
private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L; private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L;
private static final long FLUSH_TIMEOUT_SECONDS = 120L; private static final long FLUSH_TIMEOUT_SECONDS = 120L;
@@ -78,11 +79,12 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final Method directChunkAtAsyncUrgentMethod; private final Method directChunkAtAsyncUrgentMethod;
private final Method directChunkAtAsyncMethod; private final Method directChunkAtAsyncMethod;
private final String chunkAccessMode; private final String chunkAccessMode;
private final Executor executor; private final ChunkRequestExecutor executor;
private final Executor slowRequestExecutor;
private final Semaphore semaphore; private final Semaphore semaphore;
private final int threads; private final int threads;
private final int timeoutSeconds; private final int slowRequestWarningSeconds;
private final int timeoutWarnIntervalMs; private final int slowRequestWarnIntervalMs;
private final boolean urgent; private final boolean urgent;
private final ConcurrentHashMap<Long, AtomicInteger> regionPending; private final ConcurrentHashMap<Long, AtomicInteger> regionPending;
private final ConcurrentHashMap<Long, Queue<Chunk>> regionChunks; private final ConcurrentHashMap<Long, Queue<Chunk>> regionChunks;
@@ -95,10 +97,10 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private volatile int boundsMaxRegionZ; private volatile int boundsMaxRegionZ;
private final AtomicInteger adaptiveInFlightLimit; private final AtomicInteger adaptiveInFlightLimit;
private final int adaptiveMinInFlightLimit; private final int adaptiveMinInFlightLimit;
private final AtomicInteger timeoutStreak = new AtomicInteger(); private final AtomicInteger slowRequestStreak = new AtomicInteger();
private final AtomicLong lastTimeoutLogAt = new AtomicLong(0L); private final AtomicLong lastSlowRequestLogAt = new AtomicLong(0L);
private final AtomicLong lastFailedReleaseLogAt = 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 AtomicLong lastAdaptiveLogAt = new AtomicLong(0L);
private final AtomicInteger inFlight = new AtomicInteger(); private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicLong submitted = new AtomicLong(); private final AtomicLong submitted = new AtomicLong();
@@ -158,8 +160,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
this.effectiveWorkerThreads = workerThreadsForCap; this.effectiveWorkerThreads = workerThreadsForCap;
this.recommendedRuntimeConcurrencyCap = configuredThreads; this.recommendedRuntimeConcurrencyCap = configuredThreads;
this.semaphore = new Semaphore(this.threads, true); this.semaphore = new Semaphore(this.threads, true);
this.timeoutSeconds = pregen.getChunkLoadTimeoutSeconds(); this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds();
this.timeoutWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS);
this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs();
this.urgent = false; this.urgent = false;
this.regionPending = new ConcurrentHashMap<>(); this.regionPending = new ConcurrentHashMap<>();
this.regionChunks = new ConcurrentHashMap<>(); this.regionChunks = new ConcurrentHashMap<>();
@@ -266,8 +269,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
long now = M.ms(); long now = M.ms();
long last = lastFailedReleaseLogAt.get(); long last = lastFailedReleaseLogAt.get();
if (now - last >= timeoutWarnIntervalMs && lastFailedReleaseLogAt.compareAndSet(last, now)) { if (now - last >= slowRequestWarnIntervalMs && lastFailedReleaseLogAt.compareAndSet(last, now)) {
IrisLogging.warn("Released region slot for failed or timed out chunk at " + x + "," + z + ". " + metricsSnapshot()); 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) { private Chunk onChunkFutureFailure(int x, int z, Throwable throwable) {
try { 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); IrisLogging.reportError(throwable);
} catch (Throwable e) { } catch (Throwable e) {
@@ -505,32 +499,36 @@ public class AsyncPregenMethod implements PregeneratorMethod {
} }
} }
private void onTimeout(int x, int z) { private void onSlowRequest(int x, int z) {
int streak = timeoutStreak.incrementAndGet(); if (closing.get()) {
if (streak % ADAPTIVE_TIMEOUT_STEP == 0) { return;
}
int streak = slowRequestStreak.incrementAndGet();
if (streak % ADAPTIVE_SLOW_REQUEST_STEP == 0) {
lowerAdaptiveInFlightLimit(); lowerAdaptiveInFlightLimit();
} }
long now = M.ms(); long now = M.ms();
long last = lastTimeoutLogAt.get(); long last = lastSlowRequestLogAt.get();
if (now - last < timeoutWarnIntervalMs || !lastTimeoutLogAt.compareAndSet(last, now)) { if (now - last < slowRequestWarnIntervalMs || !lastSlowRequestLogAt.compareAndSet(last, now)) {
suppressedTimeoutLogs.incrementAndGet(); suppressedSlowRequestLogs.incrementAndGet();
return; return;
} }
int suppressed = suppressedTimeoutLogs.getAndSet(0); int suppressed = suppressedSlowRequestLogs.getAndSet(0);
String suppressedText = suppressed <= 0 ? "" : " suppressed=" + suppressed; String suppressedText = suppressed <= 0 ? "" : " suppressed=" + suppressed;
IrisLogging.warn("Timed out async pregen chunk load at " + x + "," + z IrisLogging.warn("Async pregen chunk load at " + x + "," + z
+ " after " + timeoutSeconds + "s." + " is still pending after " + slowRequestWarningSeconds + "s."
+ " adaptiveLimit=" + adaptiveInFlightLimit.get() + " adaptiveLimit=" + adaptiveInFlightLimit.get()
+ suppressedText + " " + metricsSnapshot()); + suppressedText + " " + metricsSnapshot());
} }
private void onSuccess() { private void onSuccess() {
int streak = timeoutStreak.get(); int streak = slowRequestStreak.get();
if (streak > 0) { if (streak > 0) {
int newStreak = Math.max(0, streak - 2); int newStreak = Math.max(0, streak - 2);
timeoutStreak.compareAndSet(streak, newStreak); slowRequestStreak.compareAndSet(streak, newStreak);
if (newStreak > 0) { if (newStreak > 0) {
return; return;
} }
@@ -606,14 +604,23 @@ public class AsyncPregenMethod implements PregeneratorMethod {
} }
static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) { static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) {
int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads);
if (detectedWorkerPoolThreads > 0) { if (detectedWorkerPoolThreads > 0) {
return Math.max(detectedWorkerPoolThreads, provisionedWorkerThreads); return detectedWorkerPoolThreads;
} }
int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads);
return Math.max(provisionedWorkerThreads, detectedCpuThreads); return Math.max(provisionedWorkerThreads, detectedCpuThreads);
} }
static <T> CompletableFuture<T> observeSlowRequest(CompletableFuture<T> future, Executor executor, Runnable onSlowRequest) {
executor.execute(() -> {
if (!future.isDone()) {
onSlowRequest.run();
}
});
return future;
}
static int computeFoliaRecommendedCap(int workerThreads) { static int computeFoliaRecommendedCap(int workerThreads) {
int normalizedWorkers = Math.max(1, workerThreads); int normalizedWorkers = Math.max(1, workerThreads);
int recommendedCap = normalizedWorkers * 8; int recommendedCap = normalizedWorkers * 8;
@@ -742,7 +749,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
+ ", effectiveWorkerThreads=" + effectiveWorkerThreads + ", effectiveWorkerThreads=" + effectiveWorkerThreads
+ ", recommendedCap=" + recommendedRuntimeConcurrencyCap + ", recommendedCap=" + recommendedRuntimeConcurrencyCap
+ ", urgent=" + urgent + ", urgent=" + urgent
+ ", timeout=" + timeoutSeconds + "s"); + ", slowWarning=" + slowRequestWarningSeconds + "s");
if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) { if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) {
acquireWorkerThreadBoost(); 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)); return CompletableFuture.failedFuture(new IllegalStateException("Failed to request async chunk " + x + "," + z + " in world " + world.getName(), failure));
} }
private CompletableFuture<Chunk> requestMonitoredChunkAsync(int x, int z) {
return observeSlowRequest(requestChunkAsync(x, z), slowRequestExecutor, () -> onSlowRequest(x, z));
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private CompletableFuture<Chunk> invokeChunkFuture(Method method, int x, int z, boolean generate, boolean urgentRequest) throws Throwable { private CompletableFuture<Chunk> invokeChunkFuture(Method method, int x, int z, boolean generate, boolean urgentRequest) throws Throwable {
Object result; 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); void generate(int x, int z, PregenListener listener);
default void shutdown() {} default void shutdown() {}
} }
private class FoliaRegionExecutor implements Executor { private class FoliaRegionExecutor implements ChunkRequestExecutor {
@Override @Override
public void generate(int x, int z, PregenListener listener) { public void generate(int x, int z, PregenListener listener) {
try { try {
requestChunkAsync(x, z) requestMonitoredChunkAsync(x, z)
.orTimeout(timeoutSeconds, TimeUnit.SECONDS)
.whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable)); .whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable));
return; return;
} catch (Throwable ignored) { } catch (Throwable ignored) {
@@ -1034,8 +1044,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
Runnable regionTask = () -> { Runnable regionTask = () -> {
try { try {
requestChunkAsync(x, z) requestMonitoredChunkAsync(x, z)
.orTimeout(timeoutSeconds, TimeUnit.SECONDS)
.whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable)); .whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable));
} catch (Throwable e) { } catch (Throwable e) {
completeChunk(x, z, listener, null, 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"); private final ExecutorService service = new MultiBurst("Iris Async Pregen");
public void generate(int x, int z, PregenListener listener) { public void generate(int x, int z, PregenListener listener) {
try { try {
service.submit(() -> { service.submit(() -> {
try { try {
Chunk i = requestChunkAsync(x, z) Chunk i = requestMonitoredChunkAsync(x, z).get();
.orTimeout(timeoutSeconds, TimeUnit.SECONDS)
.get();
completeChunk(x, z, listener, i, null); completeChunk(x, z, listener, i, null);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@@ -1078,12 +1085,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
} }
} }
private class TicketExecutor implements Executor { private class TicketExecutor implements ChunkRequestExecutor {
@Override @Override
public void generate(int x, int z, PregenListener listener) { public void generate(int x, int z, PregenListener listener) {
try { try {
requestChunkAsync(x, z) requestMonitoredChunkAsync(x, z)
.orTimeout(timeoutSeconds, TimeUnit.SECONDS)
.whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable)); .whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable));
} catch (Throwable e) { } catch (Throwable e) {
completeChunk(x, z, listener, null, e); completeChunk(x, z, listener, null, e);
@@ -31,8 +31,10 @@ public final class EngineMaintenance {
} }
public static boolean shouldRun(Engine engine) { public static boolean shouldRun(Engine engine) {
if (engine.isStudio() if (!shouldRunStudioMaintenance(
&& !IrisSettings.get().getPerformance().isTrimMantleInStudio()) { engine.isStudio(),
IrisSettings.get().getPerformance().isTrimMantleInStudio(),
engine.isStudio() && MantleHeapPressure.overHighWater())) {
return false; return false;
} }
if (GoldenHashEngine.isActive()) { if (GoldenHashEngine.isActive()) {
@@ -43,6 +45,14 @@ public final class EngineMaintenance {
|| !WorldMaintenance.isWorldMaintenanceActive(engine.getWorld().identity()); || !WorldMaintenance.isWorldMaintenanceActive(engine.getWorld().identity());
} }
static boolean shouldRunStudioMaintenance(
boolean studio,
boolean trimMantleInStudio,
boolean heapPressure
) {
return !studio || trimMantleInStudio || heapPressure;
}
public static int workerParallelism() { public static int workerParallelism() {
return IrisSettings.get().getPerformance().getEngineSVC().getParallelism(); return IrisSettings.get().getPerformance().getEngineSVC().getParallelism();
} }
@@ -51,6 +51,7 @@ public class IrisPackBenchmarking {
.name("PackBenchmarking") .name("PackBenchmarking")
.start(() -> { .start(() -> {
IrisLogging.info("Setting up benchmark environment "); IrisLogging.info("Setting up benchmark environment ");
IrisToolbelt.applyPregenPerformanceProfile();
IO.delete(IrisWorldStorage.dimensionRoot("benchmark")); IO.delete(IrisWorldStorage.dimensionRoot("benchmark"));
createBenchmark(); createBenchmark();
while (!IrisToolbelt.isIrisWorld(benchmarkWorld())) { while (!IrisToolbelt.isIrisWorld(benchmarkWorld())) {
@@ -338,9 +338,31 @@ public class IrisToolbelt {
} }
public static void applyPregenPerformanceProfile(Engine engine) { public static void applyPregenPerformanceProfile(Engine engine) {
PlatformChunkGenerator generator = resolvePlatformGenerator(engine);
if (generator != null) {
PregenPerformanceProfile.applyToGenerator(generator);
return;
}
PregenPerformanceProfile.apply(engine); 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() { public static boolean supportsStrictSerialPregeneration() {
return PaperLib.isPaper(); return PaperLib.isPaper();
} }
@@ -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<K, V> {
private final int maximumSize;
private transient LinkedHashMap<K, V> 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<? super K, ? extends V> mappingFunction) {
K requiredKey = Objects.requireNonNull(key, "Cache key");
Function<? super K, ? extends V> 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<K> 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();
}
}
@@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.data.cache.AtomicCache; 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.Engine;
import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.DependsOn; 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.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.project.context.IrisContext;
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -81,9 +81,8 @@ public class IrisBiome extends IrisRegistrant implements IRare {
private final transient AtomicCache<Color> cacheColorDepositLoad = new AtomicCache<>(); private final transient AtomicCache<Color> cacheColorDepositLoad = new AtomicCache<>();
private final transient AtomicCache<CNG> childrenCell = new AtomicCache<>(); private final transient AtomicCache<CNG> childrenCell = new AtomicCache<>();
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
private final transient ConcurrentLinkedHashMap<Long, CNG> biomeGenerators = new ConcurrentLinkedHashMap.Builder<Long, CNG>() private final transient LazyBoundedCache<Long, CNG> biomeGenerators =
.maximumWeightedCapacity(BIOME_GENERATOR_CACHE_SIZE) new LazyBoundedCache<>(BIOME_GENERATOR_CACHE_SIZE);
.build();
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
private transient volatile SeededBiomeGenerator recentBiomeGenerator; private transient volatile SeededBiomeGenerator recentBiomeGenerator;
@@ -20,6 +20,7 @@ package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.AtomicCache; 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.Engine;
import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.Required; 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.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.iris.util.project.stream.ProceduralStream;
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@@ -73,10 +73,8 @@ public class IrisExpressionLoad {
Collections.synchronizedMap(new IdentityHashMap<>()); Collections.synchronizedMap(new IdentityHashMap<>());
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
private transient final ConcurrentLinkedHashMap<StandaloneStyleKey, CNG> standaloneStyleCache = private transient final LazyBoundedCache<StandaloneStyleKey, CNG> standaloneStyleCache =
new ConcurrentLinkedHashMap.Builder<StandaloneStyleKey, CNG>() new LazyBoundedCache<>(STYLE_CACHE_SIZE);
.maximumWeightedCapacity(STYLE_CACHE_SIZE)
.build();
public double getValue(RNG rng, IrisData data, double x, double z) { public double getValue(RNG rng, IrisData data, double x, double z) {
if (engineValue != null) { if (engineValue != null) {
@@ -155,10 +153,7 @@ public class IrisExpressionLoad {
private static final class EngineCache { private static final class EngineCache {
private final AtomicCache<ProceduralStream<Double>> stream = new AtomicCache<>(); private final AtomicCache<ProceduralStream<Double>> stream = new AtomicCache<>();
private final AtomicCache<Double> value = new AtomicCache<>(); private final AtomicCache<Double> value = new AtomicCache<>();
private final ConcurrentLinkedHashMap<Long, CNG> styles = private final LazyBoundedCache<Long, CNG> styles = new LazyBoundedCache<>(STYLE_CACHE_SIZE);
new ConcurrentLinkedHashMap.Builder<Long, CNG>()
.maximumWeightedCapacity(STYLE_CACHE_SIZE)
.build();
} }
private static final class StandaloneStyleKey { private static final class StandaloneStyleKey {
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.object; package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData; 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.framework.Engine;
import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber; 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.CNG;
import art.arcane.iris.util.project.noise.ExpressionNoise; import art.arcane.iris.util.project.noise.ExpressionNoise;
import art.arcane.iris.util.project.noise.ImageNoise; import art.arcane.iris.util.project.noise.ImageNoise;
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@@ -54,10 +54,8 @@ public class IrisGeneratorStyle {
private static final ConcurrentHashMap<String, String> ACTIVE_CACHE_KEYS = new ConcurrentHashMap<>(); private static final ConcurrentHashMap<String, String> ACTIVE_CACHE_KEYS = new ConcurrentHashMap<>();
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
private final transient ConcurrentLinkedHashMap<GeneratorCacheKey, CNG> generatorCache = private final transient LazyBoundedCache<GeneratorCacheKey, CNG> generatorCache =
new ConcurrentLinkedHashMap.Builder<GeneratorCacheKey, CNG>() new LazyBoundedCache<>(GENERATOR_CACHE_SIZE);
.maximumWeightedCapacity(GENERATOR_CACHE_SIZE)
.build();
@Desc("The base noise style. Used when neither expression nor imageMap is set; a failed expression also falls back to this style.") @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; private NoiseStyle style = NoiseStyle.FLAT;
@@ -22,6 +22,7 @@ import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.AtomicCache; 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.Engine;
import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.framework.LootResolver;
import art.arcane.iris.engine.object.annotations.ArrayType; 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.iris.util.common.data.DataProvider;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.CNG;
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
@@ -69,10 +69,8 @@ public class IrisObjectPlacement {
private static final int SURFACE_WARP_CACHE_SIZE = 8; private static final int SURFACE_WARP_CACHE_SIZE = 8;
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
private final transient ConcurrentLinkedHashMap<SurfaceWarpCacheKey, CNG> surfaceWarpCache = private final transient LazyBoundedCache<SurfaceWarpCacheKey, CNG> surfaceWarpCache =
new ConcurrentLinkedHashMap.Builder<SurfaceWarpCacheKey, CNG>() new LazyBoundedCache<>(SURFACE_WARP_CACHE_SIZE);
.maximumWeightedCapacity(SURFACE_WARP_CACHE_SIZE)
.build();
@RegistryListResource(IrisObject.class) @RegistryListResource(IrisObject.class)
@Required @Required
@ArrayType(min = 1, type = String.class) @ArrayType(min = 1, type = String.class)
@@ -18,18 +18,24 @@
package art.arcane.iris.engine.object; 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.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.Snippet; 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 art.arcane.volmlib.util.math.RNG;
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.experimental.Accessors; 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") @Snippet("object-scale")
@Accessors(chain = true) @Accessors(chain = true)
@NoArgsConstructor @NoArgsConstructor
@@ -37,23 +43,16 @@ import lombok.experimental.Accessors;
@Desc("Scale objects") @Desc("Scale objects")
@Data @Data
public class IrisObjectScale { public class IrisObjectScale {
private static final int CACHE_INITIAL_CAPACITY = 256; private static final long CACHE_MIN_ESTIMATED_BYTES = 16L * 1024L * 1024L;
private static final int CACHE_MAX_WEIGHT = 8192; private static final long CACHE_MAX_ESTIMATED_BYTES = 64L * 1024L * 1024L;
private static final ConcurrentLinkedHashMap<CacheKey, KList<IrisObject>> cache private static final long CACHE_ENTRY_ESTIMATED_BYTES = 512L;
= new ConcurrentLinkedHashMap.Builder<CacheKey, KList<IrisObject>>() private static final long CACHE_VARIANT_ESTIMATED_BYTES = 256L;
.initialCapacity(CACHE_INITIAL_CAPACITY) private static final long CACHE_ESTIMATED_BYTES_PER_VOXEL = 128L;
.maximumWeightedCapacity(CACHE_MAX_WEIGHT) private static final int CACHE_LOAD_LOCK_COUNT = 256;
.concurrencyLevel(32) private static final ScaleCache CACHE = new ScaleCache(resolveCacheMaximumEstimatedBytes());
.build();
/** public static void invalidate(IrisData owner) {
* Coarse flush wired into IrisData dump/hotload: keys hold strong IrisObject references CACHE.invalidate(owner);
* (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();
} }
@MinNumber(0.01) @MinNumber(0.01)
@@ -104,37 +103,315 @@ public class IrisObjectScale {
if (origin == null) { if (origin == null) {
return null; return null;
} }
if (!shouldScale()) {
ScaleRequest request = snapshotRequest();
if (!request.shouldScale()) {
return origin; return origin;
} }
CacheKey key = new CacheKey(origin, size, minimumScale, maximumScale, variations, interpolation); int variantIndex = request.selectVariant(rng);
return cache.computeIfAbsent(key, (k) -> { CacheKey key = CACHE.key(origin, request, variantIndex);
KList<IrisObject> c = new KList<>(); CacheLookup lookup = CACHE.lookup(key);
if (lookup.variant() != null) {
if (size != 1) { return lookup.variant();
c.add(origin.scaled(size, interpolation));
return c;
} }
if (minimumScale == maximumScale) { synchronized (CACHE.loadLock(key)) {
c.add(origin.scaled(minimumScale, interpolation)); CacheLookup afterWait = CACHE.lookup(key);
return c; if (afterWait.variant() != null) {
return afterWait.variant();
} }
int vs = Math.max(1, Math.min(variations, 32)); IrisObject variant = origin.scaled(request.scaleAt(variantIndex), request.interpolation());
double step = (maximumScale - minimumScale) / (double) vs; long estimatedBytes = estimateVariant(variant);
for (int v = 0; v < vs; v++) { return CACHE.putIfCurrent(key, variant, estimatedBytes, lookup.generation());
c.add(origin.scaled(minimumScale + step * v, interpolation));
} }
return c;
}).getRandom(rng);
} }
public boolean canScaleBeyond() { public boolean canScaleBeyond() {
return shouldScale() && getMaxScale() > 1; 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<IrisObject> {
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<IrisObject> 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<CacheKey, CacheValue> entries;
private final Object[] loadLocks;
private final ReferenceQueue<IrisObject> 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<Map.Entry<CacheKey, CacheValue>> iterator = entries.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<CacheKey, CacheValue> 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<Map.Entry<CacheKey, CacheValue>> iterator = entries.entrySet().iterator();
while (estimatedBytes > maximumEstimatedBytes && iterator.hasNext()) {
Map.Entry<CacheKey, CacheValue> 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) {
} }
} }
@@ -85,9 +85,12 @@ import java.io.File;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Random; import java.util.Random;
import java.util.UUID; 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.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -524,6 +527,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
withExclusiveControl(() -> getEngine().hotload()); withExclusiveControl(() -> getEngine().hotload());
} }
@Override
public CompletableFuture<Void> 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) { static boolean shouldRunStudioHotload(boolean studio, boolean closing, boolean jigsawStudioActive) {
return studio && !closing && !jigsawStudioActive; return studio && !closing && !jigsawStudioActive;
} }
@@ -634,6 +646,21 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
return future; return future;
} }
CompletableFuture<Void> withExclusiveControlFuture(
Runnable operation,
long acquisitionTimeout,
TimeUnit unit
) {
CompletableFuture<Void> future = new CompletableFuture<>();
J.a(() -> completeExclusiveControlFuture(
loadLock,
operation,
future,
acquisitionTimeout,
unit));
return future;
}
static void completeExclusiveControlFuture( static void completeExclusiveControlFuture(
GenerationStageGate gate, GenerationStageGate gate,
Runnable operation, Runnable operation,
@@ -666,6 +693,55 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
} }
} }
static void completeExclusiveControlFuture(
GenerationStageGate gate,
Runnable operation,
CompletableFuture<Void> future,
long acquisitionTimeout,
TimeUnit unit
) {
GenerationStageGate activeGate = Objects.requireNonNull(gate, "Exclusive control gate");
Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation");
CompletableFuture<Void> 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) { public void touch(World world) {
getEngine(world); getEngine(world);
} }
@@ -935,6 +1011,32 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
permits.acquire(permitCount); 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() { void releaseExclusive() {
permits.release(permitCount); permits.release(permitCount);
} }
@@ -26,7 +26,9 @@ import art.arcane.iris.util.common.data.DataProvider;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public interface PlatformChunkGenerator extends Hotloadable, DataProvider { public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
@Nullable @Nullable
@@ -47,6 +49,23 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
return CompletableFuture.completedFuture(null); return CompletableFuture.completedFuture(null);
} }
default CompletableFuture<Void> 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(); boolean isStudio();
default boolean isClosing() { default boolean isClosing() {
@@ -14,6 +14,11 @@ import java.util.function.Function;
public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> { public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> {
private final Map<Key, Map<Key, T>> map = new KMap<>(); private final Map<Key, Map<Key, T>> map = new KMap<>();
private transient volatile long modificationRevision;
public long modificationRevision() {
return modificationRevision;
}
public int size() { public int size() {
return map.values().stream().mapToInt(Map::size).sum(); return map.values().stream().mapToInt(Map::size).sum();
@@ -40,13 +45,23 @@ public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> {
} }
public @Nullable T put(@NonNull IrisBlockVector vector, @NonNull T value) { 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); .put(relative(vector), value);
modificationRevision++;
return previous;
} }
public @Nullable T computeIfAbsent(@NonNull IrisBlockVector vector, @NonNull Function<@NonNull IrisBlockVector, @NonNull T> mappingFunction) { public @Nullable T computeIfAbsent(@NonNull IrisBlockVector vector, @NonNull Function<@NonNull IrisBlockVector, @NonNull T> mappingFunction) {
return map.computeIfAbsent(chunk(vector), k -> new KMap<>()) boolean[] inserted = new boolean[1];
.computeIfAbsent(relative(vector), $ -> mappingFunction.apply(vector)); 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") @SuppressWarnings("unchecked")
@@ -61,6 +76,10 @@ public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> {
return chunk.isEmpty() ? null : chunk; return chunk.isEmpty() ? null : chunk;
}); });
if (removed[0] != null) {
modificationRevision++;
}
return (T) removed[0]; return (T) removed[0];
} }
@@ -69,6 +88,9 @@ public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> {
} }
public void clear() { public void clear() {
if (!map.isEmpty()) {
modificationRevision++;
}
map.clear(); map.clear();
} }
@@ -193,6 +215,7 @@ public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> {
public void remove() { public void remove() {
if (relativeIterator == null) throw new IllegalStateException("No element to remove"); if (relativeIterator == null) throw new IllegalStateException("No element to remove");
relativeIterator.remove(); relativeIterator.remove();
modificationRevision++;
} }
} }
@@ -230,6 +253,7 @@ public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> {
public void remove() { public void remove() {
if (relativeIterator == null) throw new IllegalStateException("No element to remove"); if (relativeIterator == null) throw new IllegalStateException("No element to remove");
relativeIterator.remove(); relativeIterator.remove();
modificationRevision++;
} }
@Override @Override
@@ -267,6 +291,7 @@ public class VectorMap<T> implements Iterable<Map.Entry<IrisBlockVector, T>> {
public void remove() { public void remove() {
if (relativeIterator == null) throw new IllegalStateException("No element to remove"); if (relativeIterator == null) throw new IllegalStateException("No element to remove");
relativeIterator.remove(); relativeIterator.remove();
modificationRevision++;
} }
@Override @Override
@@ -31,12 +31,14 @@ public class CachedStream2D<T> extends BasicStream<T> implements ProceduralStrea
private final ProceduralStream<T> stream; private final ProceduralStream<T> stream;
private final WorldCache2D<T> cache; private final WorldCache2D<T> cache;
private final Engine engine; private final Engine engine;
private final boolean fastCache;
private final boolean chunked = true; private final boolean chunked = true;
public CachedStream2D(String name, Engine engine, ProceduralStream<T> stream, int size) { public CachedStream2D(String name, Engine engine, ProceduralStream<T> stream, int size) {
super(); super();
this.stream = stream; this.stream = stream;
this.engine = engine; this.engine = engine;
this.fastCache = Boolean.getBoolean("iris.cache.fast");
cache = WorldCache2D.<T>ofInts(stream::get, size, () -> new ChunkCache2D<>("iris")); cache = WorldCache2D.<T>ofInts(stream::get, size, () -> new ChunkCache2D<>("iris"));
IrisServices.get(PreservationRegistry.class).registerCache(this); IrisServices.get(PreservationRegistry.class).registerCache(this);
} }
@@ -76,6 +78,10 @@ public class CachedStream2D<T> extends BasicStream<T> implements ProceduralStrea
return cache.getMaxSize(); return cache.getMaxSize();
} }
public boolean usesFastCache() {
return fastCache;
}
@Override @Override
public boolean isClosed() { public boolean isClosed() {
return engine.isClosed(); return engine.isClosed();
@@ -289,6 +289,90 @@ public class WorldReplacementBootstrapTest {
assertTrue(published.stream().allMatch(transaction -> transaction.phase() == Phase.PUBLISHED)); 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<Transaction> 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 @Test
public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception { public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception {
for (String generator : List.of("", " ")) { for (String generator : List.of("", " ")) {
@@ -322,8 +322,9 @@ public class WorldReplacementFilesystemTest {
public void rejectsSymlinkInsidePackAndNonDirectoryArtifacts() throws Exception { public void rejectsSymlinkInsidePackAndNonDirectoryArtifacts() throws Exception {
WorldReplacementFilesystem.ReplacementPaths linkedPaths = paths("inside-pack-link", TRANSACTION_ID); WorldReplacementFilesystem.ReplacementPaths linkedPaths = paths("inside-pack-link", TRANSACTION_ID);
Path pack = Files.createDirectories(linkedPaths.stage().resolve("iris/pack")); 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(); 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)); 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 @Test
public void ignoresGeneratedAuthoringMetadataWithoutIgnoringNestedPackContent() throws Exception { public void ignoresGeneratedAuthoringMetadataWithoutIgnoringNestedPackContent() throws Exception {
Path pack = temporaryFolder.newFolder("generated-metadata").toPath(); 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"); 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 { private String readPackContent(Path worldDirectory) throws Exception {
return Files.readString(packContent(worldDirectory)); return Files.readString(packContent(worldDirectory));
} }
@@ -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<String> 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<String> 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<Class<? extends IrisRegistrant>, ResourceLoader<? extends IrisRegistrant>> 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<TestRegistrant> {
private final List<String> loaded = Collections.synchronizedList(new ArrayList<>());
private final Set<String> 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<String> loadedKeys() {
return List.copyOf(loaded);
}
private Set<String> 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";
}
}
}
@@ -20,6 +20,8 @@ package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData; 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.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformStructureHooks;
@@ -120,6 +122,69 @@ public class PackDownloaderTest {
assertTrue(PackDownloader.isBuiltInPack("underworld")); 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 @Test
public void recognizesOnlyHttpZipUrlsAsDirectSources() { public void recognizesOnlyHttpZipUrlsAsDirectSources() {
assertTrue(PackDownloader.isDirectZipUrl("https://packs.example.test/overworld.zip")); assertTrue(PackDownloader.isDirectZipUrl("https://packs.example.test/overworld.zip"));
@@ -654,6 +719,23 @@ public class PackDownloaderTest {
return root.toFile(); 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 { private static void writeDimension(Path root, String key) throws IOException {
Files.writeString( Files.writeString(
root.resolve("dimensions/" + key + ".json"), root.resolve("dimensions/" + key + ".json"),
@@ -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<IrisBiome> 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);
}
}
}
@@ -2,7 +2,12 @@ package art.arcane.iris.core.pregenerator.methods;
import org.junit.Test; 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.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
public class AsyncPregenMethodConcurrencyCapTest { public class AsyncPregenMethodConcurrencyCapTest {
@Test @Test
@@ -23,11 +28,13 @@ public class AsyncPregenMethodConcurrencyCapTest {
} }
@Test @Test
public void paperLikeConcurrencyProvisionsForWorldGenThreadBump() { public void paperLikeConcurrencyUsesDetectedWorkerPoolWhenAvailable() {
assertEquals(32, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 32)); assertEquals(4, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 32));
assertEquals(16, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 16)); assertEquals(4, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 16));
assertEquals(24, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 24)); assertEquals(24, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 24));
assertEquals(16, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 8)); assertEquals(16, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 8));
assertEquals(32, AsyncPregenMethod.computePaperLikeRecommendedCap(
AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 16)));
} }
@Test @Test
@@ -42,4 +49,32 @@ public class AsyncPregenMethodConcurrencyCapTest {
assertEquals(128, AsyncPregenMethod.selectConcurrencyCap(128, false)); assertEquals(128, AsyncPregenMethod.selectConcurrencyCap(128, false));
assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(0, false)); assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(0, false));
} }
@Test
public void slowRequestObservationDoesNotCompleteOrReplacePendingFuture() {
CompletableFuture<String> request = new CompletableFuture<>();
AtomicInteger slowRequests = new AtomicInteger();
CompletableFuture<String> 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<String> request = CompletableFuture.completedFuture("generated");
AtomicInteger slowRequests = new AtomicInteger();
AsyncPregenMethod.observeSlowRequest(request, Runnable::run, slowRequests::incrementAndGet);
assertEquals(0, slowRequests.get());
assertEquals("generated", request.join());
}
} }
@@ -53,6 +53,14 @@ public class EngineMaintenanceTest {
assertFalse(plan.heapPressure()); 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 @Test
public void nestedMantleClosedFailureIsRecognized() { public void nestedMantleClosedFailureIsRecognized() {
IllegalStateException cause = new IllegalStateException("Mantle is closed"); IllegalStateException cause = new IllegalStateException("Mantle is closed");
@@ -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<Void> 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);
}
}
@@ -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<String, Object> 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<Integer, Object> 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<String, Object> 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<Future<Object>> 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<Object> 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<IdentityKey, Object> 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);
}
}
}
@@ -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<Thread> secondThread = new AtomicReference<>();
try {
Future<IrisObject> first = executor.submit(() -> scale.get(new RNG(1L), origin));
assertTrue(origin.firstScaleEntered.await(5L, TimeUnit.SECONDS));
Future<IrisObject> 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;
}
}
}
@@ -16,6 +16,7 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -23,6 +24,7 @@ import java.util.concurrent.locks.LockSupport;
import java.util.function.Supplier; import java.util.function.Supplier;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; 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<Void> 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<Void> 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 @Test
public void queuedStageIsRejectedAfterCloseBegins() throws Exception { public void queuedStageIsRejectedAfterCloseBegins() throws Exception {
AtomicBoolean closing = new AtomicBoolean(false); AtomicBoolean closing = new AtomicBoolean(false);