using System.Threading.Channels; 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; /// /// Thin actor wrapper around . Live alarm transitions arrive off the cluster /// alerts topic; the Primary's adapter translates each to an and queues it /// for the sink. The queue is bounded and drained by ONE loop, so the sink sees one enqueue at a time in arrival order /// (an alarm storm used to fan into one concurrent SQLite insert per transition, serialising on the file lock and /// holding pool threads); a full queue holds the mailbox until room frees (backpressure, nothing dropped), and stopping /// flushes the queue within the shared 5 s stop budget. Production deployments register /// against IAlarmHistorianSink; 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 . /// 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; /// The most events queued for the sink before a transition waits for room (backpressure). public const int QueueCapacity = 10_000; /// How long stopping waits for the queue to flush (the runtime's shared actor-stop budget). public static readonly TimeSpan FlushBudget = TimeSpan.FromSeconds(5); private readonly Channel _queue = Channel.CreateBounded( new BoundedChannelOptions(QueueCapacity) { SingleReader = true, SingleWriter = true, FullMode = BoundedChannelFullMode.Wait }); private readonly CancellationTokenSource _lifetime = new(); private Task _drain = Task.CompletedTask; /// Cached local from the latest /// snapshot (null = unknown until the first snapshot arrives, or no wired). The /// durable sink enqueue in the handler is gated on this: only the /// Primary historizes (default-write while unknown so single-node deploys + the boot window never drop /// historization). private RedundancyRole? _localRole; /// Creates the props for a HistorianAdapterActor instance. /// The alarm historian sink implementation, or null to use a null sink. /// The local cluster node id, used to read this node's /// from the redundancy-state topic so only the Primary historizes to the durable sink. Null (the /// default) leaves the role unknown ⇒ default-write (single-node deploys + tests). /// Props configured for creating a HistorianAdapterActor. public static Props Props(IAlarmHistorianSink? sink = null, NodeId? localNode = null) => Akka.Actor.Props.Create(() => new HistorianAdapterActor(sink ?? NullAlarmHistorianSink.Instance, localNode)); /// Initializes a new instance of the HistorianAdapterActor class. /// The alarm historian sink to forward enqueued events to. /// The local cluster node id, used to read this node's /// from the redundancy-state topic so only the Primary historizes to the durable sink. Null leaves /// the role unknown ⇒ default-write (single-node deploys + tests). public HistorianAdapterActor(IAlarmHistorianSink sink, NodeId? localNode = null) { _sink = sink; _localNode = localNode; // (The direct AlarmHistorianEvent receive is gone: it had no production sender, and a transition told directly // AND published on `alerts` would have been written twice.) // 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. ReceiveAsync(async t => { if (!ShouldHistorize() || t.HistorizeToAveva is false) return; var evt = Translate(t); // Off the mailbox unless the queue is full: then the mailbox waits for room (bounded memory, nothing dropped). if (_queue.Writer.TryWrite(evt)) return; try { await _queue.Writer.WriteAsync(evt, _lifetime.Token).ConfigureAwait(false); } catch (Exception ex) when (ex is OperationCanceledException or ChannelClosedException) { // Stopping raced the enqueue. } }); Receive(_ => 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(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(_ => { }); Receive(_ => { }); } /// 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. private bool ShouldHistorize() => _localRole is not (RedundancyRole.Secondary or RedundancyRole.Detached); /// Translates a live (the alerts-topic shape) into the /// historian's . AlarmTypeName is null-coalesced to /// "AlarmCondition": during a rolling restart Akka's JSON serializer applies the CLR default (null) to /// an old-format message's AlarmTypeName rather than the record's call-site default, and the /// historian must never store a null alarm type. /// The live transition published on the alerts topic. /// The translated historian event. 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); /// Maps the OPC UA 1–1000 numeric severity carried on a transition back to the coarse /// , inverting ScriptedAlarmHostActor.SeverityToInt's bucket ceilings /// (Low=250, Medium=500, High=750, Critical=1000). /// The OPC UA 1–1000 numeric severity. /// The coarse alarm severity bucket. private static AlarmSeverity ToSeverity(int severity) => severity switch { <= 250 => AlarmSeverity.Low, <= 500 => AlarmSeverity.Medium, <= 750 => AlarmSeverity.High, _ => AlarmSeverity.Critical, }; /// protected override void PreStart() { _mediator = DistributedPubSub.Get(Context.System).Mediator; _mediator.Tell(new Subscribe(OpcUaPublishActor.RedundancyStateTopic, Self)); _mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, Self)); _drain = Task.Run(DrainAsync); base.PreStart(); } /// protected override void PostStop() { // Flush what the Primary already accepted from the alerts topic, bounded by the shared stop budget. _queue.Writer.TryComplete(); if (!_drain.Wait(FlushBudget)) _log.Warning("Historian adapter stopped with alarm events still queued for the sink; abandoned after {Seconds} s.", FlushBudget.TotalSeconds); _lifetime.Cancel(); _lifetime.Dispose(); base.PostStop(); } /// The single loop feeding the sink, in arrival order. Never throws: a rejected event is logged. private async Task DrainAsync() { // Captured now: PostStop disposes the source after the flush budget, and a disposed source's Token throws. var lifetime = _lifetime.Token; await foreach (var evt in _queue.Reader.ReadAllAsync(CancellationToken.None).ConfigureAwait(false)) await EnqueueAsync(evt, lifetime).ConfigureAwait(false); } /// Caches this node's from a cluster redundancy snapshot so the /// handler can gate the durable sink enqueue to the Primary. A snapshot /// that doesn't mention (or no local node wired) leaves the cached role unchanged /// ⇒ default-write. Mirrors 's handler. /// The cluster redundancy snapshot. 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, CancellationToken lifetime) { try { await _sink.EnqueueAsync(evt, lifetime).ConfigureAwait(false); } catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { // Stopped past the flush budget. } catch (Exception ex) { _log.Error(ex, "Historian sink rejected event for {AlarmId} at {Ts}", evt.AlarmId, evt.TimestampUtc); } } }