9ae6bce0c8
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).
143 lines
6.3 KiB
C#
143 lines
6.3 KiB
C#
using ZB.MOM.WW.GalaxyRepository;
|
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
/// <summary>
|
|
/// Projects a shared-library <see cref="GalaxyHierarchyCacheEntry"/> into the
|
|
/// dashboard's host-side <see cref="DashboardGalaxySummary"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The shared <c>ZB.MOM.WW.GalaxyRepository</c> cache entry does not compute a
|
|
/// dashboard summary; this projector relocates the summary logic that previously
|
|
/// lived inside the gateway's inline Galaxy hierarchy cache. It groups the entry's
|
|
/// objects into template usage and category counts, maps the cache status to the
|
|
/// dashboard status, and copies the timestamps and counts the entry already carries.
|
|
/// </remarks>
|
|
public static class DashboardGalaxySummaryProjector
|
|
{
|
|
private const int MaxTopTemplates = 10;
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="DashboardGalaxySummary"/> from a shared-library Galaxy
|
|
/// hierarchy cache entry.
|
|
/// </summary>
|
|
/// <param name="entry">The shared-library cache entry to project.</param>
|
|
/// <returns>The dashboard summary derived from <paramref name="entry"/>.</returns>
|
|
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entry);
|
|
return BuildSummary(entry, ComputeBreakdown(entry));
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
return GalaxyObjectBreakdown.Empty;
|
|
}
|
|
|
|
Dictionary<int, int> objectsByCategory = new();
|
|
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (GalaxyObject obj in entry.Objects)
|
|
{
|
|
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))
|
|
{
|
|
templateUsage.TryGetValue(immediate, out int templateCount);
|
|
templateUsage[immediate] = templateCount + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
LastSuccessAt: entry.LastSuccessAt,
|
|
LastDeployTime: entry.LastDeployTime,
|
|
LastError: entry.LastError,
|
|
ObjectCount: entry.ObjectCount,
|
|
AreaCount: entry.AreaCount,
|
|
AttributeCount: entry.AttributeCount,
|
|
HistorizedAttributeCount: entry.HistorizedAttributeCount,
|
|
AlarmAttributeCount: entry.AlarmAttributeCount,
|
|
TopTemplates: breakdown.TopTemplates,
|
|
ObjectCategories: breakdown.ObjectCategories);
|
|
}
|
|
|
|
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
|
|
{
|
|
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
|
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
|
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
|
_ => DashboardGalaxyStatus.Unknown,
|
|
};
|
|
|
|
private static string ResolveCategoryName(int categoryId) => categoryId switch
|
|
{
|
|
1 => "WinPlatform",
|
|
3 => "AppEngine",
|
|
4 => "InTouchViewApp",
|
|
10 => "UserDefined",
|
|
11 => "FieldReference",
|
|
13 => "Area",
|
|
17 => "DIObject",
|
|
24 => "DDESuiteLinkClient",
|
|
26 => "OPCClient",
|
|
_ => $"Category {categoryId}",
|
|
};
|
|
}
|