using System.Collections.Generic; using System.Threading; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// Per-driver counters surfaced via /// for the driver-diagnostics RPC (task #276). Hot-path increments use /// so they're lock-free; the read path snapshots into a /// keyed by stable counter names. /// /// /// 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. /// 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; /// Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero. 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); /// Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate. 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); } /// /// Snapshot the counters into the dictionary shape /// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is /// emitted as ticks to keep the value type uniform. /// public IReadOnlyDictionary Snapshot() { var dict = new Dictionary(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; } }