From f1e26fed4f22ee676f460dd546ad1332cfe72c10 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 12:20:51 -0400 Subject: [PATCH] perf(alarms): memoize CurrentAlarms projection, invalidate on mutation --- .../Alarms/GatewayAlarmMonitor.cs | 23 ++++++++- .../GatewayAlarmMonitorAttachOrderTests.cs | 49 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs index 59bb783..b29d00a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -34,6 +34,13 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic private readonly Dictionary _alarms = new(StringComparer.Ordinal); private readonly List _subscribers = []; + // Memoized CurrentAlarms projection, guarded by _sync: the cloned, read-only view of _alarms + // handed to the dashboard and the QueryActiveAlarms RPC. Cloning the whole set per read held + // _sync — the broadcast lock — for the length of the copy, so a polled dashboard stalled every + // ApplyTransition/Broadcast behind it. Null means "not built for the current generation": + // every path that writes _alarms must null this under _sync, or readers keep a stale set. + private ActiveAlarmSnapshot[]? _currentAlarmsProjection; + // NEXT-03 dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by // the most recent reconcile pass, keyed by reference with the instance's original raise // timestamp as the identity marker. A buffered live Clear for the same instance is a duplicate @@ -93,7 +100,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic { lock (_sync) { - return _alarms.Values.Select(alarm => alarm.Clone()).ToArray(); + // Same clone semantics as an uncached read — callers still get instances no + // mutation can leak back into the cache — but built once per alarm-set + // generation instead of once per caller. + return _currentAlarmsProjection ??= _alarms.Values + .Select(alarm => alarm.Clone()) + .ToArray(); } } } @@ -422,6 +434,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic if (transition.TransitionKind == AlarmTransitionKind.Clear) { bool wasKnown = _alarms.Remove(reference); + if (wasKnown) + { + _currentAlarmsProjection = null; + } + if (!wasKnown && IsDuplicateOfReconcileClear(reference, transition)) { return; @@ -433,6 +450,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic bool duplicate = _alarms.TryGetValue(reference, out ActiveAlarmSnapshot? existing) && IsDuplicateOfCachedState(existing, snapshot); _alarms[reference] = snapshot; + _currentAlarmsProjection = null; if (duplicate) { return; @@ -650,6 +668,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic { _alarms[incoming.Key] = incoming.Value; } + + _currentAlarmsProjection = null; } } @@ -696,6 +716,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic lock (_sync) { _alarms.Clear(); + _currentAlarmsProjection = null; } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs index d262154..969645b 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs @@ -282,6 +282,55 @@ public sealed class GatewayAlarmMonitorAttachOrderTests await monitor.StopAsync(CancellationToken.None); } + /// + /// clones the whole active-alarm set under + /// the broadcast lock, so rebuilding it per read stalls every transition and broadcast + /// behind the copy once the dashboard polls a large alarm set. The projection is memoized + /// for as long as the set is unchanged, and every mutation must invalidate it — a stale + /// projection would hide live transitions from the dashboard and the QueryActiveAlarms RPC. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task CurrentAlarmsProjectionIsMemoizedUntilTheAlarmSetChanges() + { + using GatewayMetrics metrics = new(); + await using FakeSessionManager sessions = new(); + using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics); + + using CancellationTokenSource cts = new(); + await monitor.StartAsync(cts.Token); + await sessions.WaitForSubscribeStartAsync(WaitTimeout); + + // Seed through a reconcile (forced by a provider-mode probe) so the cache holds one + // unacked alarm and no further mutation is in flight. + sessions.SetReconcileSnapshot(Snapshot(AlarmConditionState.Active)); + sessions.EmitEvent(ProviderModeProbe(1)); + await WaitUntilAsync( + () => monitor.CurrentAlarms.Any(alarm => alarm.AlarmFullReference == AlarmReference + && alarm.CurrentState == AlarmConditionState.Active), + WaitTimeout); + + IReadOnlyList first = monitor.CurrentAlarms; + Assert.Same(first, monitor.CurrentAlarms); + + // A live Acknowledge replaces the cached snapshot, so the next read must rebuild. + sessions.EmitEvent(Transition(2, AlarmTransitionKind.Acknowledge)); + await WaitUntilAsync( + () => monitor.CurrentAlarms.Any(alarm => alarm.AlarmFullReference == AlarmReference + && alarm.CurrentState == AlarmConditionState.ActiveAcked), + WaitTimeout); + + IReadOnlyList second = monitor.CurrentAlarms; + Assert.NotSame(first, second); + Assert.Same(second, monitor.CurrentAlarms); + + // The pre-transition projection is a snapshot of the old generation, not a live view. + Assert.Equal(AlarmConditionState.Active, Assert.Single(first).CurrentState); + + await cts.CancelAsync(); + await monitor.StopAsync(CancellationToken.None); + } + private static GatewayAlarmMonitor CreateMonitor(FakeSessionManager sessions, GatewayMetrics metrics) { AlarmsOptions options = new()