553 lines
23 KiB
C#
553 lines
23 KiB
C#
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 0–1000 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);
|
||
}
|