fix(streaming): stop replaying committed recenter retirements
Root cause: pending-only live projection buckets were misclassified as landblock presentation owners during origin recentering. That manufactured a second full cleanup receipt for a generation whose first receipt was still advancing; the duplicate guard threw and the broad retry path replayed the already-committed detach 243 times. Keep pending live projections through the spatial identity map without issuing another receipt, and fail fast when a receipt-ledger invariant occurs after detachment. Evidence: docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md. Release suite, lifecycle gate, and nine-stop soak pass.
This commit is contained in:
parent
c65559d8f8
commit
01f4791e95
9 changed files with 309 additions and 9 deletions
|
|
@ -293,7 +293,7 @@ public sealed class GpuWorldStateVisibilityTests
|
|||
|
||||
Assert.Null(result.ObserverFailure);
|
||||
Assert.Equal(
|
||||
[firstLandblock, secondLandblock, pendingLandblock],
|
||||
[firstLandblock, secondLandblock],
|
||||
result.Landblocks.Select(retirement => retirement.LandblockId));
|
||||
Assert.Contains(
|
||||
result.Landblocks.Single(
|
||||
|
|
@ -311,10 +311,9 @@ public sealed class GpuWorldStateVisibilityTests
|
|||
result.Landblocks.Single(
|
||||
retirement => retirement.LandblockId == secondLandblock).Entities,
|
||||
entity => ReferenceEquals(entity, player));
|
||||
Assert.Same(
|
||||
pending,
|
||||
Assert.Single(result.Landblocks.Single(
|
||||
retirement => retirement.LandblockId == pendingLandblock).Entities));
|
||||
Assert.DoesNotContain(
|
||||
result.Landblocks,
|
||||
retirement => retirement.LandblockId == pendingLandblock);
|
||||
|
||||
Assert.Empty(state.LoadedLandblockIds);
|
||||
Assert.Empty(state.Entities);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,112 @@ public sealed class LandblockPresentationPipelineTests
|
|||
Assert.Equal([0x2223FFFFu], enqueued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_PendingOnlyLiveProjectionKeepsItsExistingRetirementOwner()
|
||||
{
|
||||
const uint oldLandblockId = 0x2424FFFFu;
|
||||
var live = Entity(1u, serverGuid: 0x70000004u);
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
oldLandblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
state.PlaceLiveEntityProjection(oldLandblockId, live);
|
||||
|
||||
bool holdCleanup = true;
|
||||
var retirements = new LandblockRetirementCoordinator(
|
||||
state,
|
||||
ticket => ticket.RunOnce(
|
||||
LandblockRetirementStage.EntityLighting,
|
||||
() =>
|
||||
{
|
||||
if (holdCleanup)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"injected retained cleanup");
|
||||
}
|
||||
}),
|
||||
_ => LandblockRetirementStage.EntityLighting);
|
||||
retirements.BeginFull(oldLandblockId);
|
||||
Assert.Equal(1, retirements.PendingCount);
|
||||
Assert.Equal(1, state.PendingLiveEntityCount);
|
||||
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x24, 0x24));
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
retirementCoordinator: retirements,
|
||||
workBudgetOptions: GenerousWorkBudget());
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x25, 0x25, isSealedDungeon: false));
|
||||
Assert.True(Converge(recenter, controller, 0x24, 0x24));
|
||||
|
||||
Assert.Equal((0x25, 0x25), (origin.CenterX, origin.CenterY));
|
||||
Assert.Equal(1, retirements.PendingCount);
|
||||
Assert.Equal(1, state.PendingLiveEntityCount);
|
||||
|
||||
holdCleanup = false;
|
||||
controller.Tick(0x25, 0x25);
|
||||
Assert.Equal(0, retirements.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_CommittedReceiptInvariantFailsFastInsteadOfReplayingDetach()
|
||||
{
|
||||
const uint landblockId = 0x2626FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var retirements = new LandblockRetirementCoordinator(
|
||||
state,
|
||||
ticket => ticket.RunOnce(
|
||||
LandblockRetirementStage.EntityLighting,
|
||||
static () => throw new InvalidOperationException(
|
||||
"injected retained cleanup")),
|
||||
_ => LandblockRetirementStage.EntityLighting);
|
||||
retirements.BeginFull(landblockId);
|
||||
Assert.Equal(1, retirements.PendingCount);
|
||||
|
||||
// Directly violate the production publication fence so the recenter
|
||||
// obtains a genuinely conflicting receipt after its spatial commit.
|
||||
// The invariant must surface once; it must not become retry work.
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x26, 0x26));
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
retirementCoordinator: retirements,
|
||||
workBudgetOptions: GenerousWorkBudget());
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
Assert.False(recenter.Begin(0x27, 0x27, isSealedDungeon: false));
|
||||
|
||||
StreamingMutationException error = Assert.Throws<StreamingMutationException>(
|
||||
() => controller.Tick(0x26, 0x26));
|
||||
|
||||
Assert.True(error.MutationCommitted);
|
||||
Assert.IsType<InvalidOperationException>(error.InnerException);
|
||||
Assert.False(state.IsLoaded(landblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_DetachesFullTwentyFiveByTwentyFiveWindowAtomically()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -595,6 +595,34 @@ public sealed class LandblockRetirementCoordinatorTests
|
|||
Assert.Equal(0, detachedCallbacks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenterAdoption_PendingOnlyLiveProjectionDoesNotCreateSecondFullReceipt()
|
||||
{
|
||||
const uint landblockId = 0x4648FFFFu;
|
||||
WorldEntity live = Entity(1, serverGuid: 0x70000003u);
|
||||
GpuWorldState state = StateWith(landblockId, live);
|
||||
LandblockRetirementCoordinator coordinator =
|
||||
LandblockRetirementCoordinator.CreateBudgeted(
|
||||
state,
|
||||
ticket => AdvancePresentationStep(ticket),
|
||||
ticket => CompletePresentation(ticket));
|
||||
|
||||
// The ordinary retirement has already detached every landblock-owned
|
||||
// resource. The still-live entity is parked in the pending bucket
|
||||
// while that exact cleanup receipt advances asynchronously.
|
||||
coordinator.BeginFull(landblockId);
|
||||
Assert.Equal(1, coordinator.PendingCount);
|
||||
Assert.False(state.IsLoaded(landblockId));
|
||||
|
||||
GpuWorldRecenterRetirement recenter =
|
||||
state.DetachAllForOriginRecenter();
|
||||
|
||||
Assert.Empty(recenter.Landblocks);
|
||||
Assert.Null(coordinator.AdoptDetachedFull(recenter.Landblocks));
|
||||
Assert.Equal(1, coordinator.PendingCount);
|
||||
Assert.True(coordinator.IsPending(landblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenterAdoption_ObserverFailureRetainsEveryCleanupReceipt()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue