Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
T
Joseph Doherty 6cbf763719 fix(alarms): engine cache prune + bounded dispose, source Warning, cached dead-letter depth, script-log rate limit (E3)
- R05:1/12 ScriptedAlarmEngine: _valueCache is cleared in LoadAsync (it is
  re-seeded for the referenced paths) so retired tags no longer accrete across
  deploys; OnUpstreamChange no longer writes the cache after Dispose.
- R05:3 ScriptedAlarmEngine.Dispose drains in-flight work for at most 5 s
  (DisposeDrainTimeout), then cancels it through a shutdown token every
  DrainDirtyAsync/ShelvingCheckAsync now runs on, logs a Warning and gives
  one second of grace — a stalled IAlarmStateStore.SaveAsync can no longer
  hang host shutdown. A save that completes inside the bound is still drained
  (positive control test).
- R05:6 ScriptedAlarmSource logs a throwing OnAlarmEvent subscriber at
  Warning (naming the alarm) instead of swallowing it.
- R05:7 LocalDbStoreAndForwardSink keeps a cached dead-letter counter in step
  with every dead-letter/revive/purge path and resyncs it with the queued
  counter on the same cadence; GetStatus no longer runs a COUNT(*) scan.
- R05:9 ScriptLogRateLimiter (20 per source per minute, fixed window, one
  summary entry naming the dropped count when the window rolls) behind
  ScriptLogTopicSink (key = script|vtag|alarm) and ScriptLogCompanionSink
  (key = script name).

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:17:30 -04:00

153 lines
6.9 KiB
C#

using System.Collections.Concurrent;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Adapter that exposes <see cref="ScriptedAlarmEngine"/> through the driver-agnostic
/// <see cref="IAlarmSource"/> surface. The existing Phase 6.1 <c>AlarmTracker</c>
/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm
/// sources — no per-source branching in the fan-out.
/// </summary>
/// <remarks>
/// <para>
/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA
/// method calls per-condition. This adapter implements <see cref="AcknowledgeAsync"/>
/// from the base interface; the richer Part 9 methods (Confirm / Shelve /
/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA
/// method handlers wired up in Stream G.
/// </para>
/// <para>
/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an
/// Equipment path prefix). When the list is empty every alarm matches. The
/// adapter doesn't maintain per-subscription state beyond the filter set — it
/// checks each emission against every live subscription.
/// </para>
/// <para>
/// A throwing <see cref="OnAlarmEvent"/> subscriber is logged at Warning (naming the alarm) and
/// does not crash the adapter — the same posture as the engine's own <c>FireEvent</c>. It used to
/// be swallowed silently: a lost alarm event with no trace.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
{
private readonly ScriptedAlarmEngine _engine;
private readonly ILogger _logger;
private readonly ConcurrentDictionary<string, Subscription> _subscriptions
= new(StringComparer.Ordinal);
private bool _disposed;
/// <summary>Initializes a new instance of the <see cref="ScriptedAlarmSource"/> class.</summary>
/// <param name="engine">The scripted alarm engine to expose.</param>
/// <param name="logger">Where a throwing subscriber is reported; null ⇒ Serilog's global logger.</param>
public ScriptedAlarmSource(ScriptedAlarmEngine engine, ILogger? logger = null)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_logger = logger ?? Log.Logger;
_engine.OnEvent += OnEngineEvent;
}
/// <summary>Occurs when an alarm event is raised by the engine.</summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <inheritdoc />
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds));
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
_subscriptions[handle.DiagnosticId] = new Subscription(handle,
new HashSet<string>(sourceNodeIds, StringComparer.Ordinal));
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is null) throw new ArgumentNullException(nameof(handle));
_subscriptions.TryRemove(handle.DiagnosticId, out _);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
foreach (var a in acknowledgements)
{
// Honor the authenticated principal carried on the request when present
// (the OPC UA dispatch layer / Stream G threads it through). When absent —
// the raw/non-OPC-UA path — default to "opcua-client" so the ack still
// produces an audit entry.
await _engine.AcknowledgeAsync(a.ConditionId, a.OperatorUser ?? "opcua-client", a.Comment, cancellationToken)
.ConfigureAwait(false);
}
}
private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt)
{
if (_disposed) return;
// QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
if (evt.Emission == EmissionKind.QualityChanged) return;
foreach (var sub in _subscriptions.Values)
{
if (!Matches(sub, evt)) continue;
var payload = new AlarmEventArgs(
SubscriptionHandle: sub.Handle,
SourceNodeId: evt.EquipmentPath,
ConditionId: evt.AlarmId,
AlarmType: evt.Kind.ToString(),
Message: evt.Message,
Severity: evt.Severity,
SourceTimestampUtc: evt.TimestampUtc);
try { OnAlarmEvent?.Invoke(this, payload); }
catch (Exception ex)
{
// Subscriber exceptions don't crash the adapter — but they are not silent either: a lost
// alarm event must leave a trace the operator can find.
_logger.Warning(ex, "ScriptedAlarmSource OnAlarmEvent subscriber threw for {AlarmId} ({Emission})",
evt.AlarmId, evt.Emission);
}
}
}
private static bool Matches(Subscription sub, ScriptedAlarmEvent evt)
{
if (sub.Filter.Count == 0) return true;
// A subscription matches if any filter is a prefix of the alarm's equipment
// path — typical use is "Enterprise/Site/Area/Line" filtering a whole line.
foreach (var f in sub.Filter)
{
if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true;
if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true;
}
return false;
}
/// <summary>Releases resources used by the <see cref="ScriptedAlarmSource"/>.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_engine.OnEvent -= OnEngineEvent;
_subscriptions.Clear();
}
private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
{
/// <summary>Initializes a new instance of the <see cref="SubscriptionHandle"/> class.</summary>
/// <param name="id">The diagnostic ID for this subscription handle.</param>
public SubscriptionHandle(string id) { DiagnosticId = id; }
/// <inheritdoc />
public string DiagnosticId { get; }
}
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet<string> Filter);
}