From 8012509584a2cb3872c89a54b4b0acd6da7db28b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 12:48:13 -0400 Subject: [PATCH] feat(historian): honor per-alarm HistorizeToAveva opt-out at the durable write --- .../Messages/Alerts/AlarmTransitionEvent.cs | 4 +- .../ScriptedAlarmEngine.cs | 9 ++++- .../Historian/HistorianAdapterActor.cs | 8 +++- .../ScriptedAlarms/ScriptedAlarmHostActor.cs | 5 ++- .../ScriptedAlarmEngineTests.cs | 38 ++++++++++++++++++- .../Historian/HistorianAdapterActorTests.cs | 23 ++++++++++- 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs index ba1a8f71..3640908f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs @@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; /// When the transition occurred. /// OPC UA Part 9 condition subtype name — one of LimitAlarm / DiscreteAlarm / OffNormalAlarm / AlarmCondition (the base type, used as the default). The historian feed maps this onto the durable alarm-type column. /// Operator-supplied comment on ack / confirm / comment transitions; null for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment. +/// When false, the durable historian sink suppresses this transition (the live alerts fan-out is unaffected). Defaults to true. On a rolling restart an old-format message deserializes this as false (CLR default); that is safe because the writing node is always the same-version publisher — see HistorianAdapterActor. public sealed record AlarmTransitionEvent( string AlarmId, string EquipmentPath, @@ -26,4 +27,5 @@ public sealed record AlarmTransitionEvent( string User, DateTime TimestampUtc, string AlarmTypeName = "AlarmCondition", - string? Comment = null); + string? Comment = null, + bool HistorizeToAveva = true); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 0067f03f..7ef1c6e0 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -608,7 +608,11 @@ public sealed class ScriptedAlarmEngine : IDisposable EmissionKind.Confirmed => condition.LastConfirmComment, EmissionKind.CommentAdded => condition.Comments.Count == 0 ? null : condition.Comments[^1].Text, _ => null, - }); + }, + // Carry the per-alarm durable-historization opt-out through to subscribers. The historian + // adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is + // unaffected (it is not gated on this flag). + HistorizeToAveva: state.Definition.HistorizeToAveva); } /// @@ -834,7 +838,8 @@ public sealed record ScriptedAlarmEvent( AlarmConditionState Condition, EmissionKind Emission, DateTime TimestampUtc, - string? Comment = null); + string? Comment = null, + bool HistorizeToAveva = true); /// /// Upstream source abstraction — intentionally identical shape to the virtual-tag diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs index 253c3c82..db28ef16 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs @@ -71,7 +71,13 @@ public sealed class HistorianAdapterActor : ReceiveActor // ShouldHistorize gate keeps only the Primary writing ⇒ exactly-once across the warm pair. // NOTE: Translate is intentionally inside the gate so Secondary/Detached nodes never allocate a // discarded AlarmHistorianEvent. - Receive(t => { if (ShouldHistorize()) _ = EnqueueAsync(Translate(t)); }); + // t.HistorizeToAveva=false is a per-alarm opt-out of DURABLE historization only — the live `alerts` + // fan-out already happened upstream (the publish is NOT gated on this flag), so we gate the SINK + // write here, not the publish. Rolling-restart-safe: the node that WRITES is always the same-version + // node that PUBLISHED (Primary or boot window), so a cross-version old→new flow only reaches the + // Secondary, which never writes — an old-format message deserializing HistorizeToAveva as the CLR + // default (false) cannot drop a Primary's historization. + Receive(t => { if (ShouldHistorize() && t.HistorizeToAveva) _ = EnqueueAsync(Translate(t)); }); Receive(_ => Sender.Tell(_sink.GetStatus())); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index 6509e337..76c2903c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -298,7 +298,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor // Historian feed prep: carry the Part-9 subtype name (e.Kind.ToString() yields // LimitAlarm/DiscreteAlarm/OffNormalAlarm/AlarmCondition) + any operator comment. AlarmTypeName: e.Kind.ToString(), - Comment: e.Comment); + Comment: e.Comment, + // Per-alarm DURABLE-historization opt-out — honored downstream by HistorianAdapterActor to + // suppress only the sink write. This publish (and the live `/alerts` fan-out) is NOT gated on it. + HistorizeToAveva: e.HistorizeToAveva); // Warm-standby dedup: only the Primary (driver-role leader) publishes the cluster-wide // transition. Default-emit until told we are Secondary/Detached so single-node deploys + the diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs index 2236e7bf..b4061f15 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs @@ -22,14 +22,16 @@ public sealed class ScriptedAlarmEngineTests } private static ScriptedAlarmDefinition Alarm(string id, string predicate, - string msg = "condition", AlarmSeverity sev = AlarmSeverity.High) => + string msg = "condition", AlarmSeverity sev = AlarmSeverity.High, + bool historizeToAveva = true) => new(AlarmId: id, EquipmentPath: "Plant/Line1/Reactor", AlarmName: id, Kind: AlarmKind.AlarmCondition, Severity: sev, MessageTemplate: msg, - PredicateScriptSource: predicate); + PredicateScriptSource: predicate, + HistorizeToAveva: historizeToAveva); /// Verifies that LoadAsync compiles the alarm predicate and subscribes to all referenced upstream tags. [Fact] @@ -479,6 +481,38 @@ public sealed class ScriptedAlarmEngineTests events.First(e => e.Emission == EmissionKind.CommentAdded).Comment.ShouldBe("second look"); } + /// Verifies the emitted carries the + /// per-alarm opt-out flag from the through to the event so the + /// historian adapter can suppress the durable write while the live alerts fan-out is unaffected. + /// An alarm defined HistorizeToAveva: false emits false; the default (true) + /// emits true. + [Fact] + public async Task Emission_carries_HistorizeToAveva_flag_from_definition() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + using var eng = Build(up, out _); + await eng.LoadAsync( + [ + Alarm("OptOut", """return (int)ctx.GetTag("Temp").Value > 100;""", historizeToAveva: false), + Alarm("OptIn", """return (int)ctx.GetTag("Temp").Value > 100;""" /* default true */), + ], + TestContext.Current.CancellationToken); + + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + up.Push("Temp", 150); + await WaitForAsync(() => + events.Any(e => e.AlarmId == "OptOut" && e.Emission == EmissionKind.Activated) && + events.Any(e => e.AlarmId == "OptIn" && e.Emission == EmissionKind.Activated)); + + events.First(e => e.AlarmId == "OptOut" && e.Emission == EmissionKind.Activated) + .HistorizeToAveva.ShouldBeFalse("opt-out alarm carries HistorizeToAveva=false on its emission"); + events.First(e => e.AlarmId == "OptIn" && e.Emission == EmissionKind.Activated) + .HistorizeToAveva.ShouldBeTrue("opt-in (default) alarm carries HistorizeToAveva=true on its emission"); + } + // (2b) TimedShelveAsync / UnshelveAsync end-to-end through the engine. /// Verifies that TimedShelveAsync shelves with a deadline and UnshelveAsync removes the shelve before the timer expires. [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs index 2dff60f4..95b00e9b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs @@ -183,7 +183,8 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase int severity = 750, string alarmTypeName = "LimitAlarm", string? comment = "note", - string transitionKind = "Activated") => new( + string transitionKind = "Activated", + bool historizeToAveva = true) => new( AlarmId: "alm-9", EquipmentPath: "Area/Line/Equip", AlarmName: "HiHi", @@ -193,7 +194,8 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase User: "operator1", TimestampUtc: DateTime.UtcNow, AlarmTypeName: alarmTypeName, - Comment: comment); + Comment: comment, + HistorizeToAveva: historizeToAveva); /// Alerts translate (T6): an off the alerts topic /// is translated to an and historized by default (unknown role). @@ -251,6 +253,23 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); } + /// Per-alarm opt-out (T8b): a Primary node must NOT historize a transition whose + /// HistorizeToAveva is false — that flag is a per-alarm opt-out of DURABLE + /// historization only. The live alerts fan-out already happened upstream (the publish is NOT + /// gated on this flag), so only the durable sink write is suppressed. + [Fact] + public void Primary_node_does_not_historize_when_opted_out() + { + var sink = new RecordingSink(); + var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink, LocalNode)); + + TellRedundancyRole(actor, RedundancyRole.Primary); + actor.Tell(SampleTransition(historizeToAveva: false)); + + ExpectNoMsg(Settle); + sink.EnqueueCount.ShouldBe(0); + } + /// Severity buckets (T9): the OPC UA 1–1000 numeric severity on the transition maps onto /// the coarse at the same ceilings ScriptedAlarmHostActor.SeverityToInt /// emits (Low≤250, Medium≤500, High≤750, Critical otherwise). Driven end-to-end through the enqueue.