diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java index e87679454..bf596718e 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java @@ -457,9 +457,7 @@ public final class GenerationHistory { "signatureCapture" ); try (GenerationAdmission.CutoverLease ignored = admission.beginStartupCutover()) { - synchronized (this) { - return promotePendingLocked(requiredCapture, true, true); - } + return promoteCaptured(requiredCapture, true); } } @@ -478,9 +476,7 @@ public final class GenerationHistory { GenerationActivation promote(BoundarySignatureCapture signatureCapture) throws IOException { requireOpen(); - synchronized (GenerationHistory.this) { - return promotePendingLocked(Objects.requireNonNull(signatureCapture, "signature capture"), false, true); - } + return promoteCaptured(Objects.requireNonNull(signatureCapture, "signature capture"), false); } private void requireOpen() { @@ -497,34 +493,67 @@ public final class GenerationHistory { } } + private GenerationActivation promoteCaptured( + BoundarySignatureCapture signatureCapture, boolean startupRecovery + ) throws IOException { + Optional prepared; + synchronized (this) { + prepared = preparePromotionLocked(startupRecovery, true); + } + if (prepared.isEmpty()) { + return activeActivation(); + } + TerrainBoundarySignatureStore.Snapshot terrainSnapshot = publishTerrainSignatures(prepared.get(), signatureCapture); + synchronized (this) { + return completePromotionLocked(prepared.get(), terrainSnapshot); + } + } + private GenerationActivation promotePendingLocked( BoundarySignatureCapture signatureCapture, boolean startupRecovery, boolean validateSemantics + ) throws IOException { + Optional prepared = preparePromotionLocked(startupRecovery, validateSemantics); + if (prepared.isEmpty()) { + return store.activeActivation(); + } + return completePromotionLocked(prepared.get(), publishTerrainSignatures(prepared.get(), signatureCapture)); + } + + private Optional preparePromotionLocked( + boolean startupRecovery, boolean validateSemantics ) throws IOException { sync(); WorldChunkInventory inventory = recoverUnstoredClaims(startupRecovery); validateReferencedState(Optional.empty(), validateSemantics); Optional pending = store.pendingActivation(); if (pending.isEmpty()) { - return store.activeActivation(); + return Optional.empty(); } long outgoingActivationId = store.activeActivation().activationId(); ownership.assignUnassigned(inventory, outgoingActivationId); ownership.persist(); requireExplicitOwnership(inventory); - GenerationBoundary boundary = boundaries.publishOwnership( - pending.get().activationId(), - ownership - ); - TerrainBoundarySignatureStore.Snapshot terrainSnapshot; + long pendingActivationId = pending.get().activationId(); + return Optional.of(new PreparedPromotion(pendingActivationId, boundaries.publishOwnership(pendingActivationId, ownership))); + } + + private TerrainBoundarySignatureStore.Snapshot publishTerrainSignatures( + PreparedPromotion prepared, BoundarySignatureCapture signatureCapture + ) throws IOException { try (TerrainBoundarySignatureStore.SignatureSampler sampler = Objects.requireNonNull( - signatureCapture.capture(boundary), "boundary signature sampler" + signatureCapture.capture(prepared.boundary()), "boundary signature sampler" )) { - terrainSnapshot = terrainSignatures.publish(pending.get().activationId(), boundary, sampler); + return terrainSignatures.publish(prepared.activationId(), prepared.boundary(), sampler); } + } + + private GenerationActivation completePromotionLocked( + PreparedPromotion prepared, TerrainBoundarySignatureStore.Snapshot terrainSnapshot + ) throws IOException { GenerationActivation completed = store.completePendingTransition( - pending.get().activationId(), - boundary.identity(), + prepared.activationId(), + prepared.boundary().identity(), terrainSnapshot.identity() ); GenerationActivation activated = store.activatePending(completed.activationId()); @@ -1396,6 +1425,9 @@ public final class GenerationHistory { public interface BoundarySignatureCapture { TerrainBoundarySignatureStore.SignatureSampler capture(GenerationBoundary boundary) throws IOException; } + + private record PreparedPromotion(long activationId, GenerationBoundary boundary) { + } public record FreshCreation(Path dimensionRoot, Path packSource, String packFingerprint, long worldSeed, GenerationEpoch.DimensionContract dimensionContract, GenerationRegistryContract registryContract) { public FreshCreation { diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java index b3eddb747..15455769d 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java @@ -143,6 +143,37 @@ public class GenerationHistoryAdmissionTest { } } + @Test + public void liveBoundaryCaptureLeavesHistoryReadableFromOtherThreads() throws Exception { + GenerationHistory history = history(); + Path replacement = pack("replacement", "beta"); + GenerationActivation pending = history.stageUpdate(replacement, fingerprint(replacement), + history.activeEpoch().dimensionContract(), GenerationRegistryContract.empty(), 32); + ExecutorService tickThread = Executors.newSingleThreadExecutor(); + try (GenerationHistory.LiveCutover cutover = history.beginLiveCutover()) { + GenerationActivation promoted = cutover.promote(boundary -> new TerrainBoundarySignatureStore.SignatureSampler() { + @Override + public TerrainBoundarySignature sample(int blockX, int blockZ) { + return null; + } + + @Override + public void close() throws IOException { + try { + assertEquals(pending.activationId(), tickThread.submit(history::pendingActivation) + .get(1, TimeUnit.SECONDS).orElseThrow().activationId()); + } catch (InterruptedException | ExecutionException | TimeoutException failure) { + throw new IOException("History was unreadable while the boundary checkpoint waited", failure); + } + } + }); + assertEquals(pending.activationId(), promoted.activationId()); + } finally { + drain(tickThread); + } + assertEquals(pending.activationId(), history.activeActivation().activationId()); + } + @Test public void failedMetadataReadReleasesItsStageAdmission() throws Exception { GenerationHistory history = history();