diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs
deleted file mode 100644
index b4b3f2e..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using ZB.MOM.WW.GalaxyRepository;
-
-namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
-
-/// Projects the shared-library Galaxy cache entry into a dashboard Galaxy summary.
-internal static class DashboardGalaxyProjector
-{
- /// Projects the cache entry to a dashboard Galaxy summary.
- /// The Galaxy hierarchy cache entry.
- public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
- {
- return DashboardGalaxySummaryProjector.Project(entry);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummary.cs
index 247d028..1013ab2 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummary.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummary.cs
@@ -2,8 +2,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
///
/// Snapshot of the Galaxy Repository (ZB) browse state surfaced on the dashboard.
-/// Populated by on a background refresh cadence so
-/// the dashboard never blocks on SQL.
+/// Built by from the shared-library Galaxy
+/// hierarchy cache on each dashboard snapshot; the projector memoizes only the O(N)
+/// on the cache sequence while copying the volatile
+/// status/timestamp fields fresh, so the dashboard never blocks on SQL.
///
public sealed record DashboardGalaxySummary(
DashboardGalaxyStatus Status,
@@ -46,3 +48,18 @@ public enum DashboardGalaxyStatus
public sealed record DashboardGalaxyTemplateUsage(string TemplateName, int InstanceCount);
public sealed record DashboardGalaxyCategoryCount(int CategoryId, string CategoryName, int ObjectCount);
+
+///
+/// The O(N)-computed template-usage and category breakdown of a Galaxy hierarchy cache entry —
+/// the only part of that changes with the cache sequence
+/// and is therefore safe to memoize on it.
+///
+public sealed record GalaxyObjectBreakdown(
+ IReadOnlyList TopTemplates,
+ IReadOnlyList ObjectCategories)
+{
+ /// Gets the empty breakdown (no templates, no categories).
+ public static GalaxyObjectBreakdown Empty { get; } = new(
+ Array.Empty(),
+ Array.Empty());
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs
index 3815d58..7102198 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs
@@ -27,53 +27,82 @@ public static class DashboardGalaxySummaryProjector
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
+ return BuildSummary(entry, ComputeBreakdown(entry));
+ }
- IReadOnlyList topTemplates;
- IReadOnlyList objectCategories;
+ ///
+ /// Computes the O(N) template-usage and category breakdown from the entry's objects.
+ /// This is the only part of the summary that changes with the cache
+ /// — the shared library replaces the object
+ /// set only on a heavy refresh that bumps the sequence — so callers may memoize the result
+ /// on the sequence. The cheap, per-tick-volatile fields are re-read fresh via
+ /// and must never be memoized.
+ ///
+ /// The shared-library cache entry to project.
+ /// The template/category breakdown of 's objects.
+ public static GalaxyObjectBreakdown ComputeBreakdown(GalaxyHierarchyCacheEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
if (entry.Objects.Count == 0)
{
- topTemplates = Array.Empty();
- objectCategories = Array.Empty();
+ return GalaxyObjectBreakdown.Empty;
}
- else
+
+ Dictionary objectsByCategory = new();
+ Dictionary templateUsage = new(StringComparer.OrdinalIgnoreCase);
+
+ foreach (GalaxyObject obj in entry.Objects)
{
- Dictionary objectsByCategory = new();
- Dictionary templateUsage = new(StringComparer.OrdinalIgnoreCase);
+ objectsByCategory.TryGetValue(obj.CategoryId, out int categoryCount);
+ objectsByCategory[obj.CategoryId] = categoryCount + 1;
- foreach (GalaxyObject obj in entry.Objects)
+ if (obj.TemplateChain.Count > 0)
{
- objectsByCategory.TryGetValue(obj.CategoryId, out int categoryCount);
- objectsByCategory[obj.CategoryId] = categoryCount + 1;
-
- if (obj.TemplateChain.Count > 0)
+ string immediate = obj.TemplateChain[0];
+ if (!string.IsNullOrWhiteSpace(immediate))
{
- string immediate = obj.TemplateChain[0];
- if (!string.IsNullOrWhiteSpace(immediate))
- {
- templateUsage.TryGetValue(immediate, out int templateCount);
- templateUsage[immediate] = templateCount + 1;
- }
+ templateUsage.TryGetValue(immediate, out int templateCount);
+ templateUsage[immediate] = templateCount + 1;
}
}
-
- topTemplates = templateUsage
- .OrderByDescending(usage => usage.Value)
- .ThenBy(usage => usage.Key, StringComparer.OrdinalIgnoreCase)
- .Take(MaxTopTemplates)
- .Select(usage => new DashboardGalaxyTemplateUsage(usage.Key, usage.Value))
- .ToArray();
-
- objectCategories = objectsByCategory
- .OrderByDescending(category => category.Value)
- .ThenBy(category => category.Key)
- .Select(category => new DashboardGalaxyCategoryCount(
- category.Key,
- ResolveCategoryName(category.Key),
- category.Value))
- .ToArray();
}
+ IReadOnlyList topTemplates = templateUsage
+ .OrderByDescending(usage => usage.Value)
+ .ThenBy(usage => usage.Key, StringComparer.OrdinalIgnoreCase)
+ .Take(MaxTopTemplates)
+ .Select(usage => new DashboardGalaxyTemplateUsage(usage.Key, usage.Value))
+ .ToArray();
+
+ IReadOnlyList objectCategories = objectsByCategory
+ .OrderByDescending(category => category.Value)
+ .ThenBy(category => category.Key)
+ .Select(category => new DashboardGalaxyCategoryCount(
+ category.Key,
+ ResolveCategoryName(category.Key),
+ category.Value))
+ .ToArray();
+
+ return new GalaxyObjectBreakdown(topTemplates, objectCategories);
+ }
+
+ ///
+ /// Assembles a from an entry's cheap, per-tick-volatile
+ /// fields (status, timestamps, last error, counts) and a precomputed
+ /// . The volatile fields are copied fresh on every call so a
+ /// memoized breakdown never freezes the dashboard's health or timestamps between heavy
+ /// refreshes — the library mutates those fields in place at the same sequence on steady-state
+ /// ticks and on refresh failure.
+ ///
+ /// The cache entry whose volatile fields to copy.
+ /// The precomputed template/category breakdown.
+ /// The assembled dashboard summary.
+ public static DashboardGalaxySummary BuildSummary(GalaxyHierarchyCacheEntry entry, GalaxyObjectBreakdown breakdown)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ ArgumentNullException.ThrowIfNull(breakdown);
+
return new DashboardGalaxySummary(
Status: MapDashboardStatus(entry.Status),
LastQueriedAt: entry.LastQueriedAt,
@@ -85,8 +114,8 @@ public static class DashboardGalaxySummaryProjector
AttributeCount: entry.AttributeCount,
HistorizedAttributeCount: entry.HistorizedAttributeCount,
AlarmAttributeCount: entry.AlarmAttributeCount,
- TopTemplates: topTemplates,
- ObjectCategories: objectCategories);
+ TopTemplates: breakdown.TopTemplates,
+ ObjectCategories: breakdown.ObjectCategories);
}
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
index 0489bfd..371bca2 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
@@ -30,12 +30,13 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly ILogger _logger;
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
private IReadOnlyList _apiKeySummaries = Array.Empty();
- // 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;
/// Initializes a new instance of the DashboardSnapshotService class.
/// Registry of active gateway sessions.
@@ -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);
///
/// Watches dashboard snapshots at regular intervals asynchronously.
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 6c9e3e6..4df7ce3 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
@@ -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);
}
+ ///
+ /// Regression for Server-059: the shared library replaces the cache entry via
+ /// previous with { ... } at the same 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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).
+ ///
+ [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);
+ }
+
///
/// Verifies snapshot watcher cancels cleanly when subscriber cancels.
///
@@ -452,6 +557,22 @@ public sealed class DashboardSnapshotServiceTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
+ private sealed class MutableGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
+ {
+ /// Gets or sets the current Galaxy hierarchy cache entry, swappable between snapshots.
+ public GalaxyHierarchyCacheEntry Current { get; set; } = current;
+
+ /// Refreshes the cache asynchronously.
+ /// Cancellation token.
+ /// Completed task.
+ public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ /// Waits for the first cache load asynchronously.
+ /// Cancellation token.
+ /// Completed task.
+ public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
///
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs
index 2eb1667..5ae6cab 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs
@@ -41,6 +41,25 @@ public sealed class GatewayApplicationTests
Assert.Equal("SerilogLoggerFactory", factory.GetType().Name);
}
+ ///
+ /// Verifies that Build registers the gateway's browse-scope provider so it wins over the
+ /// shared library's no-op default (Server-060). The per-API-key browse-subtree scoping
+ /// depends on AddSingleton<IGalaxyBrowseScopeProvider, GatewayBrowseScopeProvider>
+ /// running before AddZbGalaxyRepository, whose TryAddSingleton default
+ /// (NullGalaxyBrowseScopeProvider → full hierarchy) must lose. If this registration order ever
+ /// regressed, every metadata-scoped key would silently see the entire Galaxy hierarchy.
+ ///
+ [Fact]
+ public async Task Build_RegistersGatewayBrowseScopeProviderOverLibraryDefault()
+ {
+ await using WebApplication app = GatewayApplication.Build([]);
+
+ ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider scopeProvider =
+ app.Services.GetRequiredService();
+
+ Assert.IsType(scopeProvider);
+ }
+
/// Verifies that Build registers the gateway metrics service.
[Fact]
public async Task Build_RegistersGatewayMetrics()