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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
@@ -457,6 +458,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
FakeTimeProvider timeProvider = new(DateTimeOffset.Parse("2026-08-15T00:00:00Z", CultureInfo.InvariantCulture));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
@@ -464,11 +466,12 @@ public sealed class DashboardSnapshotServiceTests
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
SnapshotIntervalMilliseconds = 1,
|
||||
SnapshotIntervalMilliseconds = 1000,
|
||||
},
|
||||
},
|
||||
apiKeyAdminStore: apiKeyAdminStore);
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
|
||||
apiKeyAdminStore: apiKeyAdminStore,
|
||||
timeProvider: timeProvider);
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(30));
|
||||
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||
.WatchSnapshotsAsync(cancellation.Token)
|
||||
.GetAsyncEnumerator(cancellation.Token);
|
||||
@@ -477,14 +480,86 @@ public sealed class DashboardSnapshotServiceTests
|
||||
DashboardSnapshot first = enumerator.Current;
|
||||
apiKeyAdminStore.FailNext = true;
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
DashboardSnapshot second = enumerator.Current;
|
||||
// Advance past the key-summary refresh interval so the second tick really
|
||||
// does attempt a refresh — that attempt is the one that fails.
|
||||
DashboardSnapshot second = await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(20));
|
||||
|
||||
Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
|
||||
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
|
||||
Assert.Equal(2, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The API key list is a SQLite read; at the default 1s snapshot cadence it would run
|
||||
/// ~86k times a day against a table that changes by hand. Ticks inside the refresh
|
||||
/// interval must reuse the cached summaries and not touch the store.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_WhenTicksFallInsideRefreshInterval_ListsApiKeysOnce()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
CountingApiKeyAdminStore apiKeyAdminStore = new(
|
||||
new ApiKeyListItem(
|
||||
KeyId: "operator01",
|
||||
KeyPrefix: "mxgw",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||
ConstraintsJson: null,
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
FakeTimeProvider timeProvider = new(DateTimeOffset.Parse("2026-08-15T00:00:00Z", CultureInfo.InvariantCulture));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
SnapshotIntervalMilliseconds = 1000,
|
||||
},
|
||||
},
|
||||
apiKeyAdminStore: apiKeyAdminStore,
|
||||
timeProvider: timeProvider);
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(30));
|
||||
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||
.WatchSnapshotsAsync(cancellation.Token)
|
||||
.GetAsyncEnumerator(cancellation.Token);
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
Assert.Equal(1, apiKeyAdminStore.ListCount);
|
||||
|
||||
// Two more 1s ticks, both well inside the 15s refresh interval.
|
||||
DashboardSnapshot second = await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(1));
|
||||
await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.Equal(1, apiKeyAdminStore.ListCount);
|
||||
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
|
||||
|
||||
// A tick past the interval refreshes again, so an added or revoked key still
|
||||
// reaches the dashboard within the interval.
|
||||
await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(20));
|
||||
|
||||
Assert.Equal(2, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The effective configuration is startup-static, so the snapshot must hand out the
|
||||
/// same instance instead of rebuilding the whole option tree on every tick.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_ReusesTheSameEffectiveConfigurationInstance()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics);
|
||||
|
||||
DashboardSnapshot first = service.GetSnapshot();
|
||||
DashboardSnapshot second = service.GetSnapshot();
|
||||
|
||||
Assert.Same(first.Configuration, second.Configuration);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that snapshot service disposes cleanly when subscriber cancels.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -513,12 +588,34 @@ public sealed class DashboardSnapshotServiceTests
|
||||
Assert.False(hasNext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the fake clock past the next snapshot tick and returns the snapshot it
|
||||
/// produces. <c>MoveNextAsync</c> is started before the advance because the iterator
|
||||
/// creates its <see cref="PeriodicTimer"/> synchronously on that call — the timer must
|
||||
/// exist before the clock moves or the tick is missed.
|
||||
/// </summary>
|
||||
/// <param name="enumerator">The snapshot enumerator being driven.</param>
|
||||
/// <param name="timeProvider">The fake clock backing the snapshot timer.</param>
|
||||
/// <param name="advance">How far to advance the clock.</param>
|
||||
/// <returns>The snapshot produced by the tick.</returns>
|
||||
private static async Task<DashboardSnapshot> NextSnapshotAsync(
|
||||
IAsyncEnumerator<DashboardSnapshot> enumerator,
|
||||
FakeTimeProvider timeProvider,
|
||||
TimeSpan advance)
|
||||
{
|
||||
ValueTask<bool> pending = enumerator.MoveNextAsync();
|
||||
timeProvider.Advance(advance);
|
||||
Assert.True(await pending.AsTask().WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
return enumerator.Current;
|
||||
}
|
||||
|
||||
private static DashboardSnapshotService CreateService(
|
||||
SessionRegistry registry,
|
||||
GatewayMetrics metrics,
|
||||
GatewayOptions? options = null,
|
||||
IGalaxyHierarchyCache? galaxyHierarchyCache = null,
|
||||
IApiKeyAdminStore? apiKeyAdminStore = null)
|
||||
IApiKeyAdminStore? apiKeyAdminStore = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
GatewayOptions resolvedOptions = options ?? new GatewayOptions
|
||||
{
|
||||
@@ -535,7 +632,8 @@ public sealed class DashboardSnapshotServiceTests
|
||||
configurationProvider,
|
||||
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
|
||||
apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
|
||||
Options.Create(resolvedOptions));
|
||||
Options.Create(resolvedOptions),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
|
||||
Reference in New Issue
Block a user