Merge branch 'fix/archreview-p2' into main (P2 tier: completeness & polish)

# Conflicts:
#	archreview/remediation/00-tracking.md
#	clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs
#	clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs
#	src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs
This commit is contained in:
Joseph Doherty
2026-07-12 22:12:41 -04:00
91 changed files with 7680 additions and 681 deletions
@@ -106,11 +106,23 @@ public sealed class EventStreamService(
options.Value.Sessions.MaxEventSubscribersPerSession);
}
int streamQueueDepth = 0;
IAsyncEnumerator<MxEvent> reader = subscriber.Reader
.ReadAllAsync(cancellationToken)
.GetAsyncEnumerator(cancellationToken);
// GWC-15: register this subscriber's channel as a live backlog source instead of
// reconciling the queue-depth gauge on every event. The gauge previously read the
// bounded channel's Count (which takes the channel's internal lock) and adjusted the
// metric under its own lock on every streamed event. Now the metric reads Count only
// when it is scraped (ObservableGauge callback) or projected (GetSnapshot), summing the
// live backlog across every registered subscriber — the same "buffered, not yet
// delivered" aggregate the per-event push reported, but with no per-event lock traffic.
// Disposing the registration in the finally removes this subscriber's contribution, so
// the gauge returns to the other subscribers' backlog (zero when none remain) on
// disconnect. CanCount guards a channel that ever cannot report Count (contributes 0).
IDisposable backlogRegistration = metrics.RegisterEventStreamBacklogSource(
() => subscriber.Reader.CanCount ? subscriber.Reader.Count : 0);
try
{
// Emit order for a resume: the ReplayGap sentinel FIRST (only when events were
@@ -169,32 +181,21 @@ public sealed class EventStreamService(
continue;
}
// Queue-depth gauge tracks events the pump has fanned into this subscriber's
// channel but the client has not yet consumed — the same "buffered, not yet
// delivered" quantity the original per-RPC channel reported. The bounded
// subscriber channel supports counting, so reconcile the gauge to the current
// backlog; falling back to a no-op delta if a channel ever cannot count.
int backlog = subscriber.Reader.CanCount ? subscriber.Reader.Count : streamQueueDepth;
int delta = backlog - streamQueueDepth;
if (delta != 0)
{
streamQueueDepth = backlog;
metrics.AdjustGrpcEventStreamQueueDepth(delta);
}
// The queue-depth gauge is maintained lazily via the backlog registration above
// (GWC-15): the metric reads this subscriber's channel Count only when scraped,
// so there is no per-event gauge bookkeeping on this hot path.
yield return mxEvent;
}
}
finally
{
await reader.DisposeAsync().ConfigureAwait(false);
subscriber.Dispose();
if (streamQueueDepth != 0)
{
metrics.AdjustGrpcEventStreamQueueDepth(-streamQueueDepth);
streamQueueDepth = 0;
}
// Remove this subscriber's live backlog contribution before disposing the lease so
// the gauge stops counting a channel that is about to be completed; after this the
// gauge reflects only the remaining subscribers (zero when none remain).
backlogRegistration.Dispose();
subscriber.Dispose();
metrics.StreamDisconnected("Detached");
}
@@ -152,9 +152,15 @@ public sealed class MxAccessGatewayService(
.WithCancellation(context.CancellationToken)
.ConfigureAwait(false))
{
Stopwatch stopwatch = Stopwatch.StartNew();
// GWC-06: measure send latency with the allocation-free timestamp API rather
// than allocating a Stopwatch object per event per subscriber on the highest-
// volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact
// same wall-clock span the former Stopwatch.StartNew()/.Elapsed did.
long sendStartTimestamp = Stopwatch.GetTimestamp();
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
metrics.RecordEventStreamSend(
publicEvent.Family.ToString(),
Stopwatch.GetElapsedTime(sendStartTimestamp));
}
}
catch (Exception exception) when (exception is not RpcException)
@@ -65,7 +65,16 @@ public sealed class MxAccessGrpcMapper
{
ArgumentNullException.ThrowIfNull(workerEvent);
return workerEvent.Event?.Clone() ?? new MxEvent
// GWC-07 / IPC-05: ownership transfer, not a deep clone. The enclosing WorkerEvent is
// parsed fresh from a single pipe frame in WorkerClient's read loop and is discarded
// immediately after this mapping — the SessionEventDistributor pump is its single
// consumer (GWC-01 claims the worker event channel as single-reader), so nothing else
// aliases or mutates workerEvent.Event. We therefore move the inner MxEvent into the
// outbound graph instead of cloning it. Downstream the pump fans this one MxEvent to
// every subscriber and retains it in the replay ring, but that sharing is READ-ONLY
// (subscribers only yield/filter it), so a single shared instance is safe. If a second
// consumer of WorkerEvent is ever added, restore a .Clone() here to re-isolate.
return workerEvent.Event ?? new MxEvent
{
Family = MxEventFamily.Unspecified,
RawStatus = "Worker event did not contain a public event payload.",