From 61b230d79ad9ea291e361b815f4a90ea6857bdac Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 13:00:57 -0400 Subject: [PATCH] =?UTF-8?q?harden(historian):=20nullable=20HistorizeToAvev?= =?UTF-8?q?a=20(missing=E2=86=92historize)=20for=20rolling-restart-safe=20?= =?UTF-8?q?deserialize=20+=20middle-link=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messages/Alerts/AlarmTransitionEvent.cs | 4 +-- .../Historian/HistorianAdapterActor.cs | 13 ++++--- .../Historian/HistorianAdapterActorTests.cs | 23 ++++++++++-- .../ScriptedAlarmHostActorTests.cs | 36 +++++++++++++++++++ 4 files changed, 65 insertions(+), 11 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 3640908f..45c35d5b 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,7 +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. +/// When false, the durable historian sink suppresses this transition (the live alerts fan-out is unaffected); null or true historize. null is the cross-version/rolling-restart case: an old-format message missing the field deserializes to null (CLR default for bool?) and is historized (safe default-on), matching the AlarmTypeName null-coalesce in HistorianAdapterActor.Translate. The producer (ScriptedAlarmHostActor) always sets a concrete true/false. public sealed record AlarmTransitionEvent( string AlarmId, string EquipmentPath, @@ -28,4 +28,4 @@ public sealed record AlarmTransitionEvent( DateTime TimestampUtc, string AlarmTypeName = "AlarmCondition", string? Comment = null, - bool HistorizeToAveva = true); + bool? HistorizeToAveva = null); 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 db28ef16..9c99dfdc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs @@ -71,13 +71,12 @@ 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. - // 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)); }); + // t.HistorizeToAveva is not false: only explicit false suppresses the durable sink write. null + // (CLR default for bool?) and true both historize. null is the rolling-restart / cross-version case: + // an old-format message missing the field deserializes to null and is historized (default-on), so no + // audit row is dropped at a handover — same posture as the AlarmTypeName null-coalesce in Translate. + // The producer (ScriptedAlarmHostActor) always sets a concrete true/false. + Receive(t => { if (ShouldHistorize() && t.HistorizeToAveva is not false) _ = EnqueueAsync(Translate(t)); }); Receive(_ => Sender.Tell(_sink.GetStatus())); 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 95b00e9b..0c291c83 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 @@ -178,13 +178,15 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase } /// Builds an (the shape published on the alerts - /// DPS topic) for the translate tests, with overridable severity / type / comment / kind. + /// DPS topic) for the translate tests, with overridable severity / type / comment / kind. + /// is bool? so tests can pass null to simulate the + /// rolling-restart / cross-version case (missing field → CLR default null). private static AlarmTransitionEvent SampleTransition( int severity = 750, string alarmTypeName = "LimitAlarm", string? comment = "note", string transitionKind = "Activated", - bool historizeToAveva = true) => new( + bool? historizeToAveva = true) => new( AlarmId: "alm-9", EquipmentPath: "Area/Line/Equip", AlarmName: "HiHi", @@ -270,6 +272,23 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase sink.EnqueueCount.ShouldBe(0); } + /// Rolling-restart default-on (T8c): when HistorizeToAveva is null — the shape + /// a cross-version / rolling-restart deserialize produces (old-format message missing the field maps to + /// the CLR default null for bool?) — a Primary node MUST historize. null is the + /// safe default-on posture: no audit row is dropped at a handover, matching the AlarmTypeName + /// null-coalesce precedent in the same HistorianAdapterActor.Translate. + [Fact] + public void Primary_historizes_when_flag_is_null() + { + var sink = new RecordingSink(); + var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink, LocalNode)); + + TellRedundancyRole(actor, RedundancyRole.Primary); + actor.Tell(SampleTransition(historizeToAveva: null)); + + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + } + /// 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. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 5dafa92b..c096e9ef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -576,6 +576,42 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// HistorizeToAveva flag threading (middle-link): the host MUST carry the plan's + /// HistorizeToAveva flag onto the it publishes to the + /// alerts topic. Verifies both the true (default ) and the + /// false ( carries false on the fixture) cases so any regression in + /// ScriptedAlarmHostActor.OnEngineEmission's flag threading is caught here before + /// HistorianAdapterActor's opt-out gate becomes the first line of defence. + [Fact] + public void HistorizeToAveva_flag_is_threaded_onto_published_AlarmTransitionEvent() + { + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var alerts = CreateTestProbe(); + SubscribeToAlerts(alerts); + + // Build two plans: one with HistorizeToAveva=true (Plan default), one with false. + // Use distinct ids + dep refs so both load cleanly side-by-side. + var planTrue = Plan(id: "alm-hist-true", depRef: "H.T", severity: 800); // HistorizeToAveva: true + var planFalse = Plan(id: "alm-hist-false", depRef: "H.F", severity: 800) with { HistorizeToAveva = false }; + + var (host, _) = Spawn(publish, mux); + host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { planTrue, planFalse })); + mux.ExpectMsg(Timeout); // load completed + + // Activate the true-flag alarm. + host.Tell(new VirtualTagActor.DependencyValueChanged("H.T", 99, DateTime.UtcNow)); + var evtTrue = alerts.FishForMessage( + e => e.AlarmId == "alm-hist-true" && e.TransitionKind == "Activated", Timeout); + evtTrue.HistorizeToAveva.ShouldBe(true); + + // Activate the false-flag alarm. + host.Tell(new VirtualTagActor.DependencyValueChanged("H.F", 99, DateTime.UtcNow)); + var evtFalse = alerts.FishForMessage( + e => e.AlarmId == "alm-hist-false" && e.TransitionKind == "Activated", Timeout); + evtFalse.HistorizeToAveva.ShouldBe(false); + } + /// Absent-node default-emit (A1): a snapshot that /// contains ONLY other nodes (the host's own is absent) must leave the /// cached local role unchanged (null/unknown) — the host therefore defaults to emit, publishing