using System.Collections.Concurrent; namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// /// Tracks which sessions currently have at least one live /// subscriber, so can skip the redaction /// clone and the group send for sessions nobody is watching. /// /// /// SignalR does not expose group membership, so the hub mirrors its own /// AddToGroup/RemoveFromGroup calls here. In the steady state no /// browser is on a session-details page, yet every session's dashboard-mirror /// subscriber still called Publish for every event — a deep protobuf /// clone (values are redacted by default) plus a send to an empty group, per /// event, thrown away. This registry is the cheap gate in front of that work. /// /// Per-connection subscriptions are tracked as well, because a browser tab that /// simply goes away never calls UnsubscribeSession; the hub's /// OnDisconnectedAsync releases everything the connection held. /// /// public sealed class EventsHubViewerRegistry { private readonly ConcurrentDictionary _viewersBySession = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _sessionsByConnection = new(StringComparer.Ordinal); /// /// Records that is watching /// . Repeat calls for the same pair are /// idempotent, so one always clears them. /// /// SignalR connection id of the subscriber. /// Session id being watched. public void AddViewer(string connectionId, string sessionId) { if (string.IsNullOrWhiteSpace(connectionId) || string.IsNullOrWhiteSpace(sessionId)) { return; } ConcurrentDictionary sessions = _sessionsByConnection.GetOrAdd( connectionId, static _ => new ConcurrentDictionary(StringComparer.Ordinal)); // The per-connection set is the source of truth for the count: only a // subscription that was genuinely new increments the session's viewers. if (!sessions.TryAdd(sessionId, 0)) { return; } _viewersBySession.AddOrUpdate(sessionId, 1, static (_, count) => count + 1); } /// /// Records that stopped watching /// . A removal with no matching /// is a no-op, so the count cannot go negative. /// /// SignalR connection id of the subscriber. /// Session id no longer being watched. public void RemoveViewer(string connectionId, string sessionId) { if (string.IsNullOrWhiteSpace(connectionId) || string.IsNullOrWhiteSpace(sessionId)) { return; } if (!_sessionsByConnection.TryGetValue(connectionId, out ConcurrentDictionary? sessions) || !sessions.TryRemove(sessionId, out _)) { return; } ReleaseSession(sessionId); } /// /// Releases every subscription held by . /// Called from the hub's disconnect callback, which is the only reliable /// signal for a browser tab that closed without unsubscribing. /// /// SignalR connection id that dropped. public void ReleaseConnection(string connectionId) { if (string.IsNullOrWhiteSpace(connectionId)) { return; } // Detaching the set is safe against a SubscribeSession that arrives after the disconnect // only because SignalR dispatches a connection's hub invocations sequentially by default // (MaximumParallelInvocationsPerClient = 1): OnDisconnectedAsync cannot overlap an // AddViewer for the same connection, so no late add can re-create the entry and leak a // count that nothing will ever release. Raising that option would break this. if (!_sessionsByConnection.TryRemove(connectionId, out ConcurrentDictionary? sessions)) { return; } foreach (string sessionId in sessions.Keys) { // TryRemove, not a bare enumeration: a concurrent RemoveViewer on the // same detached set must not let the session be decremented twice. if (sessions.TryRemove(sessionId, out _)) { ReleaseSession(sessionId); } } } /// Gets a value indicating whether any hub connection is watching the session. /// Session id to test. /// when at least one connection is subscribed. public bool HasViewers(string sessionId) => !string.IsNullOrEmpty(sessionId) && _viewersBySession.TryGetValue(sessionId, out int count) && count > 0; /// /// Decrements the session's viewer count, dropping the entry entirely at /// zero so the dictionary does not grow one key per session ever viewed. /// The compare-and-swap loop keeps the decrement correct against a /// concurrent on the same session. /// /// Session id whose count is released. private void ReleaseSession(string sessionId) { while (true) { if (!_viewersBySession.TryGetValue(sessionId, out int count)) { return; } if (count <= 1) { if (_viewersBySession.TryRemove(new KeyValuePair(sessionId, count))) { return; } } else if (_viewersBySession.TryUpdate(sessionId, count - 1, count)) { return; } } } }