Fixed replacement staging stalls

- Concurrent replacement requests report busy before waiting for the manager lock
- Replacement staging reports localized phases and elapsed time
- Player progress messages use entity scheduling
- Pack copies flush completed files after traversal
- Replacement admission and progress tests cover scheduling and cleanup
- Language editor tests use a local catalog response
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-05 20:30:30 -04:00
1 parent f2887632fe
commit 27d511d99a
25 files changed
+671 -128

No files matched your search

@@ -15,6 +15,7 @@ import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
import art.arcane.iris.core.lifecycle.WorldReplacementSeed;
import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.core.service.StudioSVC;
@@ -115,7 +116,7 @@ public final class PendingWorldReplacementManager implements Listener {
return worldKey;
}
public synchronized StagedReplacement stageReplacement(
public StagedReplacement stageReplacement(
VolmitSender sender,
NamespacedKey worldKey,
IrisDimension dimension,
@@ -128,132 +129,142 @@ public final class PendingWorldReplacementManager implements Listener {
OptionalLong seedSelection = requestedSeed == null
? OptionalLong.empty()
: OptionalLong.of(requestedSeed.longValue());
IrisStartupValidation.requireWorldReplacementStagingReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
}
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString()
)) {
if (findTransaction(requiredWorldSlotKey) != null) {
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
}
ExactWorldSlotPathPolicy.Target target = resolveTarget(requiredWorldSlotKey);
requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment());
requireVanillaSlotEnabled(target.slotKind());
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
WorldReplacementFilesystem.requireExistingTarget(paths);
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks.");
}
); WorldReplacementProgress progress = WorldReplacementProgress.start(
requiredSender, requiredWorldKey.toString())) {
synchronized (this) {
IrisStartupValidation.requireWorldReplacementStagingReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
}
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
if (findTransaction(requiredWorldSlotKey) != null) {
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
}
ExactWorldSlotPathPolicy.Target target = resolveTarget(requiredWorldSlotKey);
requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment());
requireVanillaSlotEnabled(target.slotKind());
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
WorldReplacementFilesystem.requireExistingTarget(paths);
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_DATAPACKS);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks.");
}
boolean targetPresent = true;
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
worldName
);
Transaction transaction = null;
boolean journalWritten = false;
boolean configurationApplied = false;
try {
Files.createDirectory(paths.stage());
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
paths.target(),
paths.stage(),
seedSelection
);
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(
requiredSender,
requiredDimension,
paths.stage().toFile(),
effectiveSeed
);
if (installed == null) {
throw new IOException("Iris could not stage the dimension pack.");
}
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) {
WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId);
}
File stagedPack = IrisWorldStorage.requireActiveGenerationPackRoot(
paths.stage().toFile(),
effectiveSeed
);
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
transaction = new Transaction(
transactionId,
requiredWorldSlotKey,
worldName,
target.levelRoot(),
installed.getLoadKey(),
effectiveSeed,
packFingerprint,
originalConfiguration,
targetPresent,
Phase.PREPARED
);
writeTransaction(transaction);
journalWritten = true;
GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching(
boolean targetPresent = true;
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
worldName,
originalConfiguration,
installed.getLoadKey(),
effectiveSeed
worldName
);
if (!replacement.applied()) {
throw new IOException("bukkit.yml changed while the replacement was being staged.");
}
configurationApplied = true;
transaction = transaction.withPhase(Phase.ARMED);
writeTransaction(transaction);
return new StagedReplacement(
requiredWorldKey,
worldName,
installed.getLoadKey(),
effectiveSeed,
targetPresent,
datapacks.restartRequired()
);
} catch (Throwable failure) {
if (configurationApplied && transaction != null) {
try {
WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
replacement,
originalConfiguration
)) {
failure.addSuppressed(new IOException(
"bukkit.yml changed before the failed replacement could be restored."));
}
} catch (Throwable restoreFailure) {
failure.addSuppressed(restoreFailure);
Transaction transaction = null;
boolean journalWritten = false;
boolean configurationApplied = false;
try {
Files.createDirectory(paths.stage());
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SEED);
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
paths.target(),
paths.stage(),
seedSelection
);
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK);
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(
requiredSender,
requiredDimension,
paths.stage().toFile(),
effectiveSeed
);
if (installed == null) {
throw new IOException("Iris could not stage the dimension pack.");
}
}
if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) {
try {
WorldReplacementFilesystem.discardStage(paths);
if (journalWritten) {
deleteJournal(transactionId);
}
} catch (Throwable cleanupFailure) {
failure.addSuppressed(cleanupFailure);
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) {
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PLAYERS);
WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId);
}
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_VERIFY);
File stagedPack = IrisWorldStorage.requireActiveGenerationPackRoot(
paths.stage().toFile(),
effectiveSeed
);
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
transaction = new Transaction(
transactionId,
requiredWorldSlotKey,
worldName,
target.levelRoot(),
installed.getLoadKey(),
effectiveSeed,
packFingerprint,
originalConfiguration,
targetPresent,
Phase.PREPARED
);
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SAVE);
writeTransaction(transaction);
journalWritten = true;
GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
originalConfiguration,
installed.getLoadKey(),
effectiveSeed
);
if (!replacement.applied()) {
throw new IOException("bukkit.yml changed while the replacement was being staged.");
}
configurationApplied = true;
transaction = transaction.withPhase(Phase.ARMED);
writeTransaction(transaction);
return new StagedReplacement(
requiredWorldKey,
worldName,
installed.getLoadKey(),
effectiveSeed,
targetPresent,
datapacks.restartRequired()
);
} catch (Throwable failure) {
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_CLEANUP);
if (configurationApplied && transaction != null) {
try {
WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
replacement,
originalConfiguration
)) {
failure.addSuppressed(new IOException(
"bukkit.yml changed before the failed replacement could be restored."));
}
} catch (Throwable restoreFailure) {
failure.addSuppressed(restoreFailure);
}
}
if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) {
try {
WorldReplacementFilesystem.discardStage(paths);
if (journalWritten) {
deleteJournal(transactionId);
}
} catch (Throwable cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
if (failure instanceof IOException ioFailure) {
throw ioFailure;
}
throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure);
}
if (failure instanceof IOException ioFailure) {
throw ioFailure;
}
throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure);
}
}
}
@@ -0,0 +1,116 @@
package art.arcane.iris.core;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.volmlib.util.localization.MessageArgs;
import art.arcane.volmlib.util.localization.MessageKey;
import art.arcane.volmlib.util.localization.TextKey;
import org.bukkit.entity.Player;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public final class WorldReplacementProgress implements AutoCloseable {
private static final int HEARTBEAT_INTERVAL_TICKS = 200;
private final VolmitSender sender;
private final String worldKey;
private final Player player;
private final UUID languageAudience;
private final long startedAtNanos;
private String detail;
private int taskId = -1;
private boolean firstTick = true;
private boolean closed;
private WorldReplacementProgress(VolmitSender sender, String worldKey) {
this.sender = Objects.requireNonNull(sender, "sender");
this.worldKey = Objects.requireNonNull(worldKey, "worldKey");
player = sender.isPlayer() ? sender.player() : null;
languageAudience = player == null ? LanguageAudience.current() : player.getUniqueId();
startedAtNanos = System.nanoTime();
detail = IrisLanguage.text(
languageAudience,
RuntimeProgressMessages.WORLD_CREATE_STAGE_INITIALIZING,
MessageArgs.empty()
);
}
public static WorldReplacementProgress start(VolmitSender sender, String worldKey) {
WorldReplacementProgress progress = new WorldReplacementProgress(sender, worldKey);
progress.send(RuntimeProgressMessages.WORLD_REPLACE_START);
try {
progress.taskId = J.ar(progress::heartbeat, HEARTBEAT_INTERVAL_TICKS);
} catch (Throwable failure) {
IrisLogging.reportError("Could not start world replacement progress updates for " + worldKey + ".", failure);
}
return progress;
}
@Override
public synchronized void close() {
if (closed) {
return;
}
closed = true;
if (taskId >= 0) {
try {
J.car(taskId);
} catch (Throwable failure) {
IrisLogging.reportError("Could not cancel world replacement progress updates for " + worldKey + ".", failure);
}
}
}
public synchronized void stage(TextKey stage) {
if (closed || stage == null) {
return;
}
String detail = IrisLanguage.text(languageAudience, stage, MessageArgs.empty());
if (detail.equals(this.detail)) {
return;
}
this.detail = detail;
send(RuntimeProgressMessages.WORLD_REPLACE_PROGRESS);
}
private synchronized void heartbeat() {
if (closed) {
return;
}
if (firstTick) {
firstTick = false;
return;
}
send(RuntimeProgressMessages.WORLD_REPLACE_PROGRESS);
}
private void send(MessageKey key) {
try {
MessageArgs.Builder arguments = MessageArgs.builder().untrusted("world", worldKey);
if (key == RuntimeProgressMessages.WORLD_REPLACE_PROGRESS) {
arguments.untrusted("stage", detail)
.untrusted("elapsed", TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAtNanos));
}
String message = IrisLanguage.text(languageAudience, key, arguments.build());
if (player == null) {
sender.sendMessage(message);
} else {
J.runEntity(player, () -> deliverToPlayer(message));
}
} catch (Throwable failure) {
IrisLogging.reportError("Could not report world replacement progress for " + worldKey + ".", failure);
}
}
private synchronized void deliverToPlayer(String message) {
if (!closed) {
sender.sendMessage(message);
}
}
}
@@ -257,6 +257,8 @@ public class CommandIris implements DirectorExecutor {
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
+ seedDetail + ". Restart once to publish it. The current dimension is retained until Iris "
+ "verifies the replacement.");
} catch (LifecycleOperationCoordinator.BusyException busy) {
sender().sendMessage(C.YELLOW + busy.getMessage());
} catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank()