From 87e22dd529a0099e71c673f05b29f3608f040af8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 12:58:07 -0400 Subject: [PATCH] galaxy: add GalaxyBrowseProjector for direct-children projection --- .../Galaxy/GalaxyBrowseChildrenResult.cs | 19 ++ .../Galaxy/GalaxyBrowseProjector.cs | 266 +++++++++++++++ .../Galaxy/GalaxyBrowseProjectorTests.cs | 311 ++++++++++++++++++ 3 files changed, 596 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs new file mode 100644 index 0000000..4d77d3f --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs @@ -0,0 +1,19 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// Result of one call. Holds a +/// materialized page of direct children for the requested parent, along with a +/// parallel-indexed hint and the total post-filter +/// sibling count for paging. +/// +/// The page of direct children, sorted areas-first then by display name. +/// Parallel array indicating whether each child has at least one matching descendant under the same filter set. +/// Total matching direct children of the parent (post-filter). +/// Stable signature of the filter and parent selector, used to bind page tokens. +public sealed record GalaxyBrowseChildrenResult( + IReadOnlyList Children, + IReadOnlyList ChildHasChildren, + int TotalChildCount, + string FilterSignature); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs new file mode 100644 index 0000000..a9b7501 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs @@ -0,0 +1,266 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// Projects one level of children of a parent object out of an immutable +/// . Pure and side-effect free. Memoizes the +/// filtered child list per cache-entry instance so repeated paging is an O(pageSize) +/// slice rather than an O(siblings) filter scan per page. The memo is keyed on the +/// immutable cache entry, so when the cache publishes a new entry the stale memo +/// becomes unreachable and is reclaimed with it. +/// +public static class GalaxyBrowseProjector +{ + private static readonly ConditionalWeakTable< + GalaxyHierarchyCacheEntry, + ConcurrentDictionary> FilteredChildrenCache = new(); + + /// Projects one page of direct children of the resolved parent. + /// The Galaxy hierarchy cache entry to query. + /// The browse-children request. + /// Optional API-key browse-subtree constraints. + /// Zero-based offset into the filtered child list. + /// Maximum number of children to return. + public static GalaxyBrowseChildrenResult ProjectChildren( + GalaxyHierarchyCacheEntry entry, + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs, + int offset, + int pageSize) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(request); + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero."); + } + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero."); + } + + int parentId = ResolveParentId(entry, request); + string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId); + FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature); + + bool includeAttributes = IncludeAttributes(request); + int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count); + List page = new(Math.Max(0, end - offset)); + List hasChildren = new(Math.Max(0, end - offset)); + for (int index = offset; index < end; index++) + { + page.Add(CloneObject(filtered.Children[index].Object, includeAttributes)); + hasChildren.Add(filtered.HasMatchingDescendant[index]); + } + + return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature); + } + + private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request) + { + switch (request.ParentCase) + { + case BrowseChildrenRequest.ParentOneofCase.None: + return 0; + case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId: + if (request.ParentGobjectId == 0) + { + return 0; + } + if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)) + { + throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); + } + return request.ParentGobjectId; + case BrowseChildrenRequest.ParentOneofCase.ParentTagName: + { + GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault( + view => string.Equals(view.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); + } + return match.Object.GobjectId; + } + case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath: + { + GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault( + view => string.Equals(view.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); + } + return match.Object.GobjectId; + } + default: + return 0; + } + } + + private static FilteredChildren GetFilteredChildren( + GalaxyHierarchyCacheEntry entry, + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs, + int parentId, + string filterSignature) + { + ConcurrentDictionary memo = + FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary(StringComparer.Ordinal)); + + return memo.GetOrAdd( + filterSignature, + static (_, state) => + { + IReadOnlyDictionary> map = state.Entry.Index.ChildrenByParent; + IReadOnlyList directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList? list) + ? list + : Array.Empty(); + + List matched = []; + List hasMatching = []; + foreach (GalaxyObjectView view in directChildren) + { + if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)) + { + continue; + } + if (!MatchesFilters(view.Object, state.Request)) + { + // Even if the direct child itself fails the filter, a matching + // descendant should still surface its ancestor — but only when + // there is one. Mirror the dashboard browse-tree semantics: if a + // descendant matches, include the parent with has-children true. + if (HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs)) + { + matched.Add(view); + hasMatching.Add(true); + } + continue; + } + matched.Add(view); + hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs)); + } + + return new FilteredChildren(matched, hasMatching); + }, + (Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs)); + } + + private static bool HasMatchingDescendant( + GalaxyObjectView parent, + GalaxyHierarchyIndex index, + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs) + { + if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList? children)) + { + return false; + } + + Stack stack = new(); + foreach (GalaxyObjectView child in children) + { + stack.Push(child); + } + while (stack.Count > 0) + { + GalaxyObjectView candidate = stack.Pop(); + if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs) + && MatchesFilters(candidate.Object, request)) + { + return true; + } + if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList? grandchildren)) + { + foreach (GalaxyObjectView grandchild in grandchildren) + { + stack.Push(grandchild); + } + } + } + return false; + } + + private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList? browseSubtreeGlobs) + { + return browseSubtreeGlobs is null + || browseSubtreeGlobs.Count == 0 + || browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob)); + } + + private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request) + { + if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId)) + { + return false; + } + foreach (string templateFilter in request.TemplateChainContains) + { + if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + if (!string.IsNullOrWhiteSpace(request.TagNameGlob) + && !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob)) + { + return false; + } + if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm)) + { + return false; + } + if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized)) + { + return false; + } + return true; + } + + private static bool IncludeAttributes(BrowseChildrenRequest request) + { + return !request.HasIncludeAttributes || request.IncludeAttributes; + } + + private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes) + { + GalaxyObject clone = source.Clone(); + if (!includeAttributes) + { + clone.Attributes.Clear(); + } + return clone; + } + + /// Computes a stable filter signature for memoization purposes. + /// The browse-children request. + /// Optional API-key browse-subtree constraints. + /// Resolved parent gobject id (0 for roots). + public static string ComputeFilterSignature( + BrowseChildrenRequest request, + IReadOnlyList? browseSubtreeGlobs, + int parentId) + { + StringBuilder builder = new(); + builder.Append("parent=").Append(parentId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order()); + builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase)); + builder.Append("|glob=").Append(request.TagNameGlob); + builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset"); + builder.Append("|alarm=").Append(request.AlarmBearingOnly); + builder.Append("|hist=").Append(request.HistorizedOnly); + builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty()).Order(StringComparer.OrdinalIgnoreCase)); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexString(hash, 0, 12); + } + + private sealed record FilteredChildren( + IReadOnlyList Children, + IReadOnlyList HasMatchingDescendant); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs new file mode 100644 index 0000000..98fee57 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs @@ -0,0 +1,311 @@ +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +/// +/// Direct coverage for . Validates parent +/// resolution (gobject id / tag name / contained path), paging across siblings, +/// filter parity with , the +/// child_has_children hint, browse-subtree constraints, and the +/// attribute-skeleton mode. +/// +public sealed class GalaxyBrowseProjectorTests +{ + /// Verifies that an empty parent oneof returns the root area. + [Fact] + public void Project_NoParent_ReturnsRootArea() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest(), + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10); + + Assert.Single(result.Children); + Assert.Equal("Plant", result.Children[0].TagName); + Assert.True(result.ChildHasChildren[0]); + } + + /// Verifies that resolving the parent by gobject id returns sorted direct children. + [Fact] + public void Project_ByParentGobjectId_ReturnsDirectChildren() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest { ParentGobjectId = 1 }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10); + + string[] names = result.Children.Select(child => child.TagName).ToArray(); + Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names); + Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray()); + Assert.Equal(4, result.TotalChildCount); + } + + /// Verifies that resolving the parent by tag name returns the same direct children. + [Fact] + public void Project_ByParentTagName_ResolvesParent() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest { ParentTagName = "Plant" }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10); + + string[] names = result.Children.Select(child => child.TagName).ToArray(); + Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names); + } + + /// Verifies that resolving the parent by contained path returns the same direct children. + [Fact] + public void Project_ByParentContainedPath_ResolvesParent() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest { ParentContainedPath = "Plant" }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10); + + string[] names = result.Children.Select(child => child.TagName).ToArray(); + Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names); + } + + /// Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound. + [Fact] + public void Project_UnknownParent_ThrowsNotFound() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + RpcException exception = Assert.Throws(() => GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest { ParentGobjectId = 999 }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10)); + + Assert.Equal(StatusCode.NotFound, exception.StatusCode); + } + + /// Verifies that paging across siblings returns every sibling exactly once. + [Fact] + public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest { ParentGobjectId = 1 }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 2); + GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest { ParentGobjectId = 1 }, + browseSubtreeGlobs: null, + offset: 2, + pageSize: 2); + + List collected = first.Children + .Concat(second.Children) + .Select(child => child.TagName) + .ToList(); + Assert.Equal(4, collected.Count); + Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal( + new HashSet(StringComparer.Ordinal) + { + "Plant.Line_A", + "Plant.Mixer_001", + "Plant.Mixer_002", + "Plant.Pump_001", + }, + new HashSet(collected, StringComparer.Ordinal)); + } + + /// Verifies that a tag-name glob filters direct children and clears the has-children hint. + [Fact] + public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest + { + ParentGobjectId = 1, + TagNameGlob = "*Mixer*", + }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10); + + string[] names = result.Children.Select(child => child.TagName).ToArray(); + Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names); + Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray()); + } + + /// Verifies that historized-only filtering also drives the has-children hint via descendants. + [Fact] + public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest + { + ParentGobjectId = 1, + HistorizedOnly = true, + }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10); + + // Line_A itself has no historized attributes, but its descendant Sensor_A1 does, + // so the subtree match keeps Line_A in the result with has-children = true. + // Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and + // no historized descendants -> filtered out entirely. + Assert.Single(result.Children); + Assert.Equal("Plant.Line_A", result.Children[0].TagName); + Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray()); + } + + /// Verifies that IncludeAttributes=false returns object skeletons. + [Fact] + public void Project_IncludeAttributesFalse_ReturnsSkeletons() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest + { + ParentGobjectId = 1, + IncludeAttributes = false, + }, + browseSubtreeGlobs: null, + offset: 0, + pageSize: 10); + + GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001"); + Assert.Empty(mixer.Attributes); + } + + /// Verifies that browse-subtree globs constrain the returned children. + [Fact] + public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs() + { + GalaxyHierarchyCacheEntry entry = CreateEntry(); + + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + new BrowseChildrenRequest { ParentGobjectId = 1 }, + browseSubtreeGlobs: new[] { "Plant/Line_*" }, + offset: 0, + pageSize: 10); + + Assert.Single(result.Children); + Assert.Equal("Plant.Line_A", result.Children[0].TagName); + } + + private static GalaxyHierarchyCacheEntry CreateEntry() + { + IReadOnlyList objects = CreateObjects(); + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Sequence = 1, + LastSuccessAt = DateTimeOffset.UtcNow, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown with + { + Status = DashboardGalaxyStatus.Healthy, + ObjectCount = objects.Count, + }, + ObjectCount = objects.Count, + }; + } + + private static IReadOnlyList CreateObjects() + { + GalaxyObject plant = new() + { + GobjectId = 1, + ParentGobjectId = 0, + IsArea = true, + ContainedName = "Plant", + BrowseName = "Plant", + TagName = "Plant", + }; + GalaxyObject mixer001 = new() + { + GobjectId = 2, + ParentGobjectId = 1, + ContainedName = "Mixer_001", + BrowseName = "Mixer_001", + TagName = "Plant.Mixer_001", + }; + mixer001.Attributes.Add(new GalaxyAttribute + { + AttributeName = "Speed", + FullTagReference = "Plant.Mixer_001.Speed", + }); + GalaxyObject mixer002 = new() + { + GobjectId = 3, + ParentGobjectId = 1, + ContainedName = "Mixer_002", + BrowseName = "Mixer_002", + TagName = "Plant.Mixer_002", + }; + GalaxyObject lineA = new() + { + GobjectId = 4, + ParentGobjectId = 1, + IsArea = true, + ContainedName = "Line_A", + BrowseName = "Line_A", + TagName = "Plant.Line_A", + }; + GalaxyObject sensorA1 = new() + { + GobjectId = 5, + ParentGobjectId = 4, + ContainedName = "Sensor_A1", + BrowseName = "Sensor_A1", + TagName = "Plant.Line_A.Sensor_A1", + }; + sensorA1.Attributes.Add(new GalaxyAttribute + { + AttributeName = "Value", + FullTagReference = "Plant.Line_A.Sensor_A1.Value", + IsHistorized = true, + }); + GalaxyObject pump001 = new() + { + GobjectId = 6, + ParentGobjectId = 1, + ContainedName = "Pump_001", + BrowseName = "Pump_001", + TagName = "Plant.Pump_001", + }; + + return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 }; + } +}