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
@@ -16,6 +16,17 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
{
private const string HealthyStatus = "Healthy";
/// <summary>
/// Minimum spacing between API key list reads. The list is a SQLite query whose
/// content only changes when an operator creates, rotates, or revokes a key, so
/// refreshing it on every ~1s snapshot tick buys nothing; the dashboard still sees
/// a key change within this interval.
/// </summary>
private static readonly TimeSpan ApiKeySummaryRefreshInterval = TimeSpan.FromSeconds(15);
/// <summary>Sentinel for "the API key summaries have never been refreshed".</summary>
private const long NeverRefreshedTicks = long.MinValue;
private readonly ISessionRegistry _sessionRegistry;
private readonly GatewayMetrics _metrics;
private readonly IGatewayConfigurationProvider _configurationProvider;
@@ -30,6 +41,13 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly ILogger<DashboardSnapshotService> _logger;
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
private long _apiKeySummariesRefreshedAtTicks = NeverRefreshedTicks;
// The effective configuration is built from IOptions<GatewayOptions> and is startup-static:
// the gateway binds options once at boot and never reloads them, so this projection cannot
// change for the process lifetime. Build it once instead of re-projecting the whole option
// tree on every snapshot tick. A racing first build is harmless — the projection is pure,
// so either winner stores equivalent content.
private EffectiveGatewayConfiguration? _effectiveConfiguration;
// Memoizes ONLY the O(N) template/category breakdown against the cache sequence. The shared
// library bumps Sequence only on a heavy refresh that replaces the object set, so an unchanged
// sequence means the breakdown is unchanged and can be reused — keeping the ~1s snapshot tick
@@ -100,10 +118,23 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
ApiKeys: Volatile.Read(ref _apiKeySummaries),
Configuration: _configurationProvider.GetEffectiveConfiguration(),
Configuration: ResolveEffectiveConfiguration(),
Galaxy: ResolveGalaxySummary());
}
private EffectiveGatewayConfiguration ResolveEffectiveConfiguration()
{
EffectiveGatewayConfiguration? cached = Volatile.Read(ref _effectiveConfiguration);
if (cached is not null)
{
return cached;
}
EffectiveGatewayConfiguration configuration = _configurationProvider.GetEffectiveConfiguration();
Volatile.Write(ref _effectiveConfiguration, configuration);
return configuration;
}
private DashboardGalaxySummary ResolveGalaxySummary()
{
GalaxyHierarchyCacheEntry entry = _galaxyHierarchyCache.Current;
@@ -255,6 +286,17 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
{
DateTimeOffset now = _timeProvider.GetUtcNow();
long lastRefreshedAtTicks = Interlocked.Read(ref _apiKeySummariesRefreshedAtTicks);
if (lastRefreshedAtTicks != NeverRefreshedTicks
&& now.UtcTicks - lastRefreshedAtTicks < ApiKeySummaryRefreshInterval.Ticks)
{
// Inside the refresh window: reuse the cached summaries rather than
// re-reading the API key table on this tick. Only a *successful* refresh
// moves the timestamp, so a failed read is retried on the next tick.
return;
}
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
@@ -278,6 +320,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
.ToArray();
Volatile.Write(ref _apiKeySummaries, summaries);
Interlocked.Exchange(ref _apiKeySummariesRefreshedAtTicks, now.UtcTicks);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{