harden(historian): nullable HistorizeToAveva (missing→historize) for rolling-restart-safe deserialize + middle-link test

This commit is contained in:
Joseph Doherty
2026-06-11 13:00:57 -04:00
parent c20d228384
commit 61b230d79a
4 changed files with 65 additions and 11 deletions
@@ -16,7 +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>
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
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);
@@ -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<AlarmTransitionEvent>(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<AlarmTransitionEvent>(t => { if (ShouldHistorize() && t.HistorizeToAveva is not false) _ = EnqueueAsync(Translate(t)); });
Receive<GetStatus>(_ => Sender.Tell(_sink.GetStatus()));