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); } /// /// Resolves the request's parent oneof to a gobject id, throwing /// with when the /// parent does not exist. Public so the gRPC handler can compute the same /// parent id (needed for the page-token signature) without reimplementing the /// resolution rules. /// /// The Galaxy hierarchy cache entry to query. /// The browse-children request. public 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: { if (!entry.Index.ObjectViewsByTagName.TryGetValue(request.ParentTagName, out GalaxyObjectView? match)) { throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); } return match.Object.GobjectId; } case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath: { if (!entry.Index.ObjectViewsByContainedPath.TryGetValue(request.ParentContainedPath, out GalaxyObjectView? match)) { 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; } // Defend against pathological cycles in Galaxy data (e.g. a corrupt A→B→A chain). // BuildContainedPath uses the same visited-id pattern; mirror it so this walk // terminates even when ChildrenByParent forms a cycle. HashSet visited = new() { parent.Object.GobjectId }; Stack stack = new(); foreach (GalaxyObjectView child in children) { if (visited.Add(child.Object.GobjectId)) { 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) { if (visited.Add(grandchild.Object.GobjectId)) { 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); }