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; public static class GalaxyHierarchyProjector { /// /// Per-cache-entry memo of filtered, ordered lists /// keyed by filter signature. Without it, paging through a large hierarchy /// re-applies every filter and re-scans the full /// collection on every page — O(total) per page, O(total²/pageSize) end-to-end. /// With it, the first page builds the filtered list and each subsequent page is an /// O(pageSize) slice. The table is keyed on the immutable cache-entry instance, so /// when the cache publishes a new entry the stale memo becomes unreachable and is /// reclaimed with it — no explicit invalidation needed. /// private static readonly ConditionalWeakTable>> FilteredViewCache = new(); /// Projects a discovery request against a cache entry and returns all matching objects. /// The Galaxy hierarchy cache entry. /// The discovery hierarchy request. /// Optional glob patterns to filter browse subtrees. public static GalaxyHierarchyQueryResult Project( GalaxyHierarchyCacheEntry entry, DiscoverHierarchyRequest request, IReadOnlyList? browseSubtreeGlobs = null) { return Project( entry, request, browseSubtreeGlobs, offset: 0, pageSize: int.MaxValue); } /// Projects a discovery request with paging against a cache entry and returns a page of matching objects. /// The Galaxy hierarchy cache entry. /// The discovery hierarchy request. /// Optional glob patterns to filter browse subtrees. /// The zero-based offset into the result set. /// The maximum number of results to return. public static GalaxyHierarchyQueryResult Project( GalaxyHierarchyCacheEntry entry, DiscoverHierarchyRequest 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? maxDepth = request.MaxDepth; if (maxDepth < 0) { throw new RpcException(new Status( StatusCode.InvalidArgument, "DiscoverHierarchy max_depth must be greater than or equal to zero when provided.")); } string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs); IReadOnlyList matchedViews = GetFilteredViews( entry, request, browseSubtreeGlobs, maxDepth, filterSignature); bool includeAttributes = IncludeAttributes(request); List page = new(Math.Min(pageSize, Math.Max(0, matchedViews.Count - offset))); int end = (int)Math.Min((long)offset + pageSize, matchedViews.Count); for (int index = offset; index < end; index++) { page.Add(CloneObject(matchedViews[index].Object, includeAttributes)); } return new GalaxyHierarchyQueryResult( page, matchedViews.Count, filterSignature); } private static IReadOnlyList GetFilteredViews( GalaxyHierarchyCacheEntry entry, DiscoverHierarchyRequest request, IReadOnlyList? browseSubtreeGlobs, int? maxDepth, string filterSignature) { // ResolveRoot can throw RpcException(NotFound); run it before consulting the // memo so a bad root surfaces consistently regardless of cache state. IReadOnlyList views = entry.Index.ObjectViews; GalaxyObjectView? root = ResolveRoot(request, entry.Index); ConcurrentDictionary> memo = FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary>(StringComparer.Ordinal)); return memo.GetOrAdd( filterSignature, static (_, state) => { List matched = []; foreach (GalaxyObjectView view in state.Views) { if (MatchesRoot(view, state.Root, state.MaxDepth) && MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs) && MatchesFilters(view.Object, state.Request)) { matched.Add(view); } } return matched; }, (Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request)); } /// Finds an object in the hierarchy by its tag address. /// The Galaxy hierarchy cache entry. /// The tag address to search for. public static GalaxyObject? FindObjectForTag( GalaxyHierarchyCacheEntry entry, string tagAddress) { if (string.IsNullOrWhiteSpace(tagAddress)) { return null; } return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) ? lookup.Object : null; } /// Finds an attribute in the hierarchy by its tag address. /// The Galaxy hierarchy cache entry. /// The tag address to search for. public static GalaxyAttribute? FindAttributeForTag( GalaxyHierarchyCacheEntry entry, string tagAddress) { if (string.IsNullOrWhiteSpace(tagAddress)) { return null; } return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) ? lookup.Attribute : null; } /// Gets the contained path for an object by its gobject ID. /// The Galaxy hierarchy cache entry. /// The Galaxy object ID. public static string GetContainedPath( GalaxyHierarchyCacheEntry entry, int gobjectId) { return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view) ? view.ContainedPath : string.Empty; } private static GalaxyObjectView? ResolveRoot( DiscoverHierarchyRequest request, GalaxyHierarchyIndex index) { GalaxyObjectView? root = request.RootCase switch { DiscoverHierarchyRequest.RootOneofCase.None => null, DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => index.ObjectViewsById.TryGetValue(request.RootGobjectId, out GalaxyObjectView? byId) ? byId : null, DiscoverHierarchyRequest.RootOneofCase.RootTagName => index.ObjectViewsByTagName.TryGetValue(request.RootTagName, out GalaxyObjectView? byTag) ? byTag : null, DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => index.ObjectViewsByContainedPath.TryGetValue(request.RootContainedPath, out GalaxyObjectView? byPath) ? byPath : null, _ => null, }; if (request.RootCase != DiscoverHierarchyRequest.RootOneofCase.None && root is null) { throw new RpcException(new Status(StatusCode.NotFound, "DiscoverHierarchy root was not found.")); } return root; } private static bool MatchesRoot( GalaxyObjectView view, GalaxyObjectView? root, int? maxDepth) { if (root is null) { return true; } bool isRoot = view.Object.GobjectId == root.Object.GobjectId; bool isDescendant = view.ContainedPath.StartsWith(root.ContainedPath + "/", StringComparison.OrdinalIgnoreCase); if (!isRoot && !isDescendant) { return false; } return maxDepth is null || view.Depth - root.Depth <= maxDepth.Value; } 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, DiscoverHierarchyRequest 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(DiscoverHierarchyRequest 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 discovery hierarchy request. /// Optional glob patterns to filter browse subtrees. public static string ComputeFilterSignature( DiscoverHierarchyRequest request, IReadOnlyList? browseSubtreeGlobs) { StringBuilder builder = new(); builder.Append("root=").Append(request.RootCase).Append('|'); builder.Append(request.RootCase switch { DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => request.RootGobjectId.ToString( System.Globalization.CultureInfo.InvariantCulture), DiscoverHierarchyRequest.RootOneofCase.RootTagName => request.RootTagName, DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => request.RootContainedPath, _ => string.Empty, }); builder.Append("|max=").Append(request.MaxDepth?.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); } }