using Akka.Actor; using Akka.Event; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Actors; /// /// 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 /// Dictionary<AlarmKey, AlarmStateChanged> of the whole site's current /// alarm state — NO persistence (locked [PERM]: no central alarm store). Created /// and torn down by under viewer reference-count. /// /// Seed-then-stream ordering (copied from ): /// the site-wide, alarm-only gRPC stream (SubscribeSite) is opened FIRST in /// so live deltas start flowing during the seed's snapshot-build /// + transit window; deltas arriving while a seed/reconcile fan-out is in flight are /// buffered in arrival order. When the fan-out completes the cache is rebuilt /// authoritatively from the fresh snapshot, then the buffer is flushed with per-key /// dedup against the seed, then the actor passes live deltas straight into the cache. /// /// /// Placeholder reconciliation: the snapshot fan-out carries /// rows (a configured native /// source binding with no active conditions); the live stream drops them server-side. /// Placeholders are seeded and never expected on the live stream — a live delta for a /// real alarm has a distinct AlarmKey so it can never wipe a placeholder row and /// vice-versa. Placeholder-vs-real coherence is refreshed by the periodic reconcile. /// /// /// Failover + drift: a gRPC error flips NodeA↔NodeB with the same retry budget + /// stability window as . A re-seed runs once per /// successful (re)connect — 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 /// of them against the very site that is unreachable). A periodic reconcile snapshot /// (, default 60s, jittered per site so N aggregators do /// not fan out in lockstep) remains the drift/backstop: it corrects instance-set drift and /// any missed delta, but it is skipped when a fan-out already completed inside the /// window, and it publishes to viewers only when the snapshot actually changed the cache. /// /// All state is mutated only on the actor thread: gRPC callbacks and fan-out results are /// marshalled back via Self.Tell, so the cache needs no internal lock. The /// published snapshot handed to the service is a fresh immutable list (reference swap), /// so Blazor render threads never observe a partially-mutated cache. /// public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers { private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly string _siteIdentifier; private readonly string _correlationId; private readonly Func>> _seedFn; private readonly Action, bool> _publish; private readonly SiteStreamGrpcClientFactory _grpcFactory; private readonly string _grpcNodeAAddress; private readonly string _grpcNodeBAddress; private readonly TimeSpan _reconcileInterval; private readonly TimeSpan _publishCoalesce; private readonly double _reconcileJitterFraction; private const int MaxRetries = 3; private const string ReconnectTimerKey = "alarm-grpc-reconnect"; private const string StabilityTimerKey = "alarm-grpc-stability"; private const string ReconcileTimerKey = "alarm-reconcile"; private const string PublishTimerKey = "alarm-publish-coalesce"; private const string SeedRetryTimerKey = "alarm-seed-retry"; /// True while a coalesced publish is armed (dirty deltas awaiting one tick). Actor-thread only. private bool _publishPending; /// Delay between gRPC reconnection attempts (ctor-injected; production default 5s). private readonly TimeSpan _reconnectDelay; /// /// How long a freshly-opened gRPC stream must stay up before its retry budget is /// considered recovered (mirrors ); /// ctor-injected (production default 60s). /// private readonly TimeSpan _stabilityWindow; private int _retryCount; private bool _useNodeA = true; private bool _stopped; /// /// True while there is no live gRPC stream — either it was given up (retry budget /// exhausted) or it ended gracefully (server status OK at the site's max stream /// lifetime). Reconcile snapshots keep serving in the meantime; the next reconcile tick /// self-heals the stream by resetting the retry budget and reopening it, so neither a /// sustained site outage nor a routine 4h stream expiry permanently drops the live feed. /// Starts true: until the site accepts the first subscription there is no live /// feed, and the owning cache must not advertise one. Actor-thread only. /// private bool _streamDown = true; /// /// Why the stream is down: true = the retry budget was exhausted, so the /// self-healing reopen must also reset it; false = it ended gracefully, and the /// budget — which a completion neither spends nor refunds — is carried across the reopen /// untouched (a stream flapping between faults and clean closes must still trip /// MaxRetries). Actor-thread only. /// private bool _retryBudgetExhausted; private CancellationTokenSource? _grpcCts; private CancellationTokenSource? _lifetimeCts; /// Current whole-site alarm state, keyed by . Actor-thread only. private readonly Dictionary _cache = new(); /// True once the first seed has completed and been published. Actor-thread only. private bool _seeded; /// True while a seed/reconcile snapshot fan-out is in flight (deltas buffer). Actor-thread only. private bool _fanoutInFlight; /// /// Ordered buffer of live deltas that arrived while a fan-out was in flight, capped at /// with drop-oldest eviction (WP2.3 — 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 /// predate — are the ones kept. Actor-thread only. /// private readonly Queue _buffer = new(); /// Total deltas evicted from over this actor's life. Actor-thread only. private long _bufferDropped; /// /// Hard cap on the pre-fan-out delta buffer. Beyond this the oldest entry is evicted and /// counted; the counter is logged (first drop + every 500th) and exported as /// scadabridge.site.alarm_cache.buffer_dropped. /// private const int MaxBufferedDeltas = 20_000; /// /// True when the next successful (re)connect must run a seed fan-out: set whenever the /// stream is lost (fault or graceful completion) and cleared by the connect that consumes /// it. This is what makes the re-seed happen once per successful reconnect rather than /// once per reconnect ATTEMPT — the old code fanned a full snapshot out on every retry, /// against a site that was by definition unreachable. Actor-thread only. /// private bool _seedOnConnect; /// /// Set when a CONNECT- OR FAILOVER-DRIVEN fan-out finishes (the initial seed, a re-seed /// consumed by a successful (re)connect, or a re-seed queued behind one); consumed by the /// next reconcile tick, which skips its own fan-out when it finds the flag set. That makes /// the connect-driven seed and the periodic backstop mutually exclusive — the pair used to /// run BOTH, which is the "full unconditional snapshot every 60s" waste — while bounding /// staleness at two intervals (a tick can be skipped at most once in a row). /// /// A TICK-driven fan-out deliberately does NOT set it. It used to: the tick's own fan-out /// completion armed the skip, so steady state ran fan-out → skip → fan-out → skip, i.e. /// one reconcile per TWO intervals — halving the not-reporting refresh rate and the alarm /// reconcile backstop for no reason (nothing was duplicated to suppress). /// /// Actor-thread only. /// private bool _fanoutSinceLastTick; /// /// Whether the in-flight fan-out is connect/failover-driven and therefore makes the next /// reconcile tick redundant (see ). Stable for the /// fan-out's lifetime: never overwrites it while one is in /// flight (a colliding request queues instead). Actor-thread only. /// private bool _fanoutSuppressesNextTick; /// /// Same flag for a fan-out that was queued behind an in-flight one /// () — OR-ed across colliding requests so a queued /// connect/failover re-seed keeps its suppression even if a tick collided too. /// Actor-thread only. /// private bool _queuedFanoutSuppressesNextTick; /// /// True between issuing a stream open and its first outcome (connected / error / /// completed), so a reconcile tick never stacks a second open on an in-flight one. /// Actor-thread only. /// private bool _openInFlight; /// Consecutive whole-fan-out failures, driving the seed-retry backoff. Actor-thread only. private int _consecutiveSeedFailures; /// Upper bound on the seed-retry backoff, as a multiple of the reconcile interval. private const int MaxSeedRetryBackoffMultiplier = 8; /// /// A failover re-seed was requested while a fan-out was already in flight; it must run /// right after the in-flight one completes rather than being silently dropped (N7.1) — /// the in-flight snapshot's read-time predates the stream death. Actor-thread only. /// private bool _reseedQueued; /// /// Monotonic stream generation stamped on each opened gRPC stream and echoed back on its /// error callback: a late error raced out of a previous (cancelled) stream carries a stale /// generation and is ignored so it never burns retry budget or double-flips (N7.2). /// Actor-thread only. /// private int _streamGeneration; private const int BufferWarnThreshold = 10_000; private bool _bufferWarned; /// public ITimerScheduler Timers { get; set; } = null!; /// /// Creates a per-site alarm aggregator. /// /// Site identifier (for logging / gRPC client keying). /// Correlation id for the site-wide gRPC subscription. /// /// Snapshot fan-out that returns the whole site's current alarm rows (including /// placeholders), best-effort and tolerant of per-instance failure. Re-run on every /// seed and reconcile, so it re-enumerates the site's Enabled instances each call. /// /// /// Publishes a fresh immutable snapshot of the cache to the owning service, which /// stores it and raises viewer onChanged callbacks. Invoked on the actor thread. /// The second argument is the live-stream liveness flag: false 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 — IsLive used to stay true across a dead stream). /// /// Factory caching one gRPC client per (site, endpoint). /// gRPC address of the site's node A. /// gRPC address of the site's node B. /// Periodic reconcile snapshot cadence. /// /// Publish-coalescing window for live deltas: a positive value batches a delta storm /// into one publish per window (review 02 round 2, N6); /// restores per-delta publishing (legacy). Seed/reconcile publishes stay immediate. /// /// Delay between gRPC reconnection attempts (production 5s). /// /// How long a fresh gRPC stream must stay up before its retry budget recovers (production 60s). /// /// /// Fraction of added as a per-tick random offset so /// N per-site aggregators on one central node do not fan out in lockstep (production 0.2 = /// up to +20%). Zero disables jitter, which is what tests want for determinism. /// public SiteAlarmAggregatorActor( string siteIdentifier, string correlationId, Func>> seedFn, Action, bool> publish, SiteStreamGrpcClientFactory grpcFactory, string grpcNodeAAddress, string grpcNodeBAddress, TimeSpan reconcileInterval, TimeSpan publishCoalesce, TimeSpan reconnectDelay, TimeSpan stabilityWindow, double reconcileJitterFraction = 0.0) { _siteIdentifier = siteIdentifier; _correlationId = correlationId; _seedFn = seedFn; _publish = publish; _grpcFactory = grpcFactory; _grpcNodeAAddress = grpcNodeAAddress; _grpcNodeBAddress = grpcNodeBAddress; _reconcileInterval = reconcileInterval; _publishCoalesce = publishCoalesce; _reconnectDelay = reconnectDelay; _stabilityWindow = stabilityWindow; _reconcileJitterFraction = Math.Clamp(reconcileJitterFraction, 0.0, 1.0); // Live delta from the site-wide alarm stream (marshalled in via Self.Tell). // A received delta must NOT reset the retry budget (a flapping stream that // delivers one delta between failures would otherwise never trip MaxRetries). Receive(HandleLiveDelta); // A seed/reconcile fan-out completed. Receive(OnSeedCompleted); // A seed/reconcile fan-out threw as a whole (individual per-instance faults are // swallowed inside seedFn and degrade to fewer rows, not a whole-fan-out failure). Receive(OnSeedFailed); // Periodic reconcile tick (and the re-seed kicked after a reconnect). Receive(_ => OnReconcileTick()); // Backoff-scheduled retry of a fan-out that failed as a whole. Receive(_ => { if (_stopped) return; // A backoff retry of a failed fan-out is not a connect/failover seed: it must // not stand the next reconcile tick down. StartFanout(isInitial: false, suppressesNextTick: false); }); // The site-wide stream is confirmed established (response headers received). This — // not the reconnect attempt — is the "successful (re)connect" that earns one re-seed. Receive(msg => { if (_stopped) return; if (msg.Generation != _streamGeneration) return; OnStreamConnected(); }); // Coalesced-publish tick: one publish for a batch of dirtying deltas (N6). Receive(_ => { _publishPending = false; if (!_stopped) Publish(); }); // Stream stayed up for StabilityWindow — recover the retry budget. Receive(_ => { if (_stopped) return; _retryCount = 0; _log.Debug("Site-alarm gRPC stream for {0} stable; retry count reset", _siteIdentifier); }); // gRPC stream error — flip node + reconnect + re-seed. Receive(msg => { // Ignore a late error raced out of a previous (cancelled) stream — the // RpcException(Cancelled) filter at SiteStreamGrpcClient.cs covers the normal // path, but a genuine socket fault can beat the cancel (N7.2). if (msg.Generation != _streamGeneration) { _log.Debug("Ignoring stale gRPC error from stream generation {0} (current {1})", msg.Generation, _streamGeneration); return; } _log.Warning("Site-alarm gRPC stream error for {0}: {1}", _siteIdentifier, msg.Exception.Message); HandleGrpcError(); }); // gRPC stream ended GRACEFULLY (server status OK) — the site's 4h max stream // lifetime elapsing or a graceful site shutdown. Not a fault: the stream is marked // down so the reconcile tick reopens it (which also re-seeds), but the retry budget // is untouched and the node is not flipped. Same generation fence as the error path. Receive(msg => { if (_stopped) return; if (msg.Generation != _streamGeneration) { _log.Debug("Ignoring stale gRPC completion from stream generation {0} (current {1})", msg.Generation, _streamGeneration); return; } HandleGrpcCompleted(); }); Receive(_ => OpenGrpcStream()); // Owning service asks us to stop (last viewer left + linger elapsed). Receive(_ => { _log.Info("Stopping site-alarm aggregator for {0}", _siteIdentifier); CleanupGrpc(); _stopped = true; Context.Stop(Self); }); } /// protected override void PreStart() { _log.Info("Starting site-alarm aggregator for site {0}", _siteIdentifier); // Telemetry: this aggregator is now a running per-site live cache (gauge +1). Balanced // in PostStop, which Akka always runs on termination for any reason. ScadaBridgeTelemetry.LiveAlarmAggregatorStarted(); _lifetimeCts = new CancellationTokenSource(); // Stream-first: open the site-wide alarm stream BEFORE the first seed so deltas // in the seed window are captured (buffered) rather than lost. OpenGrpcStream(); // Kick the initial seed fan-out. The connect callback for this first stream must // NOT seed again on top of it, so the flag starts cleared. _seedOnConnect = false; StartFanout(isInitial: true, suppressesNextTick: true); // Periodic reconcile backstop. Single-shot and re-armed with fresh jitter each // tick (a periodic timer would lock every site to the same phase forever). ArmReconcileTimer(); } /// /// Arms the next reconcile tick at plus a random /// offset of up to of it, so N per-site /// aggregators started together (a central failover restarts them all at once) spread /// their fan-outs instead of stampeding the same 60s boundary. /// private void ArmReconcileTimer() { var delay = _reconcileInterval; if (_reconcileJitterFraction > 0 && _reconcileInterval > TimeSpan.Zero) { var jitterTicks = (long)(_reconcileInterval.Ticks * _reconcileJitterFraction * Random.Shared.NextDouble()); delay += TimeSpan.FromTicks(jitterTicks); } Timers.StartSingleTimer(ReconcileTimerKey, new RunReconcile(), delay); } /// protected override void PostStop() { _grpcCts?.Cancel(); _grpcCts?.Dispose(); _grpcCts = null; _lifetimeCts?.Cancel(); _lifetimeCts?.Dispose(); _lifetimeCts = null; // Telemetry: this aggregator is no longer running (gauge -1). Balances PreStart. ScadaBridgeTelemetry.LiveAlarmAggregatorStopped(); base.PostStop(); } // ── Reconcile tick ────────────────────────────────────────────────────────── /// /// 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 /// 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 /// the live feed. /// private void OnReconcileTick() { if (_stopped) return; // Re-arm first: every exit path below must leave the backstop running. ArmReconcileTimer(); // A down stream is reopened in preference to fanning out: the reopen's connect // callback seeds by itself, so doing both would double the work. var reopening = false; if (_streamDown && !_openInFlight) { _log.Info("Site-alarm gRPC stream for {0} was down; reopening on reconcile tick", _siteIdentifier); if (_retryBudgetExhausted) { _retryBudgetExhausted = false; _retryCount = 0; } // Telemetry: a reconcile-driven reopen after the stream was given up is a reconnect. ScadaBridgeTelemetry.RecordLiveAlarmStreamReconnect(); _seedOnConnect = true; OpenGrpcStream(); reopening = true; } if (_fanoutSinceLastTick) { // A connect-driven seed (or the initial seed) already refreshed the cache inside // this window. Clear the flag so the NEXT tick fans out regardless — staleness // stays bounded at two intervals even if reconnects keep arriving. _fanoutSinceLastTick = false; _log.Debug("Site-alarm reconcile for {0} skipped; a fan-out already ran this window", _siteIdentifier); return; } if (!reopening) { // Tick-driven: deliberately does NOT set the skip flag, so the NEXT tick fans // out too. Setting it here was the "one reconcile per two intervals" bug. StartFanout(isInitial: false, suppressesNextTick: false); } } // ── Seed / reconcile fan-out ──────────────────────────────────────────────── /// /// Kicks a snapshot fan-out as a background task, marshalling the result back via /// Self.Tell. While in flight, live deltas buffer. A reconcile that arrives /// while a fan-out is already running is skipped (no stacking). /// private void StartFanout(bool isInitial, bool suppressesNextTick) { if (_stopped) return; if (_fanoutInFlight) { // A failover re-seed requested mid-fan-out must run right after the in-flight // one — its snapshot read-time predates the stream death, so skipping it would // serve stale up to the next 60s reconcile (N7.1). An initial-seed collision // never queues (there is only ever one). if (!isInitial) { _reseedQueued = true; _queuedFanoutSuppressesNextTick |= suppressesNextTick; } return; } _fanoutInFlight = true; _fanoutSuppressesNextTick = suppressesNextTick; var self = Self; var ct = _lifetimeCts?.Token ?? CancellationToken.None; Task.Run(async () => { try { var alarms = await _seedFn(ct); self.Tell(new SeedCompleted(alarms, isInitial)); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // Actor stopping — drop silently. } catch (Exception ex) { self.Tell(new SeedFailed(ex, isInitial)); } }, ct); } private void OnSeedCompleted(SeedCompleted msg) { if (_stopped) return; // Rebuild the cache authoritatively from the fresh snapshot (this is what makes // reconcile able to DROP rows for instances/alarms that disappeared — a merge // could never remove a stale row since the live stream sends no "removed" event). // The previous cache is kept alongside so the reconcile can publish as a DIFF: // an unchanged snapshot must not wake every viewer's render path once a minute. var previous = _cache.Count == 0 ? null : new Dictionary(_cache); _cache.Clear(); foreach (var alarm in msg.Alarms) { var key = AlarmKey(alarm); if (!_cache.TryGetValue(key, out var existing) || alarm.Timestamp >= existing.Timestamp) _cache[key] = alarm; } // Flush the deltas buffered during the fan-out, deduped against the seed: a // buffered delta whose key is in the fresh snapshot with an equal-or-newer // timestamp is already reflected → drop; a strictly-newer (or new-key) delta is // applied. Inclusive-on-snapshot boundary matches DebugStreamBridgeActor. var flushChanged = FlushBuffer(); _fanoutInFlight = false; // Only a connect/failover-driven seed makes the next tick redundant — a tick's own // fan-out must never suppress the tick after it (that halved the reconcile rate). if (_fanoutSuppressesNextTick) _fanoutSinceLastTick = true; _consecutiveSeedFailures = 0; Timers.Cancel(SeedRetryTimerKey); var firstSeed = !_seeded; _seeded = true; _log.Debug("Site-alarm {0} {1} complete: {2} alarm row(s)", _siteIdentifier, msg.IsInitial ? "seed" : "reconcile", _cache.Count); // The fresh snapshot already carries the buffered deltas; drop any armed coalesce // tick so we publish once, immediately — but only when something actually moved. Timers.Cancel(PublishTimerKey); _publishPending = false; if (firstSeed || flushChanged || DiffersFrom(previous)) Publish(); // A failover re-seed requested while this fan-out was in flight runs now (N7.1). RunQueuedFanoutIfAny(); } /// /// Runs a fan-out that collided with an in-flight one, carrying the suppression flag it /// was queued with so a queued connect/failover re-seed still stands the next tick down /// (and a queued TICK fan-out still does not). /// private void RunQueuedFanoutIfAny() { if (!_reseedQueued) return; _reseedQueued = false; var suppresses = _queuedFanoutSuppressesNextTick; _queuedFanoutSuppressesNextTick = false; StartFanout(isInitial: false, suppressesNextTick: suppresses); } /// /// True when the just-installed snapshot differs from in any /// key or any row value — the reconcile's "is this publish worth a viewer fan-out?" test. /// A null previous cache (nothing published yet) always counts as different. /// private bool DiffersFrom(Dictionary? previous) { if (previous is null || previous.Count != _cache.Count) return true; foreach (var (key, current) in _cache) { if (!previous.TryGetValue(key, out var old)) return true; // AlarmStateChanged is a record: structural equality covers state, level, // timestamp and every native-alarm enrichment field. if (!Equals(old, current)) return true; } return false; } private void OnSeedFailed(SeedFailed msg) { if (_stopped) return; _consecutiveSeedFailures++; _log.Warning(msg.Exception, "Site-alarm {0} {1} fan-out failed ({2} consecutive); keeping current cache and retrying with backoff", _siteIdentifier, msg.IsInitial ? "seed" : "reconcile", _consecutiveSeedFailures); // Don't lose deltas captured during the failed window — apply them pass-through // into the (possibly stale/empty) cache. The next reconcile re-seeds authoritatively. _fanoutInFlight = false; // Same rule as the success path: only a connect/failover-driven fan-out stands the // next reconcile tick down. if (_fanoutSuppressesNextTick) _fanoutSinceLastTick = true; var flushChanged = FlushBuffer(dedupAgainstSeed: false); // Only publish if we already had a seed (so IsLive doesn't flip true on a // failed initial seed — the page keeps its poll fallback until we truly seed). if (_seeded && flushChanged) { Timers.Cancel(PublishTimerKey); _publishPending = false; Publish(); } // A failover re-seed requested while this fan-out was in flight runs now (N7.1). if (_reseedQueued) { RunQueuedFanoutIfAny(); return; } // Backoff retry (WP2.3): 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. ScheduleSeedRetry(); } /// /// Arms the backoff retry for a failed fan-out: reconnectDelay × 2^(failures-1), /// capped at × the reconcile interval. Once /// the cap is reached the periodic reconcile tick is doing the same work anyway, so no /// extra timer is armed. /// private void ScheduleSeedRetry() { var cap = _reconcileInterval > TimeSpan.Zero ? TimeSpan.FromTicks(_reconcileInterval.Ticks * MaxSeedRetryBackoffMultiplier) : TimeSpan.FromMinutes(8); var shift = Math.Min(_consecutiveSeedFailures - 1, 16); var delayTicks = _reconnectDelay.Ticks * (1L << shift); var delay = TimeSpan.FromTicks(Math.Min(delayTicks, cap.Ticks)); if (delay <= TimeSpan.Zero) return; Timers.StartSingleTimer(SeedRetryTimerKey, new RetrySeed(), delay); } /// /// Flushes the pre-fan-out buffer in arrival order. When /// is true (normal completion) a buffered delta already reflected in the just-installed /// snapshot (same key, timestamp <= cache entry) is dropped; otherwise every buffered /// delta is applied pass-through. Returns true when at least one delta changed the /// cache (the reconcile's publish-only-on-change test must count these too). /// private bool FlushBuffer(bool dedupAgainstSeed = true) { if (_buffer.Count == 0) return false; var changed = false; foreach (var delta in _buffer) { changed |= ApplyDelta(delta, requireStrictlyNewer: dedupAgainstSeed); } _buffer.Clear(); return changed; } // ── Live delta handling ───────────────────────────────────────────────────── private void HandleLiveDelta(AlarmStateChanged delta) { if (_stopped) return; if (_fanoutInFlight) { if (!_bufferWarned && _buffer.Count + 1 > BufferWarnThreshold) { _bufferWarned = true; _log.Warning( "Site-alarm pre-seed buffer for {0} exceeded {1} deltas while a fan-out was in flight " + "(hard cap {2}, drop-oldest beyond it).", _siteIdentifier, BufferWarnThreshold, MaxBufferedDeltas); } // Hard cap with drop-oldest: an unbounded buffer behind a stuck fan-out was a // straight memory leak. The evicted rows are superseded by the snapshot that // ends the fan-out, so the drop costs freshness, never correctness. while (_buffer.Count >= MaxBufferedDeltas) { _buffer.Dequeue(); _bufferDropped++; ScadaBridgeTelemetry.RecordLiveAlarmBufferDrop(); if (_bufferDropped == 1 || _bufferDropped % 500 == 0) { _log.Warning( "Site-alarm pre-seed buffer for {0} is at its {1}-delta cap; {2} delta(s) evicted so far", _siteIdentifier, MaxBufferedDeltas, _bufferDropped); } } _buffer.Enqueue(delta); return; } // Pass-through: apply and (coalesced) publish only if the cache actually changed. if (ApplyDelta(delta, requireStrictlyNewer: false)) SchedulePublish(); } /// /// Coalesced publish: with a positive window, the first dirtying delta arms a /// single-shot timer and further deltas ride the same tick — one snapshot copy and /// one viewer fan-out per window instead of per transition (N6). Zero = legacy /// immediate publish. Last write wins, so batching never changes final state. /// private void SchedulePublish() { if (_publishCoalesce <= TimeSpan.Zero) { Publish(); return; } if (_publishPending) return; _publishPending = true; Timers.StartSingleTimer(PublishTimerKey, new PublishCoalesced(), _publishCoalesce); } /// /// Applies one alarm delta to the cache keyed by . Returns /// true if the cache changed. A stale (older) delta for an existing key is /// ignored. Never carries a placeholder (the live stream drops those), so a live /// delta for a real alarm can only add/replace its own key — it never touches a /// placeholder row under a different key. /// private bool ApplyDelta(AlarmStateChanged delta, bool requireStrictlyNewer) { var key = AlarmKey(delta); if (_cache.TryGetValue(key, out var existing)) { var newer = requireStrictlyNewer ? delta.Timestamp > existing.Timestamp : delta.Timestamp >= existing.Timestamp; if (!newer) return false; } _cache[key] = delta; return true; } private void Publish() { // Fresh immutable list — reference swap; readers never see a partial mutation. var snapshot = _cache.Values.ToList(); try { // Liveness rides every publish so the owning service's IsLive tracks the STREAM, // not merely "a snapshot was published once". A down stream means the cache is // only as fresh as the last reconcile and the page must keep polling. _publish(snapshot, !_streamDown); } catch (Exception ex) { _log.Warning(ex, "Site-alarm publish callback threw for {0}; ignoring", _siteIdentifier); } } // ── gRPC stream lifecycle (mirrors DebugStreamBridgeActor) ────────────────── private void OpenGrpcStream() { 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). var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress; _log.Info("Opening site-alarm gRPC stream for {0} to {1}", _siteIdentifier, endpoint); _grpcCts?.Cancel(); _grpcCts?.Dispose(); _grpcCts = new CancellationTokenSource(); Timers.StartSingleTimer(StabilityTimerKey, new GrpcAlarmStreamStable(), _stabilityWindow); _openInFlight = true; var generation = ++_streamGeneration; var client = _grpcFactory.GetOrCreate(_siteIdentifier, endpoint); var self = Self; var ct = _grpcCts.Token; // The subscription task itself is observed (below): a fault escaping // SubscribeSiteAsync — or a Task.Run that never started because ct was already // cancelled — would otherwise leave the actor waiting on a stream that does not // exist, with the exception silently unobserved. Task.Run(async () => { await client.SubscribeSiteAsync( _correlationId, alarm => self.Tell(alarm), ex => self.Tell(new GrpcAlarmStreamError(ex, generation)), () => self.Tell(new GrpcAlarmStreamCompleted(generation)), ct, () => self.Tell(new GrpcAlarmStreamConnected(generation))); }, ct).ContinueWith(t => { if (t.IsFaulted) self.Tell(new GrpcAlarmStreamError(t.Exception!.GetBaseException(), generation)); else if (t.IsCanceled && !ct.IsCancellationRequested) self.Tell(new GrpcAlarmStreamCompleted(generation)); // RanToCompletion: SubscribeSiteAsync already reported its own outcome. }, TaskContinuationOptions.ExecuteSynchronously); } /// /// The site accepted the subscription — the only point at which the stream is genuinely /// live. Consumes the pending re-seed (so exactly ONE fan-out runs per successful /// (re)connect, instead of one per reconnect attempt against a site that is by definition /// unreachable) and republishes so IsLive recovers. /// private void OnStreamConnected() { _openInFlight = false; var wasDown = _streamDown; _streamDown = false; if (_seedOnConnect) { _seedOnConnect = false; _log.Info("Site-alarm gRPC stream for {0} (re)connected; running one re-seed", _siteIdentifier); StartFanout(isInitial: false, suppressesNextTick: true); } // Liveness recovered — republish so viewers stop falling back to polling even if the // seed that follows finds nothing changed. if (wasDown && _seeded) Publish(); } private void HandleGrpcError() { if (_stopped) return; // Stream failed before the stability window — retry budget NOT recovered. Timers.Cancel(StabilityTimerKey); _openInFlight = false; var wasLive = !_streamDown; _streamDown = true; // The next successful connect owes exactly one re-seed. Setting it here (rather than // fanning out now) is the fix for the per-attempt re-fan-out: a site outage used to // cost one whole-site snapshot per retry. _seedOnConnect = true; // Liveness lost — tell viewers immediately so a dead stream stops reading as live. if (wasLive && _seeded) Publish(); _retryCount++; if (_retryCount > MaxRetries) { // Give up the stream, but do NOT stop the aggregator: the periodic reconcile // still refreshes the cache from site snapshots, so the page keeps a // (slower) live-ish view rather than going dark. A later reconcile-triggered // reconnect is not attempted here; the stream is simply left down. _log.Error("Site-alarm gRPC stream for {0} exceeded max retries ({1}); leaving stream down, " + "reconcile snapshots continue and the next reconcile tick will retry the stream", _siteIdentifier, MaxRetries); _retryBudgetExhausted = true; CleanupGrpc(); return; } // Unsubscribe the failed stream on the previous endpoint (TryGet, never // GetOrCreate) so the site releases its relay actor instead of leaving a zombie. var previousEndpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress; _grpcFactory.TryGet(_siteIdentifier, previousEndpoint)?.Unsubscribe(_correlationId); // Flip to the other node. _useNodeA = !_useNodeA; // Telemetry: a NodeA↔NodeB failover flip is a reconnect + re-seed. ScadaBridgeTelemetry.RecordLiveAlarmStreamReconnect(); // The re-seed is owed to the CONNECT (see _seedOnConnect above), not to this // attempt: a snapshot fan-out issued while the site is unreachable degrades to // empty rows and would have to be redone on reconnect anyway. if (_retryCount == 1) Self.Tell(new ReconnectAlarmStream()); else Timers.StartSingleTimer(ReconnectTimerKey, new ReconnectAlarmStream(), _reconnectDelay); } /// /// Handles a graceful end of stream. The stream is torn down and left down for the /// reconcile tick to reopen — deliberately NOT reopened inline, so a site that keeps /// completing streams immediately can never spin this actor into a hot reconnect loop. /// The retry budget is neither spent nor reset here (completion is not a fault), and the /// endpoint is not flipped: the node that just closed a stream cleanly is healthy. /// private void HandleGrpcCompleted() { // The stream is gone, so its armed stability timer must not later "recover" a // budget that its successor has since spent. Timers.Cancel(StabilityTimerKey); _log.Info("Site-alarm gRPC stream for {0} completed gracefully (server end of stream); " + "reopening on the next reconcile tick", _siteIdentifier); _openInFlight = false; var wasLive = !_streamDown; _streamDown = true; // The reopen owes one re-seed, run when it actually connects. _seedOnConnect = true; 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). if (wasLive && _seeded) Publish(); } private void CleanupGrpc() { _grpcCts?.Cancel(); _grpcCts?.Dispose(); _grpcCts = null; var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress; _grpcFactory.TryGet(_siteIdentifier, endpoint)?.Unsubscribe(_correlationId); } // ── Dedup key (copied from DebugStreamBridgeActor.AlarmKey with attribution) ── /// /// NUL delimiter so distinct identities never collide on a shared boundary. Cannot /// appear in an instance/alarm name. Mirrors . /// private const char KeyDelimiter = '\u0000'; /// /// Per-alarm dedup identity = (InstanceUniqueName, AlarmName, SourceReference) — /// identical to DebugStreamBridgeActor.AlarmKey so native per-condition alarms /// sharing an AlarmName but differing by source reference are not conflated. Each /// nullable component is guarded to prevent silent null/empty key collisions. /// private static string AlarmKey(AlarmStateChanged a) => string.Concat( a.InstanceUniqueName ?? string.Empty, KeyDelimiter, a.AlarmName ?? string.Empty, KeyDelimiter, a.SourceReference ?? string.Empty); } /// Message asking a to stop (last viewer left). public sealed record StopSiteAlarmAggregator; /// Internal: a seed/reconcile snapshot fan-out completed with the whole-site alarm rows. internal sealed record SeedCompleted(IReadOnlyList Alarms, bool IsInitial); /// Internal: a seed/reconcile snapshot fan-out threw as a whole. internal sealed record SeedFailed(Exception Exception, bool IsInitial); /// Internal: periodic reconcile tick (and the re-seed kicked after a reconnect). internal sealed record RunReconcile; /// Internal: backoff-scheduled retry of a fan-out that failed as a whole. internal sealed record RetrySeed; /// Internal: the site accepted the site-wide alarm subscription (response headers /// received), stamped with its stream generation so a late connect from a cancelled stream /// is ignored. This — not the reconnect attempt — is what earns a re-seed. internal sealed record GrpcAlarmStreamConnected(int Generation); /// Internal: coalesced-publish tick — flush the dirty cache to viewers once (N6). internal sealed record PublishCoalesced; /// Internal: site-alarm gRPC stream error occurred, stamped with the stream /// generation it came from so a late error from a cancelled stream can be ignored (N7.2). internal sealed record GrpcAlarmStreamError(Exception Exception, int Generation); /// Internal: the site-alarm gRPC stream ended gracefully (server status OK — max /// stream lifetime or site shutdown), stamped with its stream generation so a late completion /// from a cancelled stream can be ignored. internal sealed record GrpcAlarmStreamCompleted(int Generation); /// Internal: reconnect the site-alarm gRPC stream (flip node). internal sealed record ReconnectAlarmStream; /// Internal: the current site-alarm gRPC stream has stayed up long enough to recover the retry budget. internal sealed record GrpcAlarmStreamStable;