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
@@ -0,0 +1,165 @@
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Verifies <see cref="EventsHubViewerRegistry"/>, the seam
/// <see cref="DashboardEventBroadcaster"/> consults before cloning and sending
/// an event: a session is "watched" only while at least one hub connection
/// holds a subscription to it, and a dropped connection releases every
/// subscription it held.
/// </summary>
public sealed class EventsHubViewerRegistryTests
{
/// <summary>A session with no subscriber is not watched.</summary>
[Fact]
public void HasViewers_WithNoSubscribers_IsFalse()
{
EventsHubViewerRegistry registry = new();
Assert.False(registry.HasViewers("session-1"));
}
/// <summary>Adding then removing the only viewer flips the session back to unwatched.</summary>
[Fact]
public void AddViewer_ThenRemoveViewer_TogglesWatchedState()
{
EventsHubViewerRegistry registry = new();
registry.AddViewer("conn-1", "session-1");
Assert.True(registry.HasViewers("session-1"));
registry.RemoveViewer("conn-1", "session-1");
Assert.False(registry.HasViewers("session-1"));
}
/// <summary>Each connection counts once: the session stays watched until the last one leaves.</summary>
[Fact]
public void RemoveViewer_WithOtherConnectionsStillSubscribed_KeepsSessionWatched()
{
EventsHubViewerRegistry registry = new();
registry.AddViewer("conn-1", "session-1");
registry.AddViewer("conn-2", "session-1");
registry.RemoveViewer("conn-1", "session-1");
Assert.True(registry.HasViewers("session-1"));
registry.RemoveViewer("conn-2", "session-1");
Assert.False(registry.HasViewers("session-1"));
}
/// <summary>A repeated subscribe from the same connection is idempotent, so one unsubscribe clears it.</summary>
[Fact]
public void AddViewer_CalledTwiceForSameConnection_CountsOnce()
{
EventsHubViewerRegistry registry = new();
registry.AddViewer("conn-1", "session-1");
registry.AddViewer("conn-1", "session-1");
registry.RemoveViewer("conn-1", "session-1");
Assert.False(registry.HasViewers("session-1"));
}
/// <summary>Viewer counts are tracked per session; unrelated sessions stay unwatched.</summary>
[Fact]
public void AddViewer_TracksSessionsIndependently()
{
EventsHubViewerRegistry registry = new();
registry.AddViewer("conn-1", "session-1");
Assert.True(registry.HasViewers("session-1"));
Assert.False(registry.HasViewers("session-2"));
}
/// <summary>A dropped connection releases every session it held.</summary>
[Fact]
public void ReleaseConnection_ReleasesEverySessionTheConnectionHeld()
{
EventsHubViewerRegistry registry = new();
registry.AddViewer("conn-1", "session-1");
registry.AddViewer("conn-1", "session-2");
registry.AddViewer("conn-2", "session-2");
registry.ReleaseConnection("conn-1");
Assert.False(registry.HasViewers("session-1"));
// conn-2 still watches session-2.
Assert.True(registry.HasViewers("session-2"));
}
/// <summary>Releasing a connection twice does not double-decrement another connection's subscription.</summary>
[Fact]
public void ReleaseConnection_CalledTwice_DoesNotDropOtherViewers()
{
EventsHubViewerRegistry registry = new();
registry.AddViewer("conn-1", "session-1");
registry.AddViewer("conn-2", "session-1");
registry.ReleaseConnection("conn-1");
registry.ReleaseConnection("conn-1");
Assert.True(registry.HasViewers("session-1"));
}
/// <summary>Unmatched removals cannot drive the count negative and strand a session as unwatched.</summary>
[Fact]
public void RemoveViewer_WithoutMatchingAdd_LeavesCountAtZero()
{
EventsHubViewerRegistry registry = new();
registry.RemoveViewer("conn-1", "session-1");
registry.RemoveViewer("conn-1", "session-1");
registry.ReleaseConnection("conn-1");
Assert.False(registry.HasViewers("session-1"));
// A subsequent genuine subscribe must still register as exactly one viewer.
registry.AddViewer("conn-1", "session-1");
Assert.True(registry.HasViewers("session-1"));
registry.RemoveViewer("conn-1", "session-1");
Assert.False(registry.HasViewers("session-1"));
}
/// <summary>Blank connection or session ids are ignored rather than tracked.</summary>
[Theory]
[InlineData("", "session-1")]
[InlineData(" ", "session-1")]
[InlineData("conn-1", "")]
[InlineData("conn-1", " ")]
public void AddViewer_WithBlankIdentifiers_IsIgnored(string connectionId, string sessionId)
{
EventsHubViewerRegistry registry = new();
registry.AddViewer(connectionId, sessionId);
Assert.False(registry.HasViewers(sessionId));
Assert.False(registry.HasViewers("session-1"));
}
/// <summary>Concurrent add/remove pairs settle at zero viewers, never at a stuck-on count.</summary>
[Fact]
public void AddAndRemoveViewer_UnderConcurrency_SettlesAtZero()
{
EventsHubViewerRegistry registry = new();
Parallel.For(0, 64, i =>
{
string connectionId = $"conn-{i}";
for (int pass = 0; pass < 50; pass++)
{
registry.AddViewer(connectionId, "session-1");
registry.RemoveViewer(connectionId, "session-1");
}
});
Assert.False(registry.HasViewers("session-1"));
}
}