docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)

Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 14:12:25 -04:00
parent 3a217b9484
commit 0ffd4bdbea
217 changed files with 1224 additions and 695 deletions
@@ -52,7 +52,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
internal static TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Hard deadline on the initial <see cref="DebugViewSnapshot"/> (WP2.3). The site builds
/// Hard deadline on the initial <see cref="DebugViewSnapshot"/> The site builds
/// it in milliseconds; if none arrives inside this window the site never answered (the
/// Ask was lost, the singleton moved mid-request, the instance actor is wedged) and the
/// session must FAIL rather than sit in the buffering phase accumulating live events
@@ -125,7 +125,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
/// <see cref="AlarmStateChanged"/>) that arrived before the snapshot was delivered.
/// Flushed (with per-entity dedup against the snapshot) when the snapshot arrives,
/// then never used again. Bounded by <see cref="MaxPreSnapshotBuffer"/> with drop-oldest
/// eviction (WP2.3): a snapshot that never arrives used to buffer without limit on the
/// eviction: a snapshot that never arrives used to buffer without limit on the
/// central node. Mutated only on the actor thread.
/// </summary>
private readonly Queue<object> _preSnapshotBuffer = new();
@@ -243,7 +243,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
_snapshotDelivered = true;
});
// Hard snapshot deadline (WP2.3). Nothing else ends a session stuck in the
// Hard snapshot deadline. Nothing else ends a session stuck in the
// buffering phase: the site's reply was lost, so no gRPC error fires, the stream
// keeps delivering events, and stream traffic does not renew the orphan net (which
// measures the consumer). Fail the session so the consumer is told and can reopen.
@@ -351,7 +351,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
_consumerLastSeenUtc = DateTime.UtcNow;
});
// Orphan safety net, CONSUMER-measured (WP2.3 follow-up). A periodic self-tick
// Orphan safety net, CONSUMER-measured. A periodic self-tick
// compares the consumer-last-seen stamp against ConsumerIdleTimeout; the previous
// SetReceiveTimeout(5 min) measured the mailbox instead, and once stream events were
// (correctly) marked INotInfluenceReceiveTimeout nothing recurring reset it — the
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <summary>
/// One-per-site aggregator on the active central node backing the operator Alarm
/// Summary live cache (plan #10, Task 4). Holds a transient in-memory
/// Summary live cache. Holds a transient in-memory
/// <c>Dictionary&lt;AlarmKey, AlarmStateChanged&gt;</c> of the whole site's current
/// alarm state — NO persistence (locked <c>[PERM]</c>: no central alarm store). Created
/// and torn down by <see cref="SiteAlarmLiveCacheService"/> under viewer reference-count.
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <b>Failover + drift:</b> a gRPC error flips NodeA↔NodeB with the same retry budget +
/// stability window as <see cref="DebugStreamBridgeActor"/>. A re-seed runs <b>once per
/// successful (re)connect</b> — driven by the stream's connected callback, not by each
/// reconnect ATTEMPT (WP2.3: a site outage used to fan a full snapshot out per retry, all
/// reconnect ATTEMPT (a site outage used to fan a full snapshot out per retry, all
/// of them against the very site that is unreachable). A periodic reconcile snapshot
/// (<see cref="_reconcileInterval"/>, default 60s, jittered per site so N aggregators do
/// not fan out in lockstep) remains the drift/backstop: it corrects instance-set drift and
@@ -68,7 +68,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
private const string OpenDeadlineTimerKey = "alarm-grpc-open-deadline";
/// <summary>
/// Production default for the stream open-attempt deadline (arch-review 2026-08-19 P3-4).
/// Production default for the stream open-attempt deadline.
/// Comfortably above <c>SiteStreamGrpcClient.ConnectedHeaderTimeout</c> (10 s) so a
/// normally-slow connect is never pre-empted, and comfortably below the 60 s reconcile
/// interval so the tick that follows always finds the flag released.
@@ -125,7 +125,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// <summary>
/// Ordered buffer of live deltas that arrived while a fan-out was in flight, capped at
/// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (WP2.3 — a stuck fan-out
/// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (a stuck fan-out
/// used to buffer without bound). Dropping the OLDEST is the right eviction here: the
/// fan-out that follows rebuilds the cache authoritatively, so an evicted delta is
/// superseded rather than lost, while the newest transitions — the ones the snapshot may
@@ -195,7 +195,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// <summary>
/// Deadline on ONE stream open attempt, after which <see cref="_openInFlight"/> is
/// released so <see cref="OnReconcileTick"/> can reopen (arch-review 2026-08-19 P3-4).
/// released so <see cref="OnReconcileTick"/> can reopen.
/// <para>
/// The three outcomes that clear the flag — connected, error, graceful completion — are
/// all events the peer has to produce. A stream that produces NONE of them pins the flag:
@@ -244,7 +244,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
private const int BufferWarnThreshold = 10_000;
private bool _bufferWarned;
/// <inheritdoc />
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>
@@ -263,7 +263,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// The second argument is the live-stream liveness flag: <c>false</c> means the site-wide
/// gRPC stream is currently down (fault-exhausted or gracefully completed), so the cache
/// is only as fresh as the last reconcile and the page must fall back to polling
/// (WP2.3 carried residual — <c>IsLive</c> used to stay true across a dead stream).
/// (<c>IsLive</c> used to stay true across a dead stream).
/// </param>
/// <param name="grpcFactory">Factory caching one gRPC client per (site, endpoint).</param>
/// <param name="grpcNodeAAddress">gRPC address of the site's node A.</param>
@@ -284,7 +284,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// up to +20%). Zero disables jitter, which is what tests want for determinism.
/// </param>
/// <param name="openAttemptTimeout">
/// Deadline on one gRPC stream open attempt (P3-4); <c>null</c> takes
/// Deadline on one gRPC stream open attempt; <c>null</c> takes
/// <see cref="DefaultOpenAttemptTimeout"/> (30 s) and <see cref="TimeSpan.Zero"/> or less
/// disables the deadline. See <see cref="_openAttemptTimeout"/>.
/// </param>
@@ -350,7 +350,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
OnStreamConnected();
});
// The open attempt produced none of its three outcomes inside the deadline (P3-4):
// The open attempt produced none of its three outcomes inside the deadline:
// release the in-flight flag so the reconcile tick's reopen guard can run again.
// Generation-fenced like every other stream callback, and a no-op once the attempt
// has already resolved (a late timer racing its own stream's connect).
@@ -486,7 +486,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// Periodic reconcile backstop: re-run the snapshot fan-out (corrects instance-set drift
/// + any missed delta) UNLESS a CONNECT- OR FAILOVER-DRIVEN seed already completed inside
/// this window — that seed makes the tick redundant, and running both was the "full
/// unconditional snapshot every 60s" waste (WP2.3). A tick's OWN fan-out never arms that
/// unconditional snapshot every 60s" waste. A tick's OWN fan-out never arms that
/// skip: it used to, which made steady state alternate fan-out/skip and halved the
/// effective reconcile rate. If the live stream was previously given up, self-heal it by
/// resetting the retry budget and reopening, so a sustained outage never permanently kills
@@ -700,7 +700,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
return;
}
// Backoff retry (WP2.3): the seed leg used to have none — a site returning errors
// Backoff retry: the seed leg used to have none — a site returning errors
// was re-fanned at full reconcile cadence forever. Retry at reconnectDelay doubling
// up to MaxSeedRetryBackoffMultiplier × the reconcile interval; the periodic
// reconcile remains the floor once the backoff saturates.
@@ -848,7 +848,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
if (_stopped) return;
// The stream is not live again until the server actually accepts it — liveness flips
// on GrpcAlarmStreamConnected, not on the attempt (WP2.3 carried residual).
// on GrpcAlarmStreamConnected, not on the attempt.
var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
_log.Info("Opening site-alarm gRPC stream for {0} to {1}", _siteIdentifier, endpoint);
@@ -861,7 +861,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
_openInFlight = true;
var generation = ++_streamGeneration;
// Bound this attempt (P3-4). Single-shot on a shared key, so a re-open replaces the
// Bound this attempt. Single-shot on a shared key, so a re-open replaces the
// previous attempt's deadline rather than stacking one.
if (_openAttemptTimeout > TimeSpan.Zero)
{
@@ -1005,7 +1005,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
CleanupGrpc();
// A completed stream is NOT live: without this the cache kept reporting live for up
// to a full reconcile interval after the site closed the feed (WP2.3 residual).
// to a full reconcile interval after the site closed the feed.
if (wasLive && _seeded)
Publish();
}
@@ -1080,6 +1080,6 @@ internal sealed record ReconnectAlarmStream;
internal sealed record GrpcAlarmStreamStable;
/// <summary>Internal: a stream OPEN attempt produced none of connected/error/completed inside its
/// deadline (P3-4), stamped with the generation it was armed for so a late timer from a superseded
/// deadline, stamped with the generation it was armed for so a late timer from a superseded
/// attempt is ignored. Releases the in-flight guard only — it is not a fault.</summary>
internal sealed record GrpcAlarmStreamOpenTimedOut(int Generation);
@@ -76,6 +76,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
/// pass a stub so they do not need to load Akka.Cluster into the <c>TestKit</c>
/// ActorSystem.
/// </param>
/// <param name="failOverRole">
/// Optional delegate mapping a site identifier to the cluster role used for failover/singleton
/// lookups; <c>null</c> uses the default role resolution.
/// </param>
/// <param name="transport">
/// The site→central transport. Production always passes the Host-built gRPC
/// <see cref="Grpc.GrpcCentralTransport"/>. <c>null</c> (used by TestKit suites that only
@@ -149,7 +149,7 @@ public class CommunicationOptions
/// <summary>
/// Send-channel capacity for the site-wide alarm stream (<c>SubscribeSite</c>). Larger
/// than the Debug View's (WP2.3): that feed backs the operator Alarm Summary, where a
/// than the Debug View's: that feed backs the operator Alarm Summary, where a
/// silently dropped transition is a missed alarm rather than a missed diagnostic frame,
/// and an alarm burst arriving during a WAN stall must survive the stall.
/// </summary>
@@ -211,9 +211,9 @@ public class CommunicationOptions
/// </summary>
public TimeSpan PendingDeploymentPurgeInterval { get; set; } = TimeSpan.FromHours(1);
// ── Aggregated live alarm cache (plan #10, Task 4) ───────────────────────────
// Introduced by Task 4 with sane defaults; Task 6 formalizes eager validation
// (positive interval/linger, positive concurrency/subscriber cap) and telemetry.
// ── Aggregated live alarm cache ───────────────────────────────────────────────
// Sane defaults, with eager validation (positive interval/linger, positive
// concurrency/subscriber cap) and telemetry.
/// <summary>
/// How long the shared per-site live alarm aggregator keeps its gRPC stream +
@@ -256,7 +256,7 @@ public class CommunicationOptions
/// <summary>
/// Random jitter added to each per-site reconcile tick, as a fraction of
/// <see cref="LiveAlarmCacheReconcileInterval"/> (WP2.3). Without it every aggregator
/// <see cref="LiveAlarmCacheReconcileInterval"/>. Without it every aggregator
/// started by one central failover fans its whole-site snapshot out on the same 60s
/// boundary forever. Zero disables jitter.
/// </summary>
@@ -113,7 +113,7 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
+ "entries (a site node must list at least one central gRPC endpoint; central nodes "
+ "leave it empty).");
// ── Aggregated live alarm cache (plan #10, Task 6) ───────────────────────
// ── Aggregated live alarm cache ──────────────────────────────────────────
// Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator
// immediately when the last viewer leaves), only a negative value is invalid.
builder.RequireThat(options.LiveAlarmCacheLinger >= TimeSpan.Zero,
@@ -230,7 +230,7 @@ public class CommunicationService
// RouteToSetAttributes / RouteToWaitForAttribute), driven from
// InboundAPI/CommunicationServiceInstanceRouter and sharing IntegrationTimeout.
// The earlier generic IntegrationCallRequest primitive was never wired to a
// producer or a site handler and was removed as dead code (Gitea #32).
// producer or a site handler and was removed as dead code.
// ── Pattern 5: Debug View ──
@@ -803,7 +803,7 @@ public class CommunicationService
}
/// <summary>
/// Asks a site to gracefully fail over its own two-node pair (Task 10). Central and each
/// Asks a site to gracefully fail over its own two-node pair. Central and each
/// site are SEPARATE Akka clusters, so central cannot act on site membership directly —
/// the site's own <c>SiteCommunicationActor</c> performs the <c>Cluster.Leave</c> against
/// its site-specific role and acks the outcome.
@@ -82,7 +82,7 @@ public class DebugStreamService : IDisposable
/// <param name="ct">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A debug stream session with the initial snapshot.</returns>
/// <remarks>
/// Virtual purely as a test seam: the consumers' dispose-vs-start race (P6-6) can only
/// Virtual purely as a test seam: the consumers' dispose-vs-start race can only
/// be driven deterministically by a stub that holds the start open. Behaviour and the
/// keepalive/orphan contract are unchanged.
/// </remarks>
@@ -114,7 +114,7 @@ public class DebugStreamService : IDisposable
instanceUniqueName = instance.UniqueName;
siteIdentifier = site.SiteIdentifier;
// Single-endpoint site (arch-review 2026-08-19 P3-3, applying the same N9
// Single-endpoint site (applying the same
// degeneration SiteAlarmLiveCacheService.ResolveSiteAsync already uses): reuse the
// sole configured endpoint for both slots, so the bridge actor's NodeA↔NodeB
// failover flip degenerates to a reconnect against the same node. Requiring BOTH
@@ -11,10 +11,16 @@ public static class AuditWireBudget
public const int InitialBytes = 7;
/// <summary>
/// Appends one row to a field-1 repeated-message collection if it fits. A row larger than the
/// Appends one row to a repeated-message collection if it fits. A row larger than the
/// normal budget travels alone, up to the hard channel ceiling. Oversize rows are retained
/// locally and fail explicitly with their identity; they are never acknowledged or truncated.
/// </summary>
/// <typeparam name="T">The protobuf message type of the rows.</typeparam>
/// <param name="rows">The repeated field to append to.</param>
/// <param name="row">The row to append.</param>
/// <param name="wireBytes">The running wire size of the batch; increased by the row's size when appended.</param>
/// <param name="rowIdentity">Identity of the row, used in the error raised when it exceeds the hard ceiling.</param>
/// <returns><c>true</c> when the row was appended; <c>false</c> when it does not fit in the current batch.</returns>
public static bool TryAppend<T>(RepeatedField<T> rows, T row, ref int wireBytes, string rowIdentity)
where T : IMessage<T>
{
@@ -268,7 +268,7 @@ public sealed class CentralChannelProvider : IDisposable
return TimeSpan.FromTicks(scaled >= cap || scaled < 0 ? cap : scaled);
}
/// <inheritdoc />
/// <summary>Stops the failback timer and disposes all cached gRPC channels.</summary>
public void Dispose()
{
lock (_gate)
@@ -74,7 +74,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
public static class CentralControlDtoMapper
{
// -----------------------------------------------------------------------
// Notification Outbox (#21)
// Notification Outbox
// -----------------------------------------------------------------------
/// <summary>Projects a <see cref="NotificationSubmit"/> onto its wire DTO.</summary>
@@ -216,7 +216,7 @@ public static class CentralControlDtoMapper
}
// -----------------------------------------------------------------------
// Audit Log (#23) ingest — envelopes only; rows go through the existing mappers
// Audit Log ingest — envelopes only; rows go through the existing mappers
// -----------------------------------------------------------------------
/// <summary>
@@ -437,7 +437,7 @@ public static class CentralControlDtoMapper
}
// -----------------------------------------------------------------------
// Health Monitoring (#11)
// Health Monitoring
// -----------------------------------------------------------------------
/// <summary>Projects a <see cref="SiteHealthReport"/> onto its wire DTO.</summary>
@@ -559,7 +559,7 @@ public static class CentralControlDtoMapper
dto.SiteAuditBacklog = snapshot;
}
// Review finding M1: wrapper presence keeps null ("no data") distinct from empty.
// Wrapper presence keeps null ("no data") distinct from empty.
if (msg.SecretMirrorStatuses is { } mirrorStatuses)
{
var wrapper = new SecretMirrorStatusListDto();
@@ -277,21 +277,15 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
return CentralControlDtoMapper.ToDto(ack);
}
/// <summary>
/// Application heartbeat — <b>always answers OK</b>, even when this node is not ready.
/// </summary>
/// <remarks>
/// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply,
/// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised
/// an error would be a self-inflicted outage on a purely informational signal). So this is
/// the one RPC that does not go through <see cref="RequireReady"/>: a heartbeat arriving
/// before the actor exists is logged and dropped, exactly as the actor itself drops one
/// that arrives before <c>ICentralHealthAggregator</c> is resolvable. Liveness is still
/// detected — the aggregator's offline timeout fires when the heartbeats stop landing.
/// </remarks>
/// <param name="request">The heartbeat.</param>
/// <param name="context">The gRPC call context.</param>
/// <returns>An empty reply, always.</returns>
// Application heartbeat — always answers OK, even when this node is not ready.
// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply,
// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised
// an error would be a self-inflicted outage on a purely informational signal). So this is
// the one RPC that does not go through RequireReady: a heartbeat arriving before the actor
// exists is logged and dropped, exactly as the actor itself drops one that arrives before
// ICentralHealthAggregator is resolvable. Liveness is still detected — the aggregator's
// offline timeout fires when the heartbeats stop landing.
/// <inheritdoc />
public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context)
{
var central = _central;
@@ -8,14 +8,20 @@ public static class CentralControlSiteIdentity
private static readonly object IdentityKey = new();
/// <summary>Stores an identity only after the site's credential has been verified.</summary>
/// <param name="context">The call context to record the identity on.</param>
/// <param name="siteId">The verified site identifier.</param>
public static void SetVerified(ServerCallContext context, string siteId) => context.UserState[IdentityKey] = siteId;
/// <summary>Requires a credential-verified identity on the current call.</summary>
/// <param name="context">The call context carrying the verified identity.</param>
/// <returns>The verified site identifier.</returns>
public static string RequireVerified(ServerCallContext context) =>
context.UserState.TryGetValue(IdentityKey, out var value) && value is string site && !string.IsNullOrWhiteSpace(site)
? site : throw Denied();
/// <summary>Checks every ownership field before dispatching any part of a central control request.</summary>
/// <param name="request">The inbound request whose ownership fields are checked.</param>
/// <param name="context">The call context carrying the verified site identity.</param>
public static void ValidateRequest(object request, ServerCallContext context)
{
var site = RequireVerified(context);
@@ -9,14 +9,20 @@ public static class GrpcRetrySafety
public const string DispatchTrailer = "scadabridge-dispatch";
/// <summary>Creates a readiness rejection that proves no work was dispatched.</summary>
/// <param name="detail">Human-readable rejection detail placed on the status.</param>
/// <returns>An <see cref="RpcException"/> with <c>Unavailable</c> status and the not-dispatched trailer.</returns>
public static RpcException NotDispatched(string detail) => new(
new Status(StatusCode.Unavailable, detail), new Metadata { { DispatchTrailer, "not-started" } });
/// <summary>True only for an explicit rejection from the server before it dispatched work.</summary>
/// <param name="error">The RPC failure to inspect.</param>
/// <returns><c>true</c> when the trailer proves the server never dispatched the command.</returns>
public static bool WasNotDispatched(RpcException error) => error.StatusCode == StatusCode.Unavailable
&& error.Trailers.Any(e => e.Key == DispatchTrailer && e.Value == "not-started");
/// <summary>Reports an ambiguous mutation without inviting a blind replay.</summary>
/// <param name="error">The original RPC failure whose trailers are preserved.</param>
/// <returns>An <see cref="RpcException"/> with <c>Aborted</c> status marking the outcome as unknown.</returns>
public static RpcException OutcomeUnknown(RpcException error) => new(
new Status(StatusCode.Aborted, "OUTCOME_UNKNOWN: connection failed after the command may have been applied. Verify its state before retrying."),
error.Trailers);
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <para>
/// <b>Per-call deadlines match today's Ask timeouts exactly</b> — see <see cref="ResolveDeadline"/>.
/// An unknown/unconfigured site now fails fast: it surfaces to the caller as a
/// <see cref="Status.Failure"/> immediately (WP1.6) instead of being warned-and-dropped to let the
/// <see cref="Status.Failure"/> immediately instead of being warned-and-dropped to let the
/// caller's Ask burn its full timeout. A transport fault takes the same <see cref="Status.Failure"/>
/// path, which the S&amp;F/audit layers already treat as transient.
/// </para>
@@ -83,7 +83,7 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
}
catch (SiteChannelUnavailableException ex)
{
// WP1.6: a known-dead send (no configured channel for this site) used to warn-and-drop,
// A known-dead send (no configured channel for this site) used to warn-and-drop,
// leaving the caller's Ask to burn its full timeout for a failure we already know about.
// Fail fast instead — same Status.Failure completion path a transport fault takes below —
// so S&F/audit treat it as transient immediately rather than tens of seconds later.
@@ -130,6 +130,8 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
}
/// <summary>Only read-only commands can replay after a response may have been lost.</summary>
/// <param name="message">The command message about to be sent.</param>
/// <returns><c>true</c> when the message is a read-only command safe to replay; otherwise <c>false</c>.</returns>
internal static bool IsRetrySafe(object message) => message is
DeploymentStateQueryRequest or BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand
or ListServerCertsCommand or EventLogQueryRequest or DebugSnapshotRequest or ParkedMessageQueryRequest
@@ -56,7 +56,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// Nullable <b>collections</b> are wrapped in a per-collection message, so
/// the null/empty distinction becomes a message-presence question. proto3
/// <c>repeated</c>/<c>map</c> alone collapses the two — precisely the silent
/// data-loss class the transport round-trip guard caught in PLAN-05 T8.
/// data-loss class the transport round-trip guard caught in the transport round-trip tests.
/// </item>
/// <item>
/// <c>Guid?</c> members ride as a <c>string</c> holding the "D" form, with
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// Raised when a site has no usable gRPC channel — an unknown site, or a site with neither
/// <c>GrpcNodeAAddress</c> nor <c>GrpcNodeBAddress</c> configured. <see cref="GrpcSiteTransport"/>
/// treats this as a known-dead send: it fails the caller's <c>Ask</c> fast with an
/// <c>Akka.Actor.Status.Failure</c> wrapping this exception (WP1.6) instead of warning and dropping
/// <c>Akka.Actor.Status.Failure</c> wrapping this exception instead of warning and dropping
/// the message, so the caller does not burn its full Ask timeout on a failure already known at send
/// time (central still never buffers).
/// </summary>
@@ -51,8 +51,7 @@ public sealed class SitePairChannelProvider : IDisposable
private static readonly TimeSpan FailbackSweepInterval = TimeSpan.FromSeconds(1);
/// <summary>
/// How long a replaced/removed channel is PARKED before it is disposed (arch-review
/// 2026-08-19 P3-2). Active calls retain the channel beyond this minimum until completion.
/// How long a replaced/removed channel is PARKED before it is disposed. Active calls retain the channel beyond this minimum until completion.
/// </summary>
internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5);
@@ -366,7 +365,7 @@ public sealed class SitePairChannelProvider : IDisposable
/// <summary>
/// Parks a channel that is no longer reachable through <see cref="_sites"/> instead of
/// disposing it inline (arch-review 2026-08-19 P3-2).
/// disposing it inline.
/// <para>
/// <b>Why deferred.</b> <see cref="ExecuteAsync{T}"/> reads the channel under
/// <c>pair.Gate</c> but invokes the RPC OUTSIDE it, so an inline
@@ -472,7 +471,7 @@ public sealed class SitePairChannelProvider : IDisposable
}
}
/// <inheritdoc />
/// <summary>Disposes every pooled channel and stops the failback sweep and parked-channel disposal.</summary>
public void Dispose()
{
lock (_lifecycleGate)
@@ -500,6 +499,7 @@ public sealed class SitePairChannelProvider : IDisposable
private sealed class SitePair(string siteId)
{
/// <summary>The site identifier this pair of node channels belongs to.</summary>
public string SiteId { get; } = siteId;
public readonly object Gate = new();
public string? NodeAEndpoint;
@@ -515,12 +515,15 @@ public sealed class SitePairChannelProvider : IDisposable
private TimeSpan _backoff = InitialBackoff;
/// <summary>Sets the failback backoff to <paramref name="delay"/> and schedules the next probe that far ahead.</summary>
/// <param name="delay">The delay before the next failback probe.</param>
public void ScheduleFailback(TimeSpan delay)
{
_backoff = delay;
NextProbeUtc = DateTime.UtcNow + _backoff;
}
/// <summary>Doubles the failback backoff (capped at the maximum) and schedules the next probe.</summary>
public void BumpFailbackBackoff()
{
var next = TimeSpan.FromTicks(Math.Min(_backoff.Ticks * 2, MaxBackoff.Ticks));
@@ -528,6 +531,7 @@ public sealed class SitePairChannelProvider : IDisposable
NextProbeUtc = DateTime.UtcNow + _backoff;
}
/// <summary>Resets the backoff to its initial value and clears the scheduled failback probe.</summary>
public void ResetFailback()
{
_backoff = InitialBackoff;
@@ -122,6 +122,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// Creates a test-only instance that has no gRPC channel. Used to test
/// Unsubscribe and Dispose behavior without needing a real endpoint.
/// </summary>
/// <param name="options">Optional communication options; defaults are used when null.</param>
/// <returns>A <see cref="SiteStreamGrpcClient"/> with no channel or client, for testing only.</returns>
internal static SiteStreamGrpcClient CreateForTesting(CommunicationOptions? options = null) => new(options ?? new CommunicationOptions());
@@ -146,8 +147,8 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <see cref="SubscribeAsync"/> calls briefly share an ID), the prior CTS is
/// <b>cancelled only</b> — never disposed from here. Internal for testability.
/// <para>
/// <b>Cancel-only is the contract</b> (arch-review 2026-08-19 P3-1, the client half of the
/// row-47 class): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
/// <b>Cancel-only is the contract</b> (the client half of the
/// shared-token-source disposal race): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
/// still reads <c>cts.Token</c> (opening the call, <c>ReadAllAsync</c>, the
/// <c>IsCancellationRequested</c> checks), so disposing it from the REPLACING path throws
/// <see cref="ObjectDisposedException"/> inside the prior stream — and, worse, out of the
@@ -172,7 +173,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// Cancels a subscription token source, swallowing <see cref="ObjectDisposedException"/>.
/// A CTS whose owning stream loop has already unwound and disposed it is simply a
/// subscription that no longer needs cancelling; the caller must never see that race as a
/// failure (arch-review 2026-08-19 P3-1).
/// failure.
/// </summary>
/// <param name="cts">The token source to cancel.</param>
private static void CancelQuietly(CancellationTokenSource cts)
@@ -249,7 +250,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
}
// This call OWNS `cts` — it created it, so it disposes it once the stream loop has
// fully unwound (P3-1). No other path may dispose it while this one is still reading
// fully unwound. No other path may dispose it while this one is still reading
// cts.Token; a replacing subscription cancels only.
try
{
@@ -270,15 +271,15 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <summary>
/// Opens the site-wide, <b>alarm-only</b> server-streaming subscription
/// (<c>SubscribeSite</c>) for a whole site rather than a single instance. This is
/// the central per-site feed that backs the aggregated Alarm Summary live cache
/// (plan #10): the site runtime's <c>SubscribeSiteAlarms</c> hub drops the
/// the central per-site feed that backs the aggregated Alarm Summary live cache:
/// the site runtime's <c>SubscribeSiteAlarms</c> hub drops the
/// per-instance filter and carries only <see cref="AlarmStateChanged"/> events for
/// <em>all</em> instances on the site (attributes are deliberately excluded — the
/// summary never shows them and they are far higher-volume).
/// <para>
/// The callback is deliberately <b>typed</b> as <see cref="AlarmStateChanged"/>
/// rather than the generic <c>Action&lt;object&gt;</c> used by
/// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so Task 4's
/// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so the
/// per-site cache consumes an alarm delta directly with no downstream type test.
/// The mapping is still shared via <see cref="ConvertToDomainEvent"/>; a non-alarm
/// event (which should never appear on this stream) is defensively ignored rather
@@ -504,7 +505,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <summary>
/// Cancels an active subscription by correlation ID. Cancel-only: the subscription's own
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> call disposes the token
/// source once its stream loop unwinds (P3-1). Cancelling an entry whose owner already
/// source once its stream loop unwinds. Cancelling an entry whose owner already
/// disposed it — or unsubscribing after the client was disposed — is a silent no-op.
/// </summary>
/// <param name="correlationId">Unique identifier of the subscription to cancel.</param>
@@ -664,7 +665,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// here is synchronous (CTS cancellation and <see cref="GrpcChannel.Dispose"/>), so a
/// synchronous <see cref="Dispose"/> can release everything without sync-over-async blocking.
/// <para>
/// Cancel-only, per-entry guarded (P3-1). The token sources are owned by their
/// Cancel-only, per-entry guarded. The token sources are owned by their
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> calls, which dispose them
/// as their loops unwind in response to this cancellation; disposing them here raced those
/// loops, and one <see cref="ObjectDisposedException"/> aborted the whole foreach — which in
@@ -131,7 +131,7 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
/// <summary>
/// Asynchronously disposes all cached clients and clears the cache. Each client's disposal
/// is fault-isolated: one throwing client must not abort the loop and strand every later
/// client's channel undisposed (arch-review 2026-08-19 P3-1).
/// client's channel undisposed.
/// </summary>
/// <returns>A value task that completes when all clients have been disposed.</returns>
public async ValueTask DisposeAsync()
@@ -39,7 +39,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
private volatile bool _shuttingDown;
private long _actorCounter;
// Ask timeout is 30 s. The ingest actor's repo
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow
// calls take under 100 ms in steady state; a generous timeout absorbs a slow
// MSSQL connection without surfacing as a gRPC failure on a healthy site.
// Shared with CentralCommunicationActor (same assembly) so the two audit-ingest
// transports use one source of truth for the timeout.
@@ -275,7 +275,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// enriched AlarmStateUpdate, so it is reused unchanged.
_streamSubscriber.SubscribeSiteAlarms,
"site-wide alarms",
// Its OWN, much larger channel (WP2.3): sharing the Debug View's 1000-slot
// Its OWN, much larger channel: sharing the Debug View's 1000-slot
// DropOldest meant an alarm burst during a WAN stall silently evicted operator-
// visible transitions to make room for diagnostics traffic.
_siteAlarmChannelCapacity,
@@ -338,7 +338,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
//
// The removal is claimed FIRST, but nothing is cancelled until the concurrency cap
// has had its say, and a REPLACEMENT never consults the cap at all (arch-review
// 2026-08-19 P3-5). Both halves matter:
// 2026-08-19). Both halves matter:
//
// * A replacement is net-neutral on the stream count (one entry out, one back in),
// so refusing it can only ever destroy capacity, never protect it. The old code
@@ -402,7 +402,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
{
// Lossy-under-backpressure is the debug-view spec; make the real
// loss visible: first eviction + every 500th thereafter, plus a
// per-kind counter on the node (WP2.3).
// per-kind counter on the node.
var n = Interlocked.Increment(ref dropped);
Interlocked.Increment(ref _droppedStreamEvents);
ScadaBridgeTelemetry.RecordSiteStreamEventDropped(streamKind);
@@ -447,8 +447,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
"Stream {CorrelationId} started for {Description} (subscription {SubscriptionId})",
correlationId, description, subscriptionId);
// Flush response headers NOW, while the relay is attached and before the first event
// (WP2.3). This is the client's "the site accepted my subscription" signal — the
// Flush response headers NOW, while the relay is attached and before the first event.
// This is the client's "the site accepted my subscription" signal — the
// per-site aggregator hangs its once-per-successful-reconnect re-seed off it. Without
// an explicit flush, ASP.NET Core defers headers to the first written message, so a
// quiet site would never report connected. Best-effort: a client that vanished between
@@ -516,12 +516,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
}
}
/// <summary>Retained wire method; ingest is served only by CentralControlService.</summary>
/// <inheritdoc />
public override Task<IngestAck> IngestAuditEvents(AuditEventBatch request, ServerCallContext context)
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
StatusCode.Unimplemented, "Use CentralControlService.IngestAuditEvents on central.")));
/// <summary>Retained wire method; combined ingest is served only by CentralControlService.</summary>
/// <inheritdoc />
public override Task<IngestAck> IngestCachedTelemetry(CachedTelemetryBatch request, ServerCallContext context)
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
StatusCode.Unimplemented, "Use CentralControlService.IngestCachedTelemetry on central.")));
@@ -535,12 +535,11 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
/// <summary>Flow Debugger PullTraceEvents: server-side id cap, matching the site queue's.</summary>
public const int MaxTraceExecutionIdsPerPull = 64;
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
/// <c>ExecutionId</c> or <c>ParentExecutionId</c> is one of the requested ids. NOT a
/// reconciliation path — moves no cursor, flips no ForwardState. Gated by the same
/// <c>ControlPlaneAuthInterceptor</c> prefix as <see cref="PullAuditEvents"/>.
/// </summary>
// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
// ExecutionId or ParentExecutionId is one of the requested ids. NOT a reconciliation
// path — moves no cursor, flips no ForwardState. Gated by the same
// ControlPlaneAuthInterceptor prefix as PullAuditEvents.
/// <inheritdoc />
public override async Task<PullTraceEventsResponse> PullTraceEvents(
PullTraceEventsRequest request,
ServerCallContext context)
@@ -630,12 +629,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
? DateTime.SpecifyKind(request.SinceUtc.ToDateTime(), DateTimeKind.Utc)
: DateTime.MinValue;
// Composite-keyset cursor (WP2.3), mirroring PullSiteCalls: proto3
// Composite-keyset cursor, mirroring PullSiteCalls: proto3
// defaults an unset string to "", which the queue treats as "no cursor"
// (legacy inclusive >= behaviour), so an older central is unaffected.
var afterId = string.IsNullOrEmpty(request.AfterId) ? null : request.AfterId;
// AT-LEAST-ONCE (WP2.3). The incoming cursor is central's receipt for
// AT-LEAST-ONCE. The incoming cursor is central's receipt for
// everything at or before it — that is the ONLY proof the site accepts.
// Rows are flipped to Reconciled here, at the START of the NEXT pull,
// instead of right after the previous response was projected: a fault
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.Communication;
/// <summary>
/// Central, transient, in-memory per-site live alarm cache (plan #10, Task 4).
/// Central, transient, in-memory per-site live alarm cache.
/// A DI singleton shared by every Alarm Summary Blazor circuit on the active central
/// node. For each site with at least one active viewer it runs ONE shared per-site
/// aggregator that seeds from the existing debug-snapshot fan-out and stays warm on a
@@ -55,8 +55,8 @@ public interface ISiteAlarmLiveCache
/// <summary>
/// Whether the site's aggregator has completed its initial seed and is serving live
/// state. The page can use this to decide whether to keep its 15s poll fallback
/// (Task 5): <c>false</c> means "not live yet — keep polling"; <c>true</c> means the
/// state. The page can use this to decide whether to keep its 15s poll fallback:
/// <c>false</c> means "not live yet — keep polling"; <c>true</c> means the
/// cache is authoritative.
/// </summary>
/// <param name="siteId">The numeric site id.</param>
@@ -35,7 +35,7 @@ public static class ServiceCollectionExtensions
sp.GetService<ISitePskProvider>()));
services.AddSingleton<DebugStreamService>();
// Aggregated live alarm cache (plan #10, Task 4): transient, in-memory, shared
// Aggregated live alarm cache transient, in-memory, shared
// per-site aggregator backing the operator Alarm Summary page. Singleton so every
// Blazor circuit shares one aggregator per site; started/stopped by viewer
// reference-count. No persistence (locked [PERM]: no central alarm store).
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
/// <summary>
/// Default <see cref="ISiteAlarmLiveCache"/> — the DI singleton that owns the shared,
/// reference-counted, transient per-site live alarm cache (plan #10, Task 4). For each
/// reference-counted, transient per-site live alarm cache. For each
/// site with active Alarm Summary viewers it runs ONE
/// <see cref="SiteAlarmAggregatorActor"/>, seeded by the existing debug-snapshot fan-out
/// (<see cref="CommunicationService.RequestDebugSnapshotAsync"/>) and kept warm on a
@@ -246,7 +246,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
TimeSpan.FromSeconds(5), // reconnect delay — former ReconnectDelay static default
TimeSpan.FromSeconds(60), // stability window — former StabilityWindow static default
_options.LiveAlarmCacheReconcileJitterFraction,
// Open-attempt deadline (P3-4). Passed explicitly rather than defaulted:
// Open-attempt deadline. Passed explicitly rather than defaulted:
// Props.Create takes an expression tree, which may not omit an optional
// argument (CS0854).
SiteAlarmAggregatorActor.DefaultOpenAttemptTimeout));
@@ -426,9 +426,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
await gate.WaitAsync(ct);
try
{
// Alarms-only (WP2.3): this fan-out discards snapshot.AttributeValues, so
// Alarms-only: this fan-out discards snapshot.AttributeValues, so
// asking the site to build and ship them is pure wire waste. The flag is
// additive on the wire; a pre-WP2.3 site ignores it and returns the full
// additive on the wire; an older site ignores it and returns the full
// snapshot, which this loop reads exactly as before.
var request = new DebugSnapshotRequest(
instanceUniqueName, Guid.NewGuid().ToString("N"), AlarmsOnly: true);
@@ -470,7 +470,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
entry.HasPublished = true;
// Liveness is the STREAM's, not the cache's: a completed or given-up stream must
// stop reporting live between reopen ticks, or the page grafts a freezing snapshot
// over fresh poll data (WP2.3 carried residual).
// over fresh poll data.
entry.StreamLive = streamLive;
subscribers = entry.Subscribers.ToArray();
}
@@ -496,6 +496,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary>
private sealed class AggregatorTerminationWatcher : ReceiveActor
{
/// <summary>Creates the watcher and begins watching the aggregator.</summary>
/// <param name="watched">The aggregator actor to watch.</param>
/// <param name="onTerminated">Callback invoked once when the watched actor terminates.</param>
public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated)
{
Context.Watch(watched);
@@ -551,11 +554,20 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <summary>Per-site bookkeeping. All mutable fields are guarded by the service's <c>_lock</c>.</summary>
private sealed class SiteEntry
{
/// <summary>Creates the bookkeeping entry for a site.</summary>
/// <param name="siteId">The numeric site id.</param>
public SiteEntry(int siteId) => SiteId = siteId;
/// <summary>The numeric site id this entry tracks.</summary>
public int SiteId { get; }
/// <summary>Active viewer subscriptions for the site.</summary>
public List<Subscription> Subscribers { get; } = new();
/// <summary>The site's shared aggregator actor, or <c>null</c> when none is running.</summary>
public IActorRef? Actor { get; set; }
/// <summary>True while an aggregator start attempt is in progress.</summary>
public bool Starting { get; set; }
/// <summary>Current published snapshot the page renders. Swapped as a whole immutable list.</summary>
@@ -578,7 +590,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// </summary>
public bool StreamLive { get; set; }
/// <summary>Timer that stops the aggregator after the last viewer leaves.</summary>
public Timer? LingerTimer { get; set; }
/// <summary>Version counter that invalidates a stale linger-timer callback.</summary>
public int LingerVersion { get; set; }
/// <summary>Bounded retry timer armed when a start attempt failed to create the actor.</summary>
@@ -611,6 +626,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
{
public static readonly NoOpSubscription Instance = new();
private NoOpSubscription() { }
/// <summary>Does nothing; the rejected subscription was never registered.</summary>
public void Dispose() { }
}
@@ -620,6 +636,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
private readonly SiteAlarmLiveCacheService _owner;
private int _disposed;
/// <summary>Creates a subscription handle for a viewer.</summary>
/// <param name="owner">The owning cache service.</param>
/// <param name="siteId">The numeric site id subscribed to.</param>
/// <param name="onChanged">Callback invoked when the site's cache changes.</param>
public Subscription(SiteAlarmLiveCacheService owner, int siteId, Action onChanged)
{
_owner = owner;
@@ -627,9 +647,13 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
OnChanged = onChanged;
}
/// <summary>The numeric site id subscribed to.</summary>
public int SiteId { get; }
/// <summary>Callback invoked when the site's cache changes.</summary>
public Action OnChanged { get; }
/// <summary>Unregisters this viewer from the owning cache; safe to call more than once.</summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)