perf(dashboard): gate event mirror on live viewers — no clone, no send for unwatched sessions

DashboardEventBroadcaster.Publish ran a deep protobuf Clone (redaction is on by
default) and a group SendAsync for every event of every session, before anything
checked whether a dashboard client was actually watching. In the steady state the
session:{id} group is empty, so that work was thrown away per event.

SignalR does not expose group membership, so EventsHub now mirrors its own
add/remove into a singleton EventsHubViewerRegistry, and OnDisconnectedAsync
releases everything a dropped connection held (SessionDetailsPage disposes the
connection rather than unsubscribing). Publish returns early when the session has
no viewers, before the redaction clone. Watched sessions behave exactly as before.

Lazy mirror-lease start/stop was deliberately not attempted — it entangles the
dashboard with SessionEventDistributor subscribe lifetime for no saving beyond
this gate; recorded in docs/GatewayDashboardDesign.md.
This commit is contained in:
Joseph Doherty
2026-08-15 12:07:33 -04:00
parent da8463534b
commit 6c5218913b
7 changed files with 465 additions and 10 deletions
@@ -21,8 +21,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// the mirror independently of the still-outstanding per-session hub ACL
/// (see <see cref="EventsHub"/>).
/// </remarks>
/// <param name="hubContext">Hub context used to send to the session's group.</param>
/// <param name="viewerRegistry">
/// Live-subscriber registry consulted before any per-event work is done.
/// </param>
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
/// <param name="logger">Logger for best-effort mirror failures.</param>
public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext,
EventsHubViewerRegistry viewerRegistry,
IOptions<GatewayOptions> options,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{
@@ -36,6 +43,16 @@ public sealed class DashboardEventBroadcaster(
return;
}
// Every session's dashboard-mirror subscriber calls Publish for every event,
// whether or not a browser is on that session's page. Without this gate the
// steady state — no dashboard viewer at all — still paid a deep protobuf
// clone (redaction is on by default) plus a send to an empty SignalR group
// per event. Bail before both.
if (!viewerRegistry.HasViewers(sessionId))
{
return;
}
MxEvent outbound = _showTagValues ? mxEvent : RedactValues(mxEvent);
// Wrap the Task acquisition in a try/catch so a hypothetical synchronous throw
@@ -9,8 +9,14 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// session; <see cref="DashboardEventBroadcaster"/> sends messages to
/// <c>session:{id}</c> as events arrive from the live gRPC stream.
/// </summary>
/// <remarks>
/// Group membership is mirrored into <see cref="EventsHubViewerRegistry"/>
/// because SignalR does not expose it, and the broadcaster consults the
/// registry to skip all mirror work for sessions nobody is watching.
/// </remarks>
/// <param name="viewerRegistry">Registry tracking which sessions have live subscribers.</param>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class EventsHub : Hub
public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
{
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
public const string EventMessage = "MxEvent";
@@ -55,19 +61,43 @@ public sealed class EventsHub : Hub
return Task.CompletedTask;
}
// Register before joining the group: the reverse order would leave a window
// in which this connection is a group member but the broadcaster's gate still
// reports the session unwatched, silently dropping events it should receive.
viewerRegistry.AddViewer(Context.ConnectionId, sessionId);
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
}
/// <summary>Unsubscribes the calling SignalR connection from the per-session events group.</summary>
/// <param name="sessionId">Session id to unsubscribe the caller from.</param>
/// <returns>A task representing the unsubscription operation.</returns>
public Task UnsubscribeSession(string sessionId)
public async Task UnsubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return Task.CompletedTask;
return;
}
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(sessionId));
// Leave the group first, deregister after — the mirror stays enabled for the
// brief overlap rather than dropping events still owed to other subscribers.
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(sessionId)).ConfigureAwait(false);
viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId);
}
/// <summary>
/// Releases every session subscription the dropped connection held. A browser
/// tab that closes never calls <see cref="UnsubscribeSession"/>, so without
/// this the session would look watched forever and the mirror would keep
/// cloning and sending events to an empty group.
/// </summary>
/// <param name="exception">The exception that terminated the connection, if any.</param>
/// <returns>A task representing the disconnect handling.</returns>
public override Task OnDisconnectedAsync(Exception? exception)
{
viewerRegistry.ReleaseConnection(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,147 @@
using System.Collections.Concurrent;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <summary>
/// Tracks which sessions currently have at least one live <see cref="EventsHub"/>
/// subscriber, so <see cref="DashboardEventBroadcaster"/> can skip the redaction
/// clone and the group send for sessions nobody is watching.
/// </summary>
/// <remarks>
/// SignalR does not expose group membership, so the hub mirrors its own
/// <c>AddToGroup</c>/<c>RemoveFromGroup</c> calls here. In the steady state no
/// browser is on a session-details page, yet every session's dashboard-mirror
/// subscriber still called <c>Publish</c> 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.
/// <para>
/// Per-connection subscriptions are tracked as well, because a browser tab that
/// simply goes away never calls <c>UnsubscribeSession</c>; the hub's
/// <c>OnDisconnectedAsync</c> releases everything the connection held.
/// </para>
/// </remarks>
public sealed class EventsHubViewerRegistry
{
private readonly ConcurrentDictionary<string, int> _viewersBySession = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _sessionsByConnection =
new(StringComparer.Ordinal);
/// <summary>
/// Records that <paramref name="connectionId"/> is watching
/// <paramref name="sessionId"/>. Repeat calls for the same pair are
/// idempotent, so one <see cref="RemoveViewer"/> always clears them.
/// </summary>
/// <param name="connectionId">SignalR connection id of the subscriber.</param>
/// <param name="sessionId">Session id being watched.</param>
public void AddViewer(string connectionId, string sessionId)
{
if (string.IsNullOrWhiteSpace(connectionId) || string.IsNullOrWhiteSpace(sessionId))
{
return;
}
ConcurrentDictionary<string, byte> sessions = _sessionsByConnection.GetOrAdd(
connectionId,
static _ => new ConcurrentDictionary<string, byte>(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);
}
/// <summary>
/// Records that <paramref name="connectionId"/> stopped watching
/// <paramref name="sessionId"/>. A removal with no matching
/// <see cref="AddViewer"/> is a no-op, so the count cannot go negative.
/// </summary>
/// <param name="connectionId">SignalR connection id of the subscriber.</param>
/// <param name="sessionId">Session id no longer being watched.</param>
public void RemoveViewer(string connectionId, string sessionId)
{
if (string.IsNullOrWhiteSpace(connectionId) || string.IsNullOrWhiteSpace(sessionId))
{
return;
}
if (!_sessionsByConnection.TryGetValue(connectionId, out ConcurrentDictionary<string, byte>? sessions)
|| !sessions.TryRemove(sessionId, out _))
{
return;
}
ReleaseSession(sessionId);
}
/// <summary>
/// Releases every subscription held by <paramref name="connectionId"/>.
/// Called from the hub's disconnect callback, which is the only reliable
/// signal for a browser tab that closed without unsubscribing.
/// </summary>
/// <param name="connectionId">SignalR connection id that dropped.</param>
public void ReleaseConnection(string connectionId)
{
if (string.IsNullOrWhiteSpace(connectionId))
{
return;
}
if (!_sessionsByConnection.TryRemove(connectionId, out ConcurrentDictionary<string, byte>? 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);
}
}
}
/// <summary>Gets a value indicating whether any hub connection is watching the session.</summary>
/// <param name="sessionId">Session id to test.</param>
/// <returns><see langword="true"/> when at least one connection is subscribed.</returns>
public bool HasViewers(string sessionId) =>
!string.IsNullOrEmpty(sessionId)
&& _viewersBySession.TryGetValue(sessionId, out int count)
&& count > 0;
/// <summary>
/// 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 <see cref="AddViewer"/> on the same session.
/// </summary>
/// <param name="sessionId">Session id whose count is released.</param>
private void ReleaseSession(string sessionId)
{
while (true)
{
if (!_viewersBySession.TryGetValue(sessionId, out int count))
{
return;
}
if (count <= 1)
{
if (_viewersBySession.TryRemove(new KeyValuePair<string, int>(sessionId, count)))
{
return;
}
}
else if (_viewersBySession.TryUpdate(sessionId, count - 1, count))
{
return;
}
}
}
}