fix(dashboard): copy Galaxy summary volatile fields fresh; memoize only O(N) breakdown

Resolves Server-059, Tests-041, Server-060 (2026-06-25 re-review).

DashboardSnapshotService memoized the whole Galaxy summary keyed on the cache
Sequence, but the shared library bumps Sequence only on a heavy refresh: the
steady-state tick, the refresh-failure path, and the age-based status getter all
replace the entry via 'previous with { ... }' at the SAME Sequence. The dashboard
therefore froze LastQueriedAt and, during a Galaxy SQL outage, kept showing
Healthy with no error for the whole outage.

Split DashboardGalaxySummaryProjector into ComputeBreakdown (the O(N) template/
category work, the only sequence-bound part) and BuildSummary (cheap volatile
fields copied fresh). ResolveGalaxySummary now memoizes only the breakdown by
Sequence and rebuilds the summary from the current entry each tick. Removed the
redundant DashboardGalaxyProjector wrapper.

Tests: same-sequence status/error/timestamp now reflected (the regression);
memoization-hit and sequence-invalidation guards; GatewayApplicationTests asserts
the DI container resolves IGalaxyBrowseScopeProvider to GatewayBrowseScopeProvider
(pins the registration-order invariant over the library's no-op default).
This commit is contained in:
Joseph Doherty
2026-06-25 13:42:39 -04:00
parent b062cc0440
commit 9ae6bce0c8
6 changed files with 246 additions and 68 deletions
@@ -30,12 +30,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>();
// Memoizes the projected Galaxy summary against the immutable cache sequence. The shared
// library bumps Sequence only on a heavy refresh, so an unchanged sequence means the entry
// (and therefore its summary) is unchanged and the O(N) projection can be reused. This keeps
// the ~1s snapshot tick O(1) for Galaxy, restoring the pre-adoption behavior where the
// summary was computed once per refresh rather than once per tick.
private GalaxySummaryCache? _galaxySummaryCache;
// 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
// O(1) for Galaxy. The summary's cheap volatile fields (status, timestamps, last error, counts)
// are NOT memoized: the library mutates them in place at the same sequence on steady-state ticks
// and on refresh failure, so they are copied fresh on every snapshot (see Server-059).
private GalaxyBreakdownCache? _galaxyBreakdownCache;
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
@@ -111,21 +112,26 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
GalaxyHierarchyCacheEntry entry = _galaxyHierarchyCache.Current;
long sequence = entry.Sequence;
// Lock-free reuse: a matching sequence means the entry is unchanged, so the previously
// projected summary is still correct. A racing recompute for a new sequence is harmless —
// the projection is pure, so any winner stores identical content for that sequence.
GalaxySummaryCache? cached = Volatile.Read(ref _galaxySummaryCache);
// Lock-free reuse of the O(N) breakdown only: a matching sequence means the object set is
// unchanged, so the previously computed breakdown is still correct. A racing recompute for a
// new sequence is harmless — the computation is pure, so any winner stores identical content
// for that sequence. The cheap volatile fields are always taken from the current entry below.
GalaxyBreakdownCache? cached = Volatile.Read(ref _galaxyBreakdownCache);
GalaxyObjectBreakdown breakdown;
if (cached is not null && cached.Sequence == sequence)
{
return cached.Summary;
breakdown = cached.Breakdown;
}
else
{
breakdown = DashboardGalaxySummaryProjector.ComputeBreakdown(entry);
Volatile.Write(ref _galaxyBreakdownCache, new GalaxyBreakdownCache(sequence, breakdown));
}
DashboardGalaxySummary summary = DashboardGalaxyProjector.Project(entry);
Volatile.Write(ref _galaxySummaryCache, new GalaxySummaryCache(sequence, summary));
return summary;
return DashboardGalaxySummaryProjector.BuildSummary(entry, breakdown);
}
private sealed record GalaxySummaryCache(long Sequence, DashboardGalaxySummary Summary);
private sealed record GalaxyBreakdownCache(long Sequence, GalaxyObjectBreakdown Breakdown);
/// <summary>
/// Watches dashboard snapshots at regular intervals asynchronously.