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:
@@ -27,53 +27,82 @@ public static class DashboardGalaxySummaryProjector
|
||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
return BuildSummary(entry, ComputeBreakdown(entry));
|
||||
}
|
||||
|
||||
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="GalaxyHierarchyCacheEntry.Sequence"/> — 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
|
||||
/// <see cref="BuildSummary"/> and must never be memoized.
|
||||
/// </summary>
|
||||
/// <param name="entry">The shared-library cache entry to project.</param>
|
||||
/// <returns>The template/category breakdown of <paramref name="entry"/>'s objects.</returns>
|
||||
public static GalaxyObjectBreakdown ComputeBreakdown(GalaxyHierarchyCacheEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (entry.Objects.Count == 0)
|
||||
{
|
||||
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
||||
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
||||
return GalaxyObjectBreakdown.Empty;
|
||||
}
|
||||
else
|
||||
|
||||
Dictionary<int, int> objectsByCategory = new();
|
||||
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyObject obj in entry.Objects)
|
||||
{
|
||||
Dictionary<int, int> objectsByCategory = new();
|
||||
Dictionary<string, int> 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<DashboardGalaxyTemplateUsage> topTemplates = templateUsage
|
||||
.OrderByDescending(usage => usage.Value)
|
||||
.ThenBy(usage => usage.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxTopTemplates)
|
||||
.Select(usage => new DashboardGalaxyTemplateUsage(usage.Key, usage.Value))
|
||||
.ToArray();
|
||||
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles a <see cref="DashboardGalaxySummary"/> from an entry's cheap, per-tick-volatile
|
||||
/// fields (status, timestamps, last error, counts) and a precomputed
|
||||
/// <paramref name="breakdown"/>. 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.
|
||||
/// </summary>
|
||||
/// <param name="entry">The cache entry whose volatile fields to copy.</param>
|
||||
/// <param name="breakdown">The precomputed template/category breakdown.</param>
|
||||
/// <returns>The assembled dashboard summary.</returns>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user