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.