diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index d2327fa..0beb73a 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -394,18 +394,6 @@ its lease expires. One session means one worker process backs every dashboard circuit; all access is serialised so the worker sees one in-flight command at a time. Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`. -The advise set that backs those reads is capped at 256 tags (one browse page plus -headroom) and evicted least-recently-read-first. Without the cap every tag any -viewer ever inspected stayed advised on the single dashboard worker until the -session faulted, so browsing a large galaxy accreted unbounded live MXAccess -subscriptions — and the event churn they feed — on one x86 process. Reading a tag -already in the set marks it most-recently-read; subscribing past the cap unadvises -the oldest entries with `GatewaySession.UnsubscribeBulkAsync` in one batch before -the new ones are advised. Tags read in the same call are never evicted to make -room for each other. A failed unadvise does not fail the read: the tags are -dropped from tracking anyway (they re-subscribe if read again), because the -session-invalidation path already handles gateway/worker drift. - The Alarms page does **not** use the dashboard session: alarm data comes from the gateway's always-on central monitor. `QueryAlarmsAsync` reads `IGatewayAlarmService.CurrentAlarms` — the monitor's in-process cache — so the diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index c28f859..42e014f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -51,6 +51,7 @@ public static class DashboardServiceCollectionExtensions // subscriber bookkeeping they share with the broadcaster must outlive them. services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); services.AddHttpContextAccessor(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs index 7039f47..49dc7be 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -16,6 +16,17 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService { private const string HealthyStatus = "Healthy"; + /// + /// 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. + /// + private static readonly TimeSpan ApiKeySummaryRefreshInterval = TimeSpan.FromSeconds(15); + + /// Sentinel for "the API key summaries have never been refreshed". + 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 _logger; private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1); private IReadOnlyList _apiKeySummaries = Array.Empty(); + private long _apiKeySummariesRefreshedAtTicks = NeverRefreshedTicks; + // The effective configuration is built from IOptions 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) { diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs index c93d01a..3cc8d9f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs @@ -9,8 +9,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// immediately via ; subsequent refreshes are /// broadcast by . /// +/// +/// Connections are counted into +/// so can stop building and broadcasting +/// snapshots while nobody is watching. +/// [Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] -public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotService) : Hub +public sealed class DashboardSnapshotHub( + IDashboardSnapshotService snapshotService, + DashboardSnapshotHubConnectionCounter connectionCounter) : Hub { /// Method name used to push snapshot updates to clients. public const string SnapshotMessage = "SnapshotUpdated"; @@ -18,7 +25,57 @@ public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotServi /// public override async Task OnConnectedAsync() { + // Count the viewer before seeding it, so the publisher resumes its tick + // no later than the first snapshot this connection renders. + connectionCounter.Increment(); await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false); await base.OnConnectedAsync().ConfigureAwait(false); } + + /// + public override async Task OnDisconnectedAsync(Exception? exception) + { + connectionCounter.Decrement(); + await base.OnDisconnectedAsync(exception).ConfigureAwait(false); + } +} + +/// +/// Process-wide count of live connections. +/// Registered as a singleton and read by +/// to idle-gate the snapshot tick: with no dashboard connected there is nothing +/// to broadcast to, so no snapshot is built. +/// +public sealed class DashboardSnapshotHubConnectionCounter +{ + private int _count; + + /// Gets the number of live snapshot hub connections. + public int Count => Volatile.Read(ref _count); + + /// Records a new snapshot hub connection. + /// The connection count after the increment. + public int Increment() + { + return Interlocked.Increment(ref _count); + } + + /// + /// Records a snapshot hub disconnection. The count is clamped at zero: SignalR + /// can invoke OnDisconnectedAsync for a connection whose + /// OnConnectedAsync faulted, and a negative count would idle-gate the + /// publisher while viewers are still attached. + /// + /// The connection count after the decrement. + public int Decrement() + { + int updated = Interlocked.Decrement(ref _count); + if (updated >= 0) + { + return updated; + } + + Interlocked.CompareExchange(ref _count, 0, updated); + return 0; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs index 5c740fc..85701b2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// gateway process; clients listen via the hub. /// /// +/// /// wraps the snapshot subscription in /// a reconnect loop with a configurable retry delay (5s by default, /// mirroring ). A transient failure inside @@ -16,44 +17,67 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// one-time logger-init failure or a transient SQL error from the Galaxy /// summary projection — would otherwise end the BackgroundService with no /// reconnect, taking the dashboard offline until process restart. +/// +/// +/// The loop is idle-gated on . +/// Each snapshot costs a session-registry snapshot and sort, a metrics snapshot +/// that copies dictionaries under the global metrics lock, and (periodically) a +/// SQLite read of the API key table — work with no consumer when no dashboard is +/// connected. While the count is zero the publisher does not advance the snapshot +/// enumerator at all, so the producing iterator stays suspended and builds nothing. +/// /// public sealed class DashboardSnapshotPublisher : BackgroundService { private static readonly TimeSpan DefaultReconnectDelay = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DefaultIdlePollInterval = TimeSpan.FromSeconds(1); private readonly IDashboardSnapshotService _snapshotService; private readonly IHubContext _hubContext; + private readonly DashboardSnapshotHubConnectionCounter _connectionCounter; private readonly ILogger _logger; private readonly TimeSpan _reconnectDelay; + private readonly TimeSpan _idlePollInterval; /// Initializes a new instance of the DashboardSnapshotPublisher class. /// The snapshot service to subscribe to. /// The SignalR hub context for broadcasting. + /// Live snapshot hub connection count used to idle-gate the tick. /// The logger instance. public DashboardSnapshotPublisher( IDashboardSnapshotService snapshotService, IHubContext hubContext, + DashboardSnapshotHubConnectionCounter connectionCounter, ILogger logger) - : this(snapshotService, hubContext, logger, DefaultReconnectDelay) + : this(snapshotService, hubContext, connectionCounter, logger, DefaultReconnectDelay, DefaultIdlePollInterval) { } - /// Initializes a new instance of the DashboardSnapshotPublisher class with custom reconnect delay. - /// Internal hook for testing: tests inject a very short reconnect delay so assertions don't wait full 5s. + /// Initializes a new instance of the DashboardSnapshotPublisher class with custom cadences. + /// + /// Internal hook for testing: tests inject a very short reconnect delay so assertions + /// don't wait the full 5s, and a short idle poll so the resume-from-idle path is fast. + /// /// The snapshot service to subscribe to. /// The SignalR hub context for broadcasting. + /// Live snapshot hub connection count used to idle-gate the tick. /// The logger instance. /// The delay before reconnecting after a subscription failure. + /// How often the idle publisher re-checks for a connected viewer. internal DashboardSnapshotPublisher( IDashboardSnapshotService snapshotService, IHubContext hubContext, + DashboardSnapshotHubConnectionCounter connectionCounter, ILogger logger, - TimeSpan reconnectDelay) + TimeSpan reconnectDelay, + TimeSpan idlePollInterval) { _snapshotService = snapshotService; _hubContext = hubContext; + _connectionCounter = connectionCounter; _logger = logger; _reconnectDelay = reconnectDelay; + _idlePollInterval = idlePollInterval; } /// @@ -66,15 +90,31 @@ public sealed class DashboardSnapshotPublisher : BackgroundService { try { - await foreach (DashboardSnapshot snapshot in _snapshotService + // Enumerated by hand rather than with await foreach: the snapshot is + // built inside the producer's MoveNextAsync, so not calling MoveNextAsync + // is what makes the idle gate skip the build and not just the broadcast. + await using IAsyncEnumerator snapshots = _snapshotService .WatchSnapshotsAsync(stoppingToken) - .ConfigureAwait(false)) + .GetAsyncEnumerator(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) { - if (stoppingToken.IsCancellationRequested) + if (_connectionCounter.Count == 0) + { + // Nobody is watching: leave the producer suspended and re-check + // shortly. The first viewer to connect resumes the tick, and is + // seeded directly by the hub's OnConnectedAsync meanwhile. + await Task.Delay(_idlePollInterval, stoppingToken).ConfigureAwait(false); + continue; + } + + if (!await snapshots.MoveNextAsync().ConfigureAwait(false)) { break; } + DashboardSnapshot snapshot = snapshots.Current; + try { await _hubContext.Clients diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs index 742aa3f..efa49f4 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs @@ -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); /// /// A transient failure inside @@ -28,8 +29,10 @@ public sealed class DashboardSnapshotPublisherTests DashboardSnapshotPublisher publisher = new( snapshotService, hubContext, + ConnectedCounter(), NullLogger.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.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); } + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExecuteAsync_WhenNoHubConnections_DoesNotPullSnapshots() + { + CountingSnapshotService snapshotService = new(); + RecordingHubContext hubContext = new(); + DashboardSnapshotHubConnectionCounter connectionCounter = new(); + DashboardSnapshotPublisher publisher = new( + snapshotService, + hubContext, + connectionCounter, + NullLogger.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); + } + + /// Creates a connection counter that already has one live viewer. + /// A counter reporting a single connection. + private static DashboardSnapshotHubConnectionCounter ConnectedCounter() + { + DashboardSnapshotHubConnectionCounter counter = new(); + counter.Increment(); + return counter; + } + private static async Task WaitUntilAsync(Func predicate) { using CancellationTokenSource cancellationTokenSource = new(TestTimeout); @@ -174,6 +227,34 @@ public sealed class DashboardSnapshotPublisherTests } } + private sealed class CountingSnapshotService : IDashboardSnapshotService + { + private int _pullCount; + + /// Gets the number of snapshots the publisher pulled from this source. + public int PullCount => Volatile.Read(ref _pullCount); + + /// + public DashboardSnapshot GetSnapshot() + { + return null!; + } + + /// + public async IAsyncEnumerable 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 { private readonly RecordingHubClients _clients = new(); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index 5f0e48c..09efbf6 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -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 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); } + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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([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 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); + } + + /// + /// 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. + /// + [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); + } + /// Verifies that snapshot service disposes cleanly when subscriber cancels. /// A task that represents the asynchronous operation. [Fact] @@ -513,12 +588,34 @@ public sealed class DashboardSnapshotServiceTests Assert.False(hasNext); } + /// + /// Advances the fake clock past the next snapshot tick and returns the snapshot it + /// produces. MoveNextAsync is started before the advance because the iterator + /// creates its synchronously on that call — the timer must + /// exist before the clock moves or the tick is missed. + /// + /// The snapshot enumerator being driven. + /// The fake clock backing the snapshot timer. + /// How far to advance the clock. + /// The snapshot produced by the tick. + private static async Task NextSnapshotAsync( + IAsyncEnumerator enumerator, + FakeTimeProvider timeProvider, + TimeSpan advance) + { + ValueTask 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