using System.Collections.Concurrent; using System.Threading.Channels; namespace ZB.MOM.WW.OtOpcUa.Runtime.Telemetry; /// /// Default : a process-wide singleton on driver-role nodes. /// /// /// /// No DPS. This hub is fed only by this node's own in-process producers (a later /// task taps them). It never subscribes to cluster DistributedPubSub — see the invariant on /// . /// /// /// Snapshot-then-attach ordering (no lost delta). updates the snapshot /// cache and fans out under a single gate; writes the cached snapshots /// into the new channel and only THEN adds it to the subscriber set — all under that same gate. /// Because both operations are serialized by the gate, an racing a /// is resolved one of two ways, both correct: it runs entirely before the /// subscribe (its value is in the snapshot, and it is not delivered live because the channel is /// not yet attached), or entirely after (it is delivered live, strictly after the snapshot). No /// item is dropped or duplicated across the boundary. Fan-out is a non-blocking /// into bounded/DropOldest channels, so holding the gate /// never blocks on a slow consumer. /// /// public sealed class TelemetryLocalHub : ITelemetryLocalHub { /// /// How long an eviction tombstone (see ) is kept. It only has to outlive the /// removed child's last late write — an in-flight capability call that completes after /// StopChild, bounded by the longest capability timeout (seconds) plus the child's own stop — /// so ten minutes is a wide margin. Without a sweep a driver removed for good kept its id forever. /// public static readonly TimeSpan EvictionTombstoneTtl = TimeSpan.FromMinutes(10); private readonly object _gate = new(); private readonly TimeProvider _time; /// Initializes a hub on the system clock. public TelemetryLocalHub() : this(TimeProvider.System) { } /// Initializes a hub on — the tombstone-sweep test seam. /// The clock the eviction tombstones are stamped and swept by. public TelemetryLocalHub(TimeProvider time) { ArgumentNullException.ThrowIfNull(time); _time = time; } // Plain Dictionary, NOT ConcurrentDictionary: every access is already under _gate, and // ConcurrentDictionary.Values would materialize a fresh List (and take its own internal lock) on // every Emit — the hot node-actor-thread path. Under the gate a plain Dictionary is safe and // allocation-free to iterate. private readonly Dictionary> _subscribers = new(); // Snapshot last-value caches. Only ever mutated under _gate (kept ConcurrentDictionary so a // Subscribe reading them under the gate is trivially safe even against any future lock-free read). // Entries are evicted via Evict when DriverHostActor removes the instance, so a // decommissioned driver's last-known state does not replay to new subscribers. private readonly ConcurrentDictionary _healthCache = new(); private readonly ConcurrentDictionary<(string InstanceId, string HostName), TelemetryItem.Resilience> _resilienceCache = new(); // Instances evicted and not since readmitted, stamped with the eviction time. Their late Health/Resilience // items are dropped, so a removed driver's still-stopping child cannot re-prime the cache it was just // evicted from. Swept past EvictionTombstoneTtl on the next Evict (never on the Emit hot path). Under _gate. private readonly Dictionary _evicted = new(StringComparer.Ordinal); /// public void Emit(TelemetryItem item) { ArgumentNullException.ThrowIfNull(item); lock (_gate) { switch (item) { case TelemetryItem.Health h when _evicted.ContainsKey(h.E.DriverInstanceId): return; case TelemetryItem.Resilience r when _evicted.ContainsKey(r.E.DriverInstanceId): return; case TelemetryItem.Health h: _healthCache[h.E.DriverInstanceId] = h; break; case TelemetryItem.Resilience r: _resilienceCache[(r.E.DriverInstanceId, r.E.HostName)] = r; break; // Alarm / Script are append-style — never cached. } foreach (var channel in _subscribers.Values) channel.Writer.TryWrite(item); // bounded + DropOldest ⇒ non-blocking, sheds oldest when full } } /// public void Evict(string driverInstanceId) { ArgumentNullException.ThrowIfNull(driverInstanceId); lock (_gate) { var now = _time.GetUtcNow(); SweepExpiredTombstones(now); _evicted[driverInstanceId] = now; _healthCache.TryRemove(driverInstanceId, out _); foreach (var key in _resilienceCache.Keys) if (string.Equals(key.InstanceId, driverInstanceId, StringComparison.Ordinal)) _resilienceCache.TryRemove(key, out _); } } /// public void Readmit(string driverInstanceId) { ArgumentNullException.ThrowIfNull(driverInstanceId); lock (_gate) _evicted.Remove(driverInstanceId); } /// Drops tombstones older than . Called under /// from only — the set is bounded by distinct removed ids, so a linear pass per /// eviction is cheap, and Emit never pays for it. private void SweepExpiredTombstones(DateTimeOffset now) { if (_evicted.Count == 0) return; List? expired = null; foreach (var (id, evictedAt) in _evicted) { if (now - evictedAt >= EvictionTombstoneTtl) (expired ??= new List()).Add(id); } if (expired is null) return; foreach (var id in expired) _evicted.Remove(id); } /// public ITelemetrySubscription Subscribe(int boundedCapacity) { if (boundedCapacity < 1) throw new ArgumentOutOfRangeException(nameof(boundedCapacity), boundedCapacity, "Capacity must be positive."); var channel = Channel.CreateBounded(new BoundedChannelOptions(boundedCapacity) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false, }); var id = Guid.NewGuid(); lock (_gate) { // Snapshot FIRST (Health then Resilience), attach SECOND — both under the gate so a // concurrent Emit cannot slip a delta between the snapshot read and the attach. foreach (var health in _healthCache.Values) channel.Writer.TryWrite(health); foreach (var resilience in _resilienceCache.Values) channel.Writer.TryWrite(resilience); _subscribers[id] = channel; } return new Subscription(this, id, channel); } private void Detach(Guid id) { lock (_gate) { if (_subscribers.Remove(id, out var channel)) channel.Writer.TryComplete(); } } private sealed class Subscription : ITelemetrySubscription { private readonly TelemetryLocalHub _hub; private readonly Guid _id; private int _disposed; /// Creates a subscription over a hub channel. /// The owning hub, notified on dispose. /// The subscription id. /// The channel whose reader is exposed. public Subscription(TelemetryLocalHub hub, Guid id, Channel channel) { _hub = hub; _id = id; Reader = channel.Reader; } /// public ChannelReader Reader { get; } /// Detaches this subscription from the hub; safe to call more than once. public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; // idempotent _hub.Detach(_id); } } }