refactor(streaming): own presentation retirement in pipeline

Move full and near-tier presentation retirement out of GameWindow and into a concrete pipeline-owned stage mapper. Harden the retry ledger against reentrancy and committed visibility observer failures, enforce a single state/resource ownership graph, and split concrete versus legacy controller construction so callbacks cannot be silently ignored.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 22:07:36 +02:00
parent dc39772bf9
commit ea7ffbc186
10 changed files with 860 additions and 140 deletions

View file

@ -136,14 +136,23 @@ public sealed class LandblockRetirementTicket
}
/// <summary>
/// Game-window-lifetime owner for landblock retirement. World-state detachment
/// Pipeline-lifetime owner for landblock retirement. World-state detachment
/// commits first; renderer/cache/script cleanup advances afterward from a
/// per-owner ledger. The coordinator is intentionally shared by replacement
/// <see cref="StreamingController"/> instances so a quality-setting rebuild
/// cannot abandon an unfinished retirement.
/// per-owner ledger. Reentrant begin/advance requests are serialized so an
/// owner callback cannot recursively replay its active stage or mutate the
/// pending-ticket map while it is being enumerated.
/// </summary>
public sealed class LandblockRetirementCoordinator
{
private enum RequestKind : byte
{
BeginFull,
BeginNearLayer,
Advance,
}
private readonly record struct Request(RequestKind Kind, uint LandblockId);
private const LandblockRetirementStage CoreStages =
LandblockRetirementStage.MeshReferences
| LandblockRetirementStage.StaticScripts
@ -165,6 +174,9 @@ public sealed class LandblockRetirementCoordinator
_requiredPresentationStages;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly List<uint> _completedIds = new();
private readonly Queue<Request> _requests = new();
private readonly HashSet<Request> _seenDuringDrain = new();
private bool _drainingRequests;
public LandblockRetirementCoordinator(
GpuWorldState state,
@ -186,13 +198,19 @@ public sealed class LandblockRetirementCoordinator
public bool IsPending(uint landblockId) =>
_pending.ContainsKey(Canonicalize(landblockId));
public void BeginFull(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.Full);
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_state, state);
public void BeginNearLayer(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.NearLayer);
public void BeginFull(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
public void Advance()
public void BeginNearLayer(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginNearLayer, Canonicalize(landblockId)));
public void Advance() => EnqueueRequest(
new Request(RequestKind.Advance, 0u));
private void AdvanceCore()
{
if (_pending.Count == 0)
return;
@ -250,7 +268,7 @@ public sealed class LandblockRetirementCoordinator
return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required);
}
private void Begin(uint landblockId, LandblockRetirementKind kind)
private void BeginCore(uint landblockId, LandblockRetirementKind kind)
{
uint canonical = Canonicalize(landblockId);
if (_pending.TryGetValue(canonical, out List<LandblockRetirementTicket>? existing))
@ -259,20 +277,52 @@ public sealed class LandblockRetirementCoordinator
{
if (existing[i].Kind == kind)
{
AdvanceTicket(existing[i]);
LandblockRetirementTicket existingTicket = existing[i];
AdvanceTicket(existingTicket);
if (existingTicket.IsComplete)
{
existing.RemoveAt(i);
PendingCount--;
if (existing.Count == 0)
_pending.Remove(canonical);
}
return;
}
}
}
GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full
? _state.DetachLandblock(canonical)
: _state.DetachNearLayer(canonical);
if (stateRetirement is null)
return;
LandblockRetirementStage required =
CoreStages | _requiredPresentationStages(kind);
GpuLandblockRetirement? stateRetirement = null;
Exception? detachmentObserverFailure = null;
GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch();
try
{
// The detach methods create a nested batch. Holding this outer
// transaction lets us retain their returned teardown context
// before visibility observers run at the outer commit boundary.
stateRetirement = kind == LandblockRetirementKind.Full
? _state.DetachLandblock(canonical)
: _state.DetachNearLayer(canonical);
}
finally
{
try
{
mutation.Dispose();
}
catch (Exception error)
{
detachmentObserverFailure = error;
}
}
if (stateRetirement is null)
{
if (detachmentObserverFailure is not null)
throw detachmentObserverFailure;
return;
}
var ticket = new LandblockRetirementTicket(stateRetirement, required);
if (existing is null)
{
@ -290,6 +340,13 @@ public sealed class LandblockRetirementCoordinator
if (existing.Count == 0)
_pending.Remove(canonical);
}
// Visibility is a delivered-by-attempt broadcast: every subscriber is
// invoked and failures are aggregated, but failed subscribers are not
// retained as replayable work. Complete the presentation teardown
// from the preserved detach receipt, then surface that committed-edge
// failure to the caller without inventing a one-frame pending fence.
if (detachmentObserverFailure is not null)
throw detachmentObserverFailure;
}
private void AdvanceTicket(LandblockRetirementTicket ticket)
@ -325,4 +382,70 @@ public sealed class LandblockRetirementCoordinator
private static uint Canonicalize(uint id) =>
(id & 0xFFFF0000u) | 0xFFFFu;
private void EnqueueRequest(Request request)
{
if (_drainingRequests)
{
if (_seenDuringDrain.Add(request))
_requests.Enqueue(request);
return;
}
_drainingRequests = true;
_seenDuringDrain.Clear();
_requests.Clear();
_seenDuringDrain.Add(request);
_requests.Enqueue(request);
List<Exception>? failures = null;
try
{
while (_requests.TryDequeue(out Request pending))
{
try
{
switch (pending.Kind)
{
case RequestKind.BeginFull:
BeginCore(
pending.LandblockId,
LandblockRetirementKind.Full);
break;
case RequestKind.BeginNearLayer:
BeginCore(
pending.LandblockId,
LandblockRetirementKind.NearLayer);
break;
case RequestKind.Advance:
AdvanceCore();
break;
default:
throw new InvalidOperationException(
$"Unknown retirement request {pending.Kind}.");
}
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
}
finally
{
_requests.Clear();
_seenDuringDrain.Clear();
_drainingRequests = false;
}
if (failures is { Count: 1 })
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(failures[0])
.Throw();
if (failures is { Count: > 1 })
{
throw new AggregateException(
"One or more serialized landblock retirement requests failed.",
failures);
}
}
}