fix(mesh-phase5): telemetry hub — plain Dictionary under gate, concurrency stress test, priming/eviction notes

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-23 15:30:24 -04:00
parent 8e5090edf3
commit 7bdb6d00ec
3 changed files with 95 additions and 4 deletions
@@ -28,10 +28,18 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
public sealed class TelemetryLocalHub : ITelemetryLocalHub
{
private readonly object _gate = new();
private readonly ConcurrentDictionary<Guid, Channel<TelemetryItem>> _subscribers = new();
// 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<Guid, Channel<TelemetryItem>> _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).
// TODO(mesh-phase5+): evict snapshot cache entries on driver-instance removal (needs a
// driver-lifecycle hook); pre-production, accepted for now. Until then a decommissioned driver
// instance's last-known Health/Resilience state replays to new subscribers forever.
private readonly ConcurrentDictionary<string, TelemetryItem.Health> _healthCache = new();
private readonly ConcurrentDictionary<(string InstanceId, string HostName), TelemetryItem.Resilience> _resilienceCache = new();
@@ -91,7 +99,7 @@ public sealed class TelemetryLocalHub : ITelemetryLocalHub
{
lock (_gate)
{
if (_subscribers.TryRemove(id, out var channel))
if (_subscribers.Remove(id, out var channel))
channel.Writer.TryComplete();
}
}