feat(historian): honor per-alarm HistorizeToAveva opt-out at the durable write

This commit is contained in:
Joseph Doherty
2026-06-11 12:48:13 -04:00
parent fa839d1dbf
commit 8012509584
6 changed files with 78 additions and 9 deletions
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
/// <param name="TimestampUtc">When the transition occurred.</param>
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected). Defaults to <c>true</c>. On a rolling restart an old-format message deserializes this as <c>false</c> (CLR default); that is safe because the writing node is always the same-version publisher — see HistorianAdapterActor.</param>
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);
@@ -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);
}
/// <summary>
@@ -834,7 +838,8 @@ public sealed record ScriptedAlarmEvent(
AlarmConditionState Condition,
EmissionKind Emission,
DateTime TimestampUtc,
string? Comment = null);
string? Comment = null,
bool HistorizeToAveva = true);
/// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
@@ -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<AlarmTransitionEvent>(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<AlarmTransitionEvent>(t => { if (ShouldHistorize() && t.HistorizeToAveva) _ = EnqueueAsync(Translate(t)); });
Receive<GetStatus>(_ => Sender.Tell(_sink.GetStatus()));
@@ -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