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:
@@ -218,7 +218,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
{
|
||||
// The shared-library cache entry no longer carries a precomputed dashboard summary;
|
||||
// DashboardSnapshotService derives templates/categories from the entry's objects via
|
||||
// DashboardGalaxyProjector. Seed objects that yield $Pump x2 / $Area x1 templates and
|
||||
// DashboardGalaxySummaryProjector. Seed objects that yield $Pump x2 / $Area x1 templates and
|
||||
// categories UserDefined(10) x2 / Area(13) x1, matching the asserted summary.
|
||||
GalaxyObject[] objects =
|
||||
[
|
||||
@@ -280,6 +280,111 @@ public sealed class DashboardSnapshotServiceTests
|
||||
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-059: the shared library replaces the cache entry via
|
||||
/// <c>previous with { ... }</c> at the <b>same</b> Sequence on a steady-state tick and on a
|
||||
/// refresh failure (Status → Unavailable, LastError set). The dashboard summary must reflect
|
||||
/// those per-tick-volatile fields, not a summary frozen at the last heavy-refresh sequence —
|
||||
/// otherwise the Galaxy health indicator keeps showing Healthy throughout a SQL outage.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_WhenGalaxyEntryChangesAtSameSequence_ReflectsVolatileStatusAndError()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry healthy = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 5,
|
||||
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:00:00Z", CultureInfo.InvariantCulture),
|
||||
};
|
||||
MutableGalaxyHierarchyCache cache = new(healthy);
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache);
|
||||
|
||||
Assert.Equal(DashboardGalaxyStatus.Healthy, service.GetSnapshot().Galaxy.Status);
|
||||
|
||||
// SQL outage: no heavy refresh can succeed, so Sequence cannot advance; the library
|
||||
// mutates the entry in place to Unavailable with an error at the same Sequence.
|
||||
cache.Current = healthy with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Unavailable,
|
||||
LastError = "SQL unreachable",
|
||||
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:00:05Z", CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
DashboardGalaxySummary summary = service.GetSnapshot().Galaxy;
|
||||
Assert.Equal(DashboardGalaxyStatus.Unavailable, summary.Status);
|
||||
Assert.Equal("SQL unreachable", summary.LastError);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-04-28T11:00:05Z", CultureInfo.InvariantCulture),
|
||||
summary.LastQueriedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the O(N) template/category breakdown is memoized on the cache Sequence: in
|
||||
/// production the object set only changes when the library bumps Sequence on a heavy refresh,
|
||||
/// so the breakdown is reused at an unchanged Sequence. Proves the memo keys on Sequence by
|
||||
/// swapping Objects at the same Sequence and asserting the breakdown does not recompute.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_WhenSequenceUnchanged_ReusesExpensiveTemplateBreakdown()
|
||||
{
|
||||
GalaxyObject[] first =
|
||||
[new GalaxyObject { GobjectId = 1, BrowseName = "Pump01", CategoryId = 10, TemplateChain = { "$Pump" } }];
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 9,
|
||||
Objects = first,
|
||||
Index = GalaxyHierarchyIndex.Build(first),
|
||||
ObjectCount = 1,
|
||||
};
|
||||
MutableGalaxyHierarchyCache cache = new(entry);
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache);
|
||||
|
||||
Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);
|
||||
|
||||
GalaxyObject[] second =
|
||||
[new GalaxyObject { GobjectId = 2, BrowseName = "Valve01", CategoryId = 10, TemplateChain = { "$Valve" } }];
|
||||
cache.Current = entry with { Objects = second, Index = GalaxyHierarchyIndex.Build(second) };
|
||||
|
||||
// Same Sequence (9) → breakdown reused → still "$Pump", not "$Valve".
|
||||
Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a changed cache Sequence invalidates the memoized template breakdown and the
|
||||
/// next snapshot reflects the new object set. Guards against an inverted sequence check that
|
||||
/// would freeze the Galaxy section after its first load (Tests-041).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_WhenSequenceChanges_RecomputesTemplateBreakdown()
|
||||
{
|
||||
GalaxyObject[] first =
|
||||
[new GalaxyObject { GobjectId = 1, BrowseName = "Pump01", CategoryId = 10, TemplateChain = { "$Pump" } }];
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 9,
|
||||
Objects = first,
|
||||
Index = GalaxyHierarchyIndex.Build(first),
|
||||
ObjectCount = 1,
|
||||
};
|
||||
MutableGalaxyHierarchyCache cache = new(entry);
|
||||
using GatewayMetrics metrics = new();
|
||||
DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache);
|
||||
|
||||
Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);
|
||||
|
||||
GalaxyObject[] second =
|
||||
[new GalaxyObject { GobjectId = 2, BrowseName = "Valve01", CategoryId = 10, TemplateChain = { "$Valve" } }];
|
||||
cache.Current = entry with { Sequence = 10, Objects = second, Index = GalaxyHierarchyIndex.Build(second) };
|
||||
|
||||
// New Sequence (10) → breakdown recomputed → now "$Valve".
|
||||
Assert.Equal("$Valve", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies snapshot watcher cancels cleanly when subscriber cancels.
|
||||
/// </summary>
|
||||
@@ -452,6 +557,22 @@ public sealed class DashboardSnapshotServiceTests
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class MutableGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <summary>Gets or sets the current Galaxy hierarchy cache entry, swappable between snapshots.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; set; } = current;
|
||||
|
||||
/// <summary>Refreshes the cache asynchronously.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Waits for the first cache load asynchronously.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private class FakeApiKeyAdminStore : IApiKeyAdminStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
Reference in New Issue
Block a user