2e4ccf7fe9
Build: 0 errors solution-wide, and 0 warnings from every project this branch
touches. The ~816 solution-wide warnings are pre-existing xUnit1051 /
OTOPCUA0001 / CS86xx in untouched driver + client test projects.
Tests: full solution run compared against a full run on a detached worktree at
the pre-branch baseline 2e46d054. The two failure SETS are identical -- all 13
tests, same names, zero new failures. Net +26 tests: +3 Core.AlarmHistorian
(drain gate), +6 Runtime (role view), +17 Host.IntegrationTests (migrator +
convergence). Set comparison rather than counts, because the suite carries
standing environment- and load-dependent failures a count would hide.
The greps found real drift Task 6 missed -- eight live sites still naming the
deleted SqliteStoreAndForwardSink, including the AdminUI /alarms/historian
panel text, which is user-visible, and a <see cref> in HistorianAdapterActor
that resolved to nothing without warning. All repointed at
LocalDbStoreAndForwardSink; CLAUDE.md's alarm-history paragraph now also
records the LocalDb buffer and the primary-gated drain, and drops DatabasePath
from the knob list. docs/AlarmTracking.md still promised an
AlarmHistorianOptions.Validate() startup warning for a relative DatabasePath
and an empty SharedSecret; both branches are gone, so it now says so.
Code references to AlarmHistorian:DatabasePath reduce to exactly two intentional
ones: AlarmSfLegacyMigrator.LegacyPathKey and its test. No `new SqliteConnection`
remains anywhere in Core.AlarmHistorian.
Recon doc gains the durable verification record: guard-deletion evidence for
both vacuous passes, the two exact-set replicated-table pins (both assert set
equality, so an added or a dropped registration fails), and the baseline test
comparison with a per-failure account of why each of the 13 is not this
branch's.
Stops here per the plan. Task 8's live gate needs explicit go-ahead; nothing on
this branch is to be merged.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
172 lines
9.5 KiB
C#
172 lines
9.5 KiB
C#
using Akka.Actor;
|
||
using Akka.Cluster.Tools.PublishSubscribe;
|
||
using Akka.Event;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||
|
||
/// <summary>
|
||
/// Thin actor wrapper around <see cref="IAlarmHistorianSink"/>. Engine code (ScriptedAlarmActor,
|
||
/// Galaxy native alarm bridge, AB CIP ALMD reader) tells <see cref="AlarmHistorianEvent"/>s to this
|
||
/// actor; the actor enqueues them on the sink fire-and-forget. Production deployments register
|
||
/// <see cref="LocalDbStoreAndForwardSink"/> against <c>IAlarmHistorianSink</c>; the sink owns the
|
||
/// durable queue + drain-to-HistorianGateway-SendEvent loop. The actor here owns nothing operational beyond
|
||
/// the message contract — its job is to keep the engine actors on Akka's mailbox without blocking
|
||
/// them on disk I/O or gateway round-trips.
|
||
///
|
||
/// Query queue depth + drain health via <see cref="GetStatus"/>.
|
||
/// </summary>
|
||
public sealed class HistorianAdapterActor : ReceiveActor
|
||
{
|
||
public sealed record GetStatus
|
||
{
|
||
public static readonly GetStatus Instance = new();
|
||
}
|
||
|
||
private readonly IAlarmHistorianSink _sink;
|
||
private readonly NodeId? _localNode;
|
||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||
private IActorRef? _mediator;
|
||
|
||
/// <summary>Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
||
/// snapshot (null = unknown until the first snapshot arrives, or no <see cref="_localNode"/> wired). The
|
||
/// durable sink enqueue in the <see cref="AlarmHistorianEvent"/> handler is gated on this: only the
|
||
/// Primary historizes (default-write while unknown so single-node deploys + the boot window never drop
|
||
/// historization).</summary>
|
||
private RedundancyRole? _localRole;
|
||
|
||
/// <summary>Creates the props for a HistorianAdapterActor instance.</summary>
|
||
/// <param name="sink">The alarm historian sink implementation, or null to use a null sink.</param>
|
||
/// <param name="localNode">The local cluster node id, used to read this node's <see cref="RedundancyRole"/>
|
||
/// from the <c>redundancy-state</c> topic so only the Primary historizes to the durable sink. Null (the
|
||
/// default) leaves the role unknown ⇒ default-write (single-node deploys + tests).</param>
|
||
/// <returns>Props configured for creating a HistorianAdapterActor.</returns>
|
||
public static Props Props(IAlarmHistorianSink? sink = null, NodeId? localNode = null) =>
|
||
Akka.Actor.Props.Create(() => new HistorianAdapterActor(sink ?? NullAlarmHistorianSink.Instance, localNode));
|
||
|
||
/// <summary>Initializes a new instance of the HistorianAdapterActor class.</summary>
|
||
/// <param name="sink">The alarm historian sink to forward enqueued events to.</param>
|
||
/// <param name="localNode">The local cluster node id, used to read this node's <see cref="RedundancyRole"/>
|
||
/// from the <c>redundancy-state</c> topic so only the Primary historizes to the durable sink. Null leaves
|
||
/// the role unknown ⇒ default-write (single-node deploys + tests).</param>
|
||
public HistorianAdapterActor(IAlarmHistorianSink sink, NodeId? localNode = null)
|
||
{
|
||
_sink = sink;
|
||
_localNode = localNode;
|
||
|
||
// A direct AlarmHistorianEvent source is kept for a future engine→historian path; it goes through the
|
||
// SAME ShouldHistorize gate. WARNING: do NOT Tell an AlarmHistorianEvent for a transition that is ALSO
|
||
// published on the `alerts` topic — the sink would then double-write (both handlers enqueue it).
|
||
Receive<AlarmHistorianEvent>(evt => { if (ShouldHistorize()) _ = EnqueueAsync(evt); });
|
||
|
||
// Live alarm transitions arrive off the cluster `alerts` DPS topic (subscribed in PreStart). The
|
||
// Primary ScriptedAlarmHostActor publishes each transition ONCE, but DistributedPubSub fans that
|
||
// single message to EVERY node's subscriber — including BOTH central nodes' historian adapters. The
|
||
// 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 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()));
|
||
|
||
// Cluster redundancy snapshots (published on the `redundancy-state` topic, subscribed in PreStart)
|
||
// cache this node's role so the historize gate can scope the durable sink enqueue to the Primary.
|
||
// The PubSub Subscribe acks (redundancy-state + alerts) are acked back to Self (no-op below).
|
||
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
||
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
||
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
||
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
||
Receive<SubscribeAck>(_ => { });
|
||
}
|
||
|
||
/// <summary>True iff this node should write to the durable sink: only the Primary historizes
|
||
/// (default-write while the role is unknown so single-node deploys + the boot window never drop
|
||
/// historization). DPS fans the Primary's single alerts publish to BOTH nodes' historians, so this
|
||
/// gate is what keeps the durable write exactly-once across the warm-redundant pair.</summary>
|
||
private bool ShouldHistorize() => _localRole is not (RedundancyRole.Secondary or RedundancyRole.Detached);
|
||
|
||
/// <summary>Translates a live <see cref="AlarmTransitionEvent"/> (the alerts-topic shape) into the
|
||
/// historian's <see cref="AlarmHistorianEvent"/>. <c>AlarmTypeName</c> is null-coalesced to
|
||
/// "AlarmCondition": during a rolling restart Akka's JSON serializer applies the CLR default (null) to
|
||
/// an old-format message's <c>AlarmTypeName</c> rather than the record's call-site default, and the
|
||
/// historian must never store a null alarm type.</summary>
|
||
/// <param name="t">The live transition published on the <c>alerts</c> topic.</param>
|
||
/// <returns>The translated historian event.</returns>
|
||
private static AlarmHistorianEvent Translate(AlarmTransitionEvent t) => new(
|
||
AlarmId: t.AlarmId,
|
||
EquipmentPath: t.EquipmentPath,
|
||
AlarmName: t.AlarmName,
|
||
AlarmTypeName: string.IsNullOrEmpty(t.AlarmTypeName) ? "AlarmCondition" : t.AlarmTypeName,
|
||
Severity: ToSeverity(t.Severity),
|
||
EventKind: t.TransitionKind,
|
||
Message: t.Message,
|
||
User: t.User,
|
||
Comment: t.Comment,
|
||
TimestampUtc: t.TimestampUtc);
|
||
|
||
/// <summary>Maps the OPC UA 1–1000 numeric severity carried on a transition back to the coarse
|
||
/// <see cref="AlarmSeverity"/>, inverting <c>ScriptedAlarmHostActor.SeverityToInt</c>'s bucket ceilings
|
||
/// (Low=250, Medium=500, High=750, Critical=1000).</summary>
|
||
/// <param name="severity">The OPC UA 1–1000 numeric severity.</param>
|
||
/// <returns>The coarse alarm severity bucket.</returns>
|
||
private static AlarmSeverity ToSeverity(int severity) => severity switch
|
||
{
|
||
<= 250 => AlarmSeverity.Low,
|
||
<= 500 => AlarmSeverity.Medium,
|
||
<= 750 => AlarmSeverity.High,
|
||
_ => AlarmSeverity.Critical,
|
||
};
|
||
|
||
/// <inheritdoc />
|
||
protected override void PreStart()
|
||
{
|
||
_mediator = DistributedPubSub.Get(Context.System).Mediator;
|
||
_mediator.Tell(new Subscribe(OpcUaPublishActor.RedundancyStateTopic, Self));
|
||
_mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, Self));
|
||
base.PreStart();
|
||
}
|
||
|
||
/// <summary>Caches this node's <see cref="RedundancyRole"/> from a cluster redundancy snapshot so the
|
||
/// <see cref="AlarmHistorianEvent"/> handler can gate the durable sink enqueue to the Primary. A snapshot
|
||
/// that doesn't mention <see cref="_localNode"/> (or no local node wired) leaves the cached role unchanged
|
||
/// ⇒ default-write. Mirrors <see cref="OpcUaPublishActor"/>'s handler.</summary>
|
||
/// <param name="msg">The cluster redundancy snapshot.</param>
|
||
private void OnRedundancyStateChanged(RedundancyStateChanged msg)
|
||
{
|
||
if (_localNode is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var local = msg.Nodes.FirstOrDefault(n => n.NodeId == _localNode.Value);
|
||
if (local is not null)
|
||
{
|
||
_localRole = local.Role;
|
||
}
|
||
}
|
||
|
||
private async Task EnqueueAsync(AlarmHistorianEvent evt)
|
||
{
|
||
try
|
||
{
|
||
await _sink.EnqueueAsync(evt, CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.Error(ex, "Historian sink rejected event for {AlarmId} at {Ts}",
|
||
evt.AlarmId, evt.TimestampUtc);
|
||
}
|
||
}
|
||
}
|