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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user