Per-driver counters surfaced via DriverHealth.Diagnostics for the driver-diagnostics RPC. New OpcUaClientDiagnostics tracks PublishRequestCount, NotificationCount, NotificationsPerSecond (5s-half-life EWMA), MissingPublishRequestCount, DroppedNotificationCount, SessionResetCount and LastReconnectUtcTicks via Interlocked on the hot path. DriverHealth gains an optional IReadOnlyDictionary<string,double>? Diagnostics parameter (defaulted null for back-compat with the seven other drivers' constructors). OpcUaClientDriver wires Session.Notification + Session.PublishError on connect and on reconnect-complete (recording a session-reset there); GetHealth snapshots the counters on every poll so the RPC sees fresh values without a tick source. Tests: 11 new OpcUaClientDiagnosticsTests cover counter increments, EWMA convergence, snapshot shape, GetHealth integration, and DriverHealth back-compat. Full OpcUaClient.Tests 115/115 green. Closes #276 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
5.9 KiB
C#
135 lines
5.9 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|
|
|
/// <summary>
|
|
/// Per-driver counters surfaced via <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
|
/// for the <c>driver-diagnostics</c> RPC (task #276). Hot-path increments use
|
|
/// <see cref="Interlocked"/> so they're lock-free; the read path snapshots into a
|
|
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keyed by stable counter names.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The counters are operational metrics, not config — they reset to zero when the
|
|
/// driver instance is recreated (Reinitialize tear-down + rebuild) and there is no
|
|
/// persistence across process restarts. NotificationsPerSecond is a simple decay-EWMA
|
|
/// so a quiet subscription doesn't latch the value at the last burst rate.
|
|
/// </remarks>
|
|
internal sealed class OpcUaClientDiagnostics
|
|
{
|
|
// ---- Hot-path counters (Interlocked) ----
|
|
|
|
private long _publishRequestCount;
|
|
private long _notificationCount;
|
|
private long _missingPublishRequestCount;
|
|
private long _droppedNotificationCount;
|
|
private long _sessionResetCount;
|
|
|
|
// ---- EWMA state for NotificationsPerSecond ----
|
|
//
|
|
// Use ticks (long) for the timestamp so we can swap atomically. The rate is a double
|
|
// updated under a tight lock — the EWMA arithmetic (load, blend, store) isn't naturally
|
|
// atomic on doubles, and the spinlock is held only for arithmetic so contention is
|
|
// bounded. A subscription firing at 10 kHz with one driver instance is dominated by
|
|
// the SDK's notification path, not this lock.
|
|
private readonly object _ewmaLock = new();
|
|
private double _notificationsPerSecond;
|
|
private long _lastNotificationTicks;
|
|
|
|
/// <summary>Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero.</summary>
|
|
private static readonly TimeSpan EwmaHalfLife = TimeSpan.FromSeconds(5);
|
|
|
|
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
|
|
private long _lastReconnectUtcTicks;
|
|
|
|
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
|
|
public long NotificationCount => Interlocked.Read(ref _notificationCount);
|
|
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
|
|
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotificationCount);
|
|
public long SessionResetCount => Interlocked.Read(ref _sessionResetCount);
|
|
|
|
public DateTime? LastReconnectUtc
|
|
{
|
|
get
|
|
{
|
|
var ticks = Interlocked.Read(ref _lastReconnectUtcTicks);
|
|
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
|
}
|
|
}
|
|
|
|
public double NotificationsPerSecond
|
|
{
|
|
get { lock (_ewmaLock) return _notificationsPerSecond; }
|
|
}
|
|
|
|
public void IncrementPublishRequest() => Interlocked.Increment(ref _publishRequestCount);
|
|
|
|
public void IncrementMissingPublishRequest() => Interlocked.Increment(ref _missingPublishRequestCount);
|
|
|
|
public void IncrementDroppedNotification() => Interlocked.Increment(ref _droppedNotificationCount);
|
|
|
|
/// <summary>Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate.</summary>
|
|
public void RecordNotification() => RecordNotification(DateTime.UtcNow);
|
|
|
|
internal void RecordNotification(DateTime nowUtc)
|
|
{
|
|
Interlocked.Increment(ref _notificationCount);
|
|
|
|
// EWMA over instantaneous rate. instRate = 1 / dt (events per second since last sample).
|
|
// Decay factor a = 2^(-dt/halfLife) puts a five-second window on the smoothing — recent
|
|
// bursts win, idle periods bleed back to zero.
|
|
var nowTicks = nowUtc.Ticks;
|
|
lock (_ewmaLock)
|
|
{
|
|
if (_lastNotificationTicks == 0)
|
|
{
|
|
_lastNotificationTicks = nowTicks;
|
|
// First sample: seed at 0 — we don't know the prior rate. The next sample
|
|
// produces a real instRate.
|
|
return;
|
|
}
|
|
var dtTicks = nowTicks - _lastNotificationTicks;
|
|
if (dtTicks <= 0)
|
|
{
|
|
// Same-tick collisions on bursts: treat as no time elapsed for rate purposes
|
|
// (count was already incremented above) so we don't divide by zero or feed
|
|
// an absurd instRate spike.
|
|
return;
|
|
}
|
|
var dtSeconds = (double)dtTicks / TimeSpan.TicksPerSecond;
|
|
var instRate = 1.0 / dtSeconds;
|
|
var alpha = System.Math.Pow(0.5, dtSeconds / EwmaHalfLife.TotalSeconds);
|
|
_notificationsPerSecond = (alpha * _notificationsPerSecond) + ((1.0 - alpha) * instRate);
|
|
_lastNotificationTicks = nowTicks;
|
|
}
|
|
}
|
|
|
|
public void RecordSessionReset(DateTime nowUtc)
|
|
{
|
|
Interlocked.Increment(ref _sessionResetCount);
|
|
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
|
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
|
|
/// emitted as ticks to keep the value type uniform.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, double> Snapshot()
|
|
{
|
|
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
|
|
{
|
|
["PublishRequestCount"] = PublishRequestCount,
|
|
["NotificationCount"] = NotificationCount,
|
|
["NotificationsPerSecond"] = NotificationsPerSecond,
|
|
["MissingPublishRequestCount"] = MissingPublishRequestCount,
|
|
["DroppedNotificationCount"] = DroppedNotificationCount,
|
|
["SessionResetCount"] = SessionResetCount,
|
|
};
|
|
var last = LastReconnectUtc;
|
|
if (last is not null)
|
|
dict["LastReconnectUtcTicks"] = last.Value.Ticks;
|
|
return dict;
|
|
}
|
|
}
|