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;
}
}
}
}