perf(dashboard): idle-gate the snapshot tick; cache static config; bound key-list refresh

The snapshot publisher broadcast to Clients.All on every ~1s tick forever, with
zero viewers. Each tick cost a session-registry snapshot and sort, a metrics
snapshot that copies dictionaries under the global metrics lock, a full rebuild
of EffectiveGatewayConfiguration, and a SQLite read of the API key table.

DashboardSnapshotHub now counts live connections into the singleton
DashboardSnapshotHubConnectionCounter (clamped at zero, since SignalR can call
OnDisconnectedAsync for a connection whose OnConnectedAsync faulted). The
publisher drives the snapshot enumerator by hand instead of await foreach: with
no connections it does not call MoveNextAsync at all, so the producing iterator
stays suspended at its yield and no snapshot is built — the gate removes the
build, not just the broadcast. It re-checks once a second, so the first viewer
resumes the tick within about one interval; that viewer is seeded immediately by
DashboardPageBase's synchronous GetSnapshot() and by the hub's OnConnectedAsync.

Two per-tick costs are bounded independently of the gate: the effective
configuration is startup-static (options are bound once at boot and never
reloaded), so it is built once and cached; and the API key summaries refresh at
most every 15s, since the list only changes when an operator creates, rotates,
or revokes a key. Only a successful refresh restarts the interval, so a failed
or timed-out read is still retried on the next tick with the previous summaries
left on screen.
This commit is contained in:
Joseph Doherty
2026-08-15 12:21:49 -04:00
parent e04b1c9199
commit 77c5731b7b
7 changed files with 338 additions and 30 deletions
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSnapshotPublisherTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan IdlePollInterval = TimeSpan.FromMilliseconds(10);
/// <summary>
/// A transient failure inside
@@ -28,8 +29,10 @@ public sealed class DashboardSnapshotPublisherTests
DashboardSnapshotPublisher publisher = new(
snapshotService,
hubContext,
ConnectedCounter(),
NullLogger<DashboardSnapshotPublisher>.Instance,
reconnectDelay);
reconnectDelay,
IdlePollInterval);
using CancellationTokenSource cts = new();
Task execute = publisher.StartAsync(cts.Token);
@@ -73,8 +76,10 @@ public sealed class DashboardSnapshotPublisherTests
DashboardSnapshotPublisher publisher = new(
snapshotService,
hubContext,
ConnectedCounter(),
NullLogger<DashboardSnapshotPublisher>.Instance,
reconnectDelay);
reconnectDelay,
IdlePollInterval);
using CancellationTokenSource cts = new();
Task execute = publisher.StartAsync(cts.Token);
@@ -88,6 +93,54 @@ public sealed class DashboardSnapshotPublisherTests
Assert.True(snapshotService.SubscribeCount >= 2);
}
/// <summary>
/// With no dashboard connected there is nobody to broadcast to, so the publisher must
/// not advance the snapshot enumerator at all — every pull costs a registry snapshot and
/// sort, a locked metrics dictionary copy, and periodically a SQLite key-table read.
/// The first viewer to connect resumes the tick.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_WhenNoHubConnections_DoesNotPullSnapshots()
{
CountingSnapshotService snapshotService = new();
RecordingHubContext hubContext = new();
DashboardSnapshotHubConnectionCounter connectionCounter = new();
DashboardSnapshotPublisher publisher = new(
snapshotService,
hubContext,
connectionCounter,
NullLogger<DashboardSnapshotPublisher>.Instance,
TimeSpan.FromMilliseconds(50),
IdlePollInterval);
using CancellationTokenSource cts = new();
await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout);
// Long enough for many idle polls at IdlePollInterval.
await Task.Delay(TimeSpan.FromMilliseconds(250));
Assert.Equal(0, snapshotService.PullCount);
Assert.Equal(0, hubContext.SendCount);
connectionCounter.Increment();
await WaitUntilAsync(() => hubContext.SendCount >= 1);
await cts.CancelAsync();
await publisher.StopAsync(CancellationToken.None);
Assert.True(snapshotService.PullCount >= 1);
}
/// <summary>Creates a connection counter that already has one live viewer.</summary>
/// <returns>A counter reporting a single connection.</returns>
private static DashboardSnapshotHubConnectionCounter ConnectedCounter()
{
DashboardSnapshotHubConnectionCounter counter = new();
counter.Increment();
return counter;
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
@@ -174,6 +227,34 @@ public sealed class DashboardSnapshotPublisherTests
}
}
private sealed class CountingSnapshotService : IDashboardSnapshotService
{
private int _pullCount;
/// <summary>Gets the number of snapshots the publisher pulled from this source.</summary>
public int PullCount => Volatile.Read(ref _pullCount);
/// <inheritdoc />
public DashboardSnapshot GetSnapshot()
{
return null!;
}
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
// Short cadence so a listening publisher pulls quickly; the counter only
// moves when the publisher actually advances the enumerator.
await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationToken).ConfigureAwait(false);
Interlocked.Increment(ref _pullCount);
yield return GetSnapshot();
}
}
}
private sealed class RecordingHubContext : IHubContext<DashboardSnapshotHub>
{
private readonly RecordingHubClients _clients = new();