From 780720165a7921f96097007a3c285048e5f56718 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 25 Aug 2026 10:58:21 -0400 Subject: [PATCH] fix(GWC-35): repair re-raised alarm snapshots --- .../Alarms/GatewayAlarmMonitor.cs | 16 ++--- .../GatewayAlarmMonitorAttachOrderTests.cs | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs index 04fd3cb..5b238c3 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -839,15 +839,17 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) }, incoming.Key); } - else if (existing.CurrentState != incoming.Value.CurrentState - && incoming.Value.CurrentState == AlarmConditionState.ActiveAcked) + else if (existing.CurrentState != incoming.Value.CurrentState) { - // The alarm was already known but the worker now reports it acknowledged: the - // live Acknowledge transition never reached the feed. Without this the acked - // state is absorbed silently by the snapshot replace below and subscribers show - // the alarm unacked until it clears. + // The alarm was already known but the worker now reports a different state. An + // ActiveAcked snapshot repairs a missed Acknowledge. Any other incoming active + // state repairs a missed Raise for the incoming instance (including an + // acknowledged alarm reference that was cleared and re-raised between passes). + AlarmTransitionKind kind = incoming.Value.CurrentState == AlarmConditionState.ActiveAcked + ? AlarmTransitionKind.Acknowledge + : AlarmTransitionKind.Raise; Broadcast( - new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Acknowledge) }, + new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, kind) }, incoming.Key); } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs index 4bfbb58..f319ae2 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs @@ -155,6 +155,66 @@ public sealed class GatewayAlarmMonitorAttachOrderTests await monitor.StopAsync(CancellationToken.None); } + /// + /// GWC-35: an acknowledged cached alarm followed by an active snapshot with a newer + /// original-raise timestamp is a new alarm instance, not an acknowledgement reversal. The + /// reconcile repair must broadcast a Raise carrying the incoming instance identity. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ApplyReconcile_WhenAcknowledgedAlarmHasNewActiveInstance_BroadcastsRaise() + { + 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); + + Timestamp oldRaise = Timestamp.FromDateTimeOffset( + new DateTimeOffset(2026, 8, 10, 13, 0, 0, TimeSpan.Zero)); + sessions.SetReconcileSnapshot(SnapshotAt(AlarmConditionState.ActiveAcked, oldRaise, oldRaise)); + sessions.EmitEvent(ProviderModeProbe(1)); + await WaitUntilAsync( + () => monitor.CurrentAlarms.Any(alarm => alarm.AlarmFullReference == AlarmReference + && alarm.CurrentState == AlarmConditionState.ActiveAcked), + WaitTimeout); + + List received = []; + TaskCompletionSource snapshotComplete = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CancellationTokenSource streamCts = new(); + Task reader = ReadFeedAsync(monitor, received, snapshotComplete, streamCts.Token, untilSnapshotComplete: true); + await snapshotComplete.Task.WaitAsync(WaitTimeout); + + Timestamp newRaise = Timestamp.FromDateTimeOffset( + new DateTimeOffset(2026, 8, 10, 13, 0, 20, TimeSpan.Zero)); + sessions.SetReconcileSnapshot(SnapshotAt(AlarmConditionState.Active, newRaise, newRaise)); + sessions.EmitEvent(ProviderModeProbe(2)); + + AlarmFeedMessage raise = await WaitForAsync( + received, + m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition + && m.Transition.TransitionKind == AlarmTransitionKind.Raise, + WaitTimeout); + Assert.Equal(AlarmReference, raise.Transition.AlarmFullReference); + Assert.Equal(newRaise, raise.Transition.OriginalRaiseTimestamp); + Assert.Equal(newRaise, raise.Transition.TransitionTimestamp); + + lock (received) + { + AlarmFeedMessage[] transitions = received + .Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition) + .ToArray(); + Assert.Single(transitions); + } + + await streamCts.CancelAsync(); + await reader; + await cts.CancelAsync(); + await monitor.StopAsync(CancellationToken.None); + } + /// /// NEXT-03. A reconcile Raise repair applied while the matching live Raise is still /// buffered must not double-broadcast: the live transition carrying the same worker