Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs
T

553 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Native Alarm Actor — child of Instance Actor, peer to the computed
/// <see cref="AlarmActor"/>. Mirrors a single source binding's native alarms
/// read-only: subscribes through the DCL, applies snapshot/live transitions,
/// retains active+unacked conditions, persists to site SQLite, and emits an
/// enriched <see cref="AlarmStateChanged"/> upward for every transition.
///
/// State is keyed by <see cref="NativeAlarmTransition.SourceReference"/> (the
/// stable per-condition key). A snapshot replay (Snapshot* sentinels) atomically
/// swaps the mirrored set so conditions that cleared while disconnected emit a
/// return-to-normal. Persistence is best-effort/fire-and-forget — a failed write
/// never blocks the actor or aborts the emit.
///
/// Supervision: parent Instance Actor uses Resume for coordinator children.
/// </summary>
public class NativeAlarmActor : ReceiveActor
{
private readonly ResolvedNativeAlarmSource _source;
private readonly string _instanceName;
private readonly IActorRef _instanceActor;
private readonly IActorRef _dclManager;
private readonly SiteStorageService _storage;
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly AlarmKind _nativeKind;
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// Severity at or above which a native-alarm raise is logged as
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
/// Mirrors the 01000 condition-severity scale.
/// </summary>
private const int ErrorSeverityThreshold = 700;
/// <summary>Current mirrored conditions, keyed by source reference.</summary>
private readonly Dictionary<string, NativeAlarmTransition> _alarms = new();
/// <summary>Buffer accumulating a snapshot replay until <see cref="AlarmTransitionKind.SnapshotComplete"/>.</summary>
private readonly Dictionary<string, NativeAlarmTransition> _snapshotBuffer = new();
/// <summary>
/// Pending mirror upserts coalesced by source reference (P4): under an alarm storm each
/// source reference keeps only its LATEST transition, flushed as one batched SQLite write on
/// a short single-shot timer rather than a write per transition. A crash loses at most one
/// flush interval of mirror writes; the (re)subscribe snapshot swap reconciles the mirror, so
/// the durability trade is safe. A <see cref="PersistDelete"/> removes the ref here so a
/// deleted condition is not resurrected by a still-pending upsert.
/// </summary>
private readonly Dictionary<string, NativeAlarmTransition> _dirtyUpserts = new();
private ICancelable? _retryTimer;
private ICancelable? _flushTimer;
/// <summary>How long transitions coalesce before a batched persist flush (P4).</summary>
private readonly TimeSpan _persistFlushInterval;
/// <summary>Initializes a new <see cref="NativeAlarmActor"/> for a single resolved source binding.</summary>
/// <param name="source">The resolved native alarm source (connection + source reference + filter).</param>
/// <param name="instanceName">The owning instance's unique name.</param>
/// <param name="instanceActor">Parent instance actor; receives emitted <see cref="AlarmStateChanged"/>.</param>
/// <param name="dclManager">DCL target the subscribe request is sent to.</param>
/// <param name="storage">Site-local SQLite store for rehydration + persistence.</param>
/// <param name="options">Site runtime options (alarm cap, retry interval).</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="nativeKind">Alarm kind to stamp on emitted events (OPC UA vs MxAccess); set by the
/// Instance Actor from the connection protocol. Defaults to <see cref="AlarmKind.NativeOpcUa"/>.</param>
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
/// <see cref="ISiteEventLogger"/> for <c>alarm</c> operational events. Fire-and-forget;
/// a logging failure never affects the mirror.</param>
/// <param name="persistFlushInterval">How long native-alarm transitions coalesce before a
/// batched SQLite persist flush (P4). Defaults to 100 ms.</param>
public NativeAlarmActor(
ResolvedNativeAlarmSource source,
string instanceName,
IActorRef instanceActor,
IActorRef dclManager,
SiteStorageService storage,
SiteRuntimeOptions options,
ILogger logger,
AlarmKind nativeKind = AlarmKind.NativeOpcUa,
IServiceProvider? serviceProvider = null,
TimeSpan? persistFlushInterval = null)
{
_source = source;
_instanceName = instanceName;
_instanceActor = instanceActor;
_dclManager = dclManager;
_storage = storage;
_options = options;
_logger = logger;
_nativeKind = nativeKind;
_serviceProvider = serviceProvider;
_persistFlushInterval = persistFlushInterval ?? TimeSpan.FromMilliseconds(100);
Receive<RehydrationCompleted>(HandleRehydration);
Receive<NativeAlarmTransitionUpdate>(HandleTransition);
Receive<NativeAlarmSourceUnavailable>(HandleSourceUnavailable);
Receive<SubscribeAlarmsResponse>(HandleSubscribeResponse);
Receive<RetrySubscribe>(_ => SendSubscribe());
Receive<FlushPersistence>(_ => FlushDirtyUpserts());
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
// Rehydrate last-known state from SQLite (non-blocking), then subscribe.
// A fresh source snapshot will reconcile/replace this once it arrives.
_storage.GetNativeAlarmsAsync(_instanceName, _source.CanonicalName)
.PipeTo(Self, Self, rows => new RehydrationCompleted(rows));
SendSubscribe();
_logger.LogInformation(
"NativeAlarmActor started for {Source} ({Connection}:{Ref}) on {Instance}",
_source.CanonicalName, _source.ConnectionName, _source.SourceReference, _instanceName);
}
/// <inheritdoc />
protected override void PostStop()
{
_retryTimer?.Cancel();
_flushTimer?.Cancel();
// Best-effort final flush of any coalesced upserts so a graceful stop does not lose the
// last ≤ flush-interval of mirror writes (P4). Fire-and-forget — a failed final write is
// reconciled by the next (re)subscribe snapshot swap.
FlushDirtyUpserts();
base.PostStop();
}
private void SendSubscribe()
{
_dclManager.Tell(new SubscribeAlarmsRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
InstanceUniqueName: _instanceName,
ConnectionName: _source.ConnectionName,
SourceReference: _source.SourceReference,
ConditionFilter: _source.ConditionFilter,
Timestamp: DateTimeOffset.UtcNow));
// Arm a response-timeout retry so a LOST subscribe response is re-sent, not
// only a failure reply (S4). Cancelled on a Success reply; a failure reply
// re-arms it at 1× (overriding this 2× response-timeout arm). 2× interval so
// a slow-but-arriving response is not pre-empted by a redundant re-send.
_retryTimer?.Cancel();
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs * 2),
Self, RetrySubscribe.Instance, Self);
}
private void HandleRehydration(RehydrationCompleted msg)
{
foreach (var row in msg.Rows)
{
// A live transition that arrived before the read completed wins.
if (_alarms.ContainsKey(row.SourceReference))
{
continue;
}
AlarmConditionState? condition;
try
{
condition = JsonSerializer.Deserialize<AlarmConditionState>(row.ConditionJson);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Discarding unreadable persisted native alarm {Ref} on {Instance}",
row.SourceReference, _instanceName);
continue;
}
if (condition is null)
{
continue;
}
// Restore the persisted display metadata (UA4) so the rehydrated condition renders
// fully (type/category/message/values) until the first source snapshot replaces it.
// Null/malformed metadata falls back to empty strings (pre-UA4 rows behave as before).
NativeAlarmMetadata meta = EmptyMetadata;
if (!string.IsNullOrEmpty(row.MetadataJson))
{
try
{
meta = JsonSerializer.Deserialize<NativeAlarmMetadata>(row.MetadataJson) ?? EmptyMetadata;
}
catch (JsonException)
{
meta = EmptyMetadata;
}
}
var t = new NativeAlarmTransition(
row.SourceReference, string.Empty, meta.AlarmTypeName, AlarmTransitionKind.Snapshot,
condition, meta.Category, string.Empty, meta.Message, string.Empty, string.Empty,
null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue);
_alarms[row.SourceReference] = t;
// Rehydration replays last-known state on (re)start — surface it
// upward for the DebugView but do NOT re-log it as a fresh operational
// event (it is not a live transition).
Emit(t, t.Condition, logSiteEvent: false);
}
}
private void HandleTransition(NativeAlarmTransitionUpdate update)
{
var t = update.Transition;
switch (t.Kind)
{
case AlarmTransitionKind.Snapshot:
_snapshotBuffer[t.SourceReference] = t;
break;
case AlarmTransitionKind.SnapshotComplete:
ApplySnapshotSwap();
break;
default:
ApplyLiveTransition(t);
break;
}
}
/// <summary>
/// Atomically replaces the mirrored set with the buffered snapshot: conditions
/// no longer present emit a return-to-normal (and drop out); present conditions
/// are upserted, persisted, and emitted.
/// </summary>
private void ApplySnapshotSwap()
{
foreach (var (sourceRef, prior) in _alarms)
{
if (!_snapshotBuffer.ContainsKey(sourceRef))
{
Emit(prior, prior.Condition with { Active = false });
PersistDelete(sourceRef);
// This condition is gone for good — tell the parent
// to evict its _latestAlarmEvents key so it does not retain a stale
// (Normal) entry forever.
NotifyParentDropped(sourceRef);
}
}
_alarms.Clear();
foreach (var (sourceRef, t) in _snapshotBuffer)
{
_alarms[sourceRef] = t;
MarkDirtyUpsert(t);
// A snapshot replay is a re-sync of the source's current
// active set on (re)subscribe, NOT a live transition — surface it
// upward for the DebugView but do NOT re-log an `alarm` operational
// event. Otherwise every DCL reconnect would re-emit an `alarm`
// event for every already-active native condition (the
// synthesised return-to-normal above IS a real state change and
// keeps logSiteEvent: true).
Emit(t, t.Condition, logSiteEvent: false);
}
_snapshotBuffer.Clear();
EnforceCap();
}
private void ApplyLiveTransition(NativeAlarmTransition t)
{
// Ignore stale (out-of-order) transitions for a condition we already hold.
if (_alarms.TryGetValue(t.SourceReference, out var existing) && t.TransitionTime < existing.TransitionTime)
{
return;
}
_alarms[t.SourceReference] = t;
MarkDirtyUpsert(t);
Emit(t, t.Condition);
// Retention: a resolved condition (inactive AND acknowledged) drops out of
// the mirror — the return-to-normal has already been emitted above.
if (!t.Condition.Active && t.Condition.Acknowledged)
{
_alarms.Remove(t.SourceReference);
PersistDelete(t.SourceReference);
// Evict the parent's _latestAlarmEvents key for the
// now-resolved condition so it does not leak.
NotifyParentDropped(t.SourceReference);
}
EnforceCap();
}
private void HandleSourceUnavailable(NativeAlarmSourceUnavailable msg)
{
// Keep last-known conditions (uncertain) rather than clearing them; the
// reconnect snapshot reconciles state. No emit — avoids a flap.
_logger.LogWarning(
"Native alarm feed unavailable for {Source} ({Connection}) on {Instance}; retaining {Count} mirrored alarms pending reconnect snapshot",
_source.CanonicalName, msg.ConnectionName, _instanceName, _alarms.Count);
}
private void HandleSubscribeResponse(SubscribeAlarmsResponse resp)
{
if (resp.Success)
{
// Success — cancel the response-timeout retry armed in SendSubscribe.
_retryTimer?.Cancel();
_logger.LogInformation("Native alarm subscription established for {Source} on {Instance}",
_source.CanonicalName, _instanceName);
return;
}
_logger.LogWarning(
"Native alarm subscription failed for {Source} on {Instance}: {Error}; retrying in {RetryMs}ms",
_source.CanonicalName, _instanceName, resp.ErrorMessage, _options.NativeAlarmRetryIntervalMs);
_retryTimer?.Cancel();
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs),
Self, RetrySubscribe.Instance, Self);
}
/// <summary>Caps the mirrored set, dropping the oldest conditions (and logging — no silent truncation).</summary>
private void EnforceCap()
{
var cap = _options.MirroredAlarmCapPerSource;
if (_alarms.Count <= cap)
{
return;
}
var overflow = _alarms.Values
.OrderBy(a => a.TransitionTime)
.Take(_alarms.Count - cap)
.ToList();
foreach (var evicted in overflow)
{
var sourceRef = evicted.SourceReference;
// The sibling drop paths (ApplySnapshotSwap, the
// ApplyLiveTransition retention drop) always emit a return-to-normal
// before the condition leaves the mirror. EnforceCap previously dropped
// a condition whose last-emitted state could still be Active, with no
// compensating emit — so the Instance Actor (and central's stream / the
// operator Alarm Summary) kept showing it Active forever, a phantom
// stuck alarm the mirror could never clear. Emit the return-to-normal
// for any still-active evicted condition (mirroring ApplySnapshotSwap)
// before removing it.
if (evicted.Condition.Active)
{
Emit(evicted, evicted.Condition with { Active = false });
}
_alarms.Remove(sourceRef);
PersistDelete(sourceRef);
// This condition is gone for good — evict the parent's
// _latestAlarmEvents key so it does not retain a stale entry.
NotifyParentDropped(sourceRef);
_logger.LogWarning(
"Native alarm cap {Cap} exceeded for {Source} on {Instance}; dropped oldest mirrored alarm {Ref}",
cap, _source.CanonicalName, _instanceName, sourceRef);
}
}
/// <summary>
/// Signals the parent Instance Actor that a native condition has
/// left the mirror for good so it can evict the matching <c>_latestAlarmEvents</c>
/// key. Always sent AFTER the condition's final return-to-normal
/// <see cref="AlarmStateChanged"/> emit, so the stream still sees the clear.
/// </summary>
private void NotifyParentDropped(string sourceReference)
{
_instanceActor.Tell(new NativeAlarmDropped(sourceReference));
}
/// <summary>
/// Builds and tells the parent an enriched <see cref="AlarmStateChanged"/> for a condition.
/// </summary>
/// <param name="t">The mirrored transition.</param>
/// <param name="condition">The condition state to surface (may differ from <paramref name="t"/>'s
/// own condition, e.g. a synthesised return-to-normal on snapshot swap).</param>
/// <param name="logSiteEvent">When <c>true</c> (live + snapshot transitions), emit an
/// <c>alarm</c> operational event. Suppressed for SQLite rehydration so a node restart does not
/// re-log every last-known condition.</param>
private void Emit(NativeAlarmTransition t, AlarmConditionState condition, bool logSiteEvent = true)
{
var change = new AlarmStateChanged(
_instanceName,
t.SourceReference,
condition.Active ? AlarmState.Active : AlarmState.Normal,
condition.Severity,
t.TransitionTime)
{
Kind = _nativeKind,
Condition = condition,
SourceReference = t.SourceReference,
AlarmTypeName = t.AlarmTypeName,
Category = t.Category,
Message = t.Message,
OperatorUser = t.OperatorUser,
OperatorComment = t.OperatorComment,
OriginalRaiseTime = t.OriginalRaiseTime,
CurrentValue = t.CurrentValue,
LimitValue = t.LimitValue,
NativeSourceCanonicalName = _source.CanonicalName,
};
_instanceActor.Tell(change);
if (logSiteEvent)
{
LogAlarmEvent(t, condition);
}
}
/// <summary>
/// Fire-and-forget an <c>alarm</c> operational event mirroring a native
/// condition transition. An active condition is a raise (severity by the
/// condition's severity); an inactive condition is a return-to-normal; an
/// acknowledge transition is informational. Resolved optionally and never
/// awaited so a logging failure cannot affect the mirror (matching the
/// established ScriptActor/ScriptExecutionActor pattern).
/// </summary>
private void LogAlarmEvent(NativeAlarmTransition t, AlarmConditionState condition)
{
var logger = _serviceProvider?.GetService<ISiteEventLogger>();
if (logger == null)
{
return;
}
string severity;
string message;
if (t.Kind == AlarmTransitionKind.Acknowledge)
{
severity = "Info";
message = $"Native alarm {t.SourceReference} acknowledged";
}
else if (condition.Active)
{
severity = condition.Severity >= ErrorSeverityThreshold ? "Error" : "Warning";
message = $"Native alarm {t.SourceReference} active (severity {condition.Severity})";
}
else
{
severity = "Info";
message = $"Native alarm {t.SourceReference} returned to normal";
}
_ = logger.LogEventAsync(
"alarm", severity, _instanceName, $"NativeAlarmActor:{_source.CanonicalName}", message);
}
/// <summary>
/// Records the latest transition per source reference and arms the coalesced flush timer (P4).
/// The actual SQLite write is deferred to <see cref="FlushDirtyUpserts"/> so an alarm storm
/// collapses into one batched write instead of a write per transition.
/// </summary>
private void MarkDirtyUpsert(NativeAlarmTransition t)
{
_dirtyUpserts[t.SourceReference] = t;
if (_flushTimer == null)
{
_flushTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
_persistFlushInterval, Self, FlushPersistence.Instance, Self);
}
}
/// <summary>
/// Persists all coalesced upserts in one batched transaction (P4), then disarms the flush
/// timer. Fire-and-forget with OnlyOnFaulted logging — a failed write never blocks the actor;
/// the mirror reconciles on the next (re)subscribe snapshot.
/// </summary>
private void FlushDirtyUpserts()
{
_flushTimer = null;
if (_dirtyUpserts.Count == 0)
return;
var batch = _dirtyUpserts.Values
.Select(t => (
t.SourceReference,
JsonSerializer.Serialize(t.Condition),
(string?)JsonSerializer.Serialize(new NativeAlarmMetadata(
t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue)),
t.TransitionTime))
.ToList();
_dirtyUpserts.Clear();
_storage.UpsertNativeAlarmsAsync(_instanceName, _source.CanonicalName, batch)
.ContinueWith(
task => _logger.LogWarning(task.Exception,
"Failed to persist {Count} native alarms on {Instance}", batch.Count, _instanceName),
TaskContinuationOptions.OnlyOnFaulted);
}
private void PersistDelete(string sourceReference)
{
// Drop any pending coalesced upsert for this ref so the delete is not resurrected by a
// still-buffered upsert on the next flush (P4).
_dirtyUpserts.Remove(sourceReference);
_storage.DeleteNativeAlarmAsync(_instanceName, _source.CanonicalName, sourceReference)
.ContinueWith(
task => _logger.LogWarning(task.Exception,
"Failed to delete native alarm {Ref} on {Instance}", sourceReference, _instanceName),
TaskContinuationOptions.OnlyOnFaulted);
}
// ── Internal messages ──
private sealed record RehydrationCompleted(IReadOnlyList<NativeAlarmRow> Rows);
private sealed class RetrySubscribe
{
public static readonly RetrySubscribe Instance = new();
private RetrySubscribe() { }
}
/// <summary>Self-tell that fires the coalesced native-alarm persist flush (P4).</summary>
private sealed class FlushPersistence
{
public static readonly FlushPersistence Instance = new();
private FlushPersistence() { }
}
/// <summary>Empty metadata fallback for pre-UA4 rows or malformed <c>metadata_json</c>.</summary>
private static readonly NativeAlarmMetadata EmptyMetadata =
new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
/// <summary>
/// Persisted display metadata for a native alarm condition (UA4). Serialized into the
/// <c>metadata_json</c> column so a rehydrated condition renders fully (type/category/message/
/// current+limit values) before the first source snapshot re-supplies it.
/// </summary>
private sealed record NativeAlarmMetadata(
string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue);
}