From cbb5e8363395fdd3a2db7f8595b3e48912d84ec4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 11 Sep 2026 09:59:27 -0400 Subject: [PATCH] feat(site-runtime): stamp IsMesReportable on the Instance Actor alarm snapshot Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB --- .../Messages/Streaming/AlarmStateChanged.cs | 9 + .../Actors/InstanceActor.cs | 27 ++- ...InstanceActorMesReportableSnapshotTests.cs | 163 ++++++++++++++++++ 3 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs index e163a02c..ca5691be 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs @@ -107,4 +107,13 @@ public record AlarmStateChanged( /// show the binding node as "no active conditions". Additive; default false. /// public bool IsConfiguredPlaceholder { get; init; } + + /// + /// True when the alarm's deployed configuration flags it as MES-reportable + /// (ResolvedAlarm.IsMesReportable). Stamped by the Instance Actor on its alarm + /// snapshot; always false for native conditions, which have no per-alarm config row. + /// NOT carried on the gRPC stream or the debug-snapshot DTO in this slice (no proto + /// field on either) — central sees the default. Additive; default false. + /// + public bool IsMesReportable { get; init; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index ebc746c0..933bedac 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -51,6 +51,16 @@ public class InstanceActor : ReceiveActor private readonly Dictionary _alarmStates = new(); private readonly Dictionary _alarmTimestamps = new(); private readonly Dictionary _alarmPriorities = new(); + + /// + /// Computed-alarm canonical name → the deployed ResolvedAlarm.IsMesReportable + /// flag. Populated alongside when the Alarm Actor + /// children are created and read back in , which + /// is the single place the flag is stamped — so a fired alarm (served from the retained + /// event) and an unfired one (served from the bare fallback projection) cannot drift. + /// Native conditions have no entry here and correctly resolve to false. + /// + private readonly Dictionary _alarmMesReportable = new(); private readonly Dictionary _scriptActors = new(); private readonly Dictionary _alarmActors = new(); private readonly Dictionary _nativeAlarmActors = new(); @@ -1568,7 +1578,16 @@ public class InstanceActor : ReceiveActor /// private List BuildAlarmStatesSnapshot() { - var states = _latestAlarmEvents.Values.ToList(); + // The MES-reportable flag is stamped HERE and nowhere else (not at the Alarm + // Actor's emit sites), so the retained-event rows below and the fallback rows + // that follow always agree on it. A native condition has no _alarmMesReportable + // entry and is returned as-is (no record copy) — the default false is the intended + // result, and native rows dominate this list on a busy binding. + var states = _latestAlarmEvents.Values + .Select(e => _alarmMesReportable.TryGetValue(e.AlarmName, out var mes) && mes + ? e with { IsMesReportable = true } + : e) + .ToList(); foreach (var name in _alarmActors.Keys) { @@ -1582,7 +1601,10 @@ public class InstanceActor : ReceiveActor name, _alarmStates.GetValueOrDefault(name, AlarmState.Normal), _alarmPriorities.GetValueOrDefault(name, 0), - _alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow))); + _alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)) + { + IsMesReportable = _alarmMesReportable.GetValueOrDefault(name) + }); } // A native binding is "already represented" if any retained event carries its @@ -1980,6 +2002,7 @@ public class InstanceActor : ReceiveActor var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}"); _alarmActors[alarm.CanonicalName] = actorRef; _alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel; + _alarmMesReportable[alarm.CanonicalName] = alarm.IsMesReportable; _alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow; RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration)); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs new file mode 100644 index 00000000..45e7293e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// The deployed alarm's IsMesReportable flag must reach the site-local alarm +/// snapshot that Alarms.CurrentAsync() and the Debug View query both build (the +/// flag does not cross DebugAlarmStateDto to central in this slice). It is stamped in ONE +/// place — BuildAlarmStatesSnapshot — so a computed alarm that has fired (served +/// from the retained AlarmStateChanged) and one that never has (served from the +/// bare Normal fallback) can never disagree about the flag. +/// +public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteRuntimeOptions _options = new(); + private readonly TestLocalDb _localDb; + + public InstanceActorMesReportableSnapshotTests() + { + _localDb = TestLocalDb.CreateTemp("instance-mes-reportable"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService(NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance); + } + + private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) => + ActorOf(Props.Create(() => new InstanceActor( + instanceName, + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedScriptLibrary, + null, + _options, + NullLogger.Instance, + null))); + + /// + /// One static attribute driving two computed alarms: HighTemp is flagged + /// MES-reportable and fires at 80; TempSpike is not flagged and only matches + /// the exact value 100, so it never fires in this test. + /// + private static FlattenedConfiguration TwoComputedAlarmsConfig(string instanceName) => new() + { + InstanceUniqueName = instanceName, + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Temperature", + Value = "20", + DataType = "Double" + } + ], + Alarms = + [ + new ResolvedAlarm + { + CanonicalName = "HighTemp", + TriggerType = "HiLo", + TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"hi\":80}", + PriorityLevel = 700, + IsMesReportable = true + }, + new ResolvedAlarm + { + CanonicalName = "TempSpike", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}", + PriorityLevel = 300, + IsMesReportable = false + } + ] + }; + + private GetAlarmSnapshotResponse AskSnapshot(IActorRef actor, string instanceName) + { + var correlationId = Guid.NewGuid().ToString(); + actor.Tell(new GetAlarmSnapshotRequest(correlationId, instanceName, DateTimeOffset.UtcNow)); + // Match on the correlation id so a late reply from a timed-out probe can never be + // consumed as the "current" snapshot by a later call; keep the inner wait well + // inside the AwaitAssert budget so one slow probe doesn't eat the whole retry window. + return ExpectMsg( + m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms() + { + const string instance = "Oven1"; + var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance)); + + // Drive Temperature above the Hi setpoint: HighTemp fires, TempSpike does not. + actor.Tell(new SetStaticAttributeCommand( + "corr-temp", instance, "Temperature", "90", DateTimeOffset.UtcNow)); + var write = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(write.Success); + + // The alarm child evaluates asynchronously — poll the snapshot until the + // retained event for HighTemp has landed. + AwaitAssert(() => + { + var pending = AskSnapshot(actor, instance); + var firing = Assert.Single(pending.Alarms, a => a.AlarmName == "HighTemp"); + Assert.Equal(AlarmState.Active, firing.State); + }, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100)); + + var snapshot = AskSnapshot(actor, instance); + + // Both rows are still returned — stamping must not drop or duplicate anything. + Assert.Equal(2, snapshot.Alarms.Count); + + // Fired alarm: served from the retained AlarmStateChanged. + var fired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp"); + Assert.Equal(AlarmState.Active, fired.State); + Assert.True(fired.IsMesReportable); + + // Never-fired alarm: served from the bare Normal fallback projection. + var unfired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike"); + Assert.Equal(AlarmState.Normal, unfired.State); + Assert.False(unfired.IsMesReportable); + } + + [Fact] + public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires() + { + // The fallback branch must carry the flag on its own: an alarm that has never + // emitted an event is still MES-reportable per its deployed configuration. + const string instance = "Oven2"; + var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance)); + + var snapshot = AskSnapshot(actor, instance); + + Assert.Equal(2, snapshot.Alarms.Count); + Assert.True(Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp").IsMesReportable); + Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable); + } + + void IDisposable.Dispose() + { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. + Shutdown(); + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } +}