From 8e196a7c83f068ab564f9bcfe49500250e385f8c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 11:33:22 -0400 Subject: [PATCH] refactor(gateway): adopt ZB.MOM.WW.GalaxyRepository 0.2.0; delete inline Galaxy code --- .../Alarms/AlarmWatchListResolver.cs | 2 +- .../Components/Pages/BrowsePage.razor | 4 +- .../Components/Pages/GalaxyPage.razor | 2 +- .../Shared/BrowseTreeNodeView.razor | 2 +- .../Dashboard/DashboardBrowseModel.cs | 2 +- .../Dashboard/DashboardBrowseService.cs | 4 +- .../Dashboard/DashboardGalaxyProjector.cs | 6 +- .../Dashboard/DashboardSnapshotService.cs | 2 +- .../Dashboard/IDashboardBrowseService.cs | 2 +- .../Galaxy/GalaxyAlarmAttributeRow.cs | 48 -- .../Galaxy/GalaxyBrowseChildrenResult.cs | 19 - .../Galaxy/GalaxyBrowseProjector.cs | 281 ---------- .../Galaxy/GalaxyCacheStatus.cs | 17 - .../Galaxy/GalaxyDeployEventInfo.cs | 14 - .../Galaxy/GalaxyDeployNotifier.cs | 82 --- .../Galaxy/GalaxyGlobMatcher.cs | 126 ----- .../Galaxy/GalaxyHierarchyCache.cs | 489 ------------------ .../Galaxy/GalaxyHierarchyCacheEntry.cs | 46 -- .../Galaxy/GalaxyHierarchyIndex.cs | 200 ------- .../Galaxy/GalaxyHierarchyProjector.cs | 311 ----------- .../Galaxy/GalaxyHierarchyQueryResult.cs | 8 - .../Galaxy/GalaxyHierarchyRefreshService.cs | 62 --- .../Galaxy/GalaxyHierarchyRow.cs | 75 --- .../Galaxy/GalaxyHierarchySnapshot.cs | 24 - .../Galaxy/GalaxyHierarchySnapshotStore.cs | 141 ----- .../Galaxy/GalaxyObjectView.cs | 8 - .../Galaxy/GalaxyRepository.cs | 372 ------------- .../Galaxy/GalaxyRepositoryOptions.cs | 47 -- ...xyRepositoryServiceCollectionExtensions.cs | 28 - .../Galaxy/GalaxyTagLookup.cs | 8 - .../Galaxy/IGalaxyDeployNotifier.cs | 17 - .../Galaxy/IGalaxyHierarchyCache.cs | 25 - .../Galaxy/IGalaxyHierarchySnapshotStore.cs | 28 - .../Galaxy/IGalaxyRepository.cs | 38 -- .../GatewayApplication.cs | 10 +- .../Grpc/GalaxyProtoMapper.cs | 77 --- .../Grpc/GalaxyRepositoryGrpcService.cs | 348 ------------- .../Authorization/ConstraintEnforcer.cs | 4 +- .../GatewayBrowseScopeProvider.cs | 17 + .../Authorization/GatewayGrpcScopeResolver.cs | 2 +- .../Sessions/ArrayAddressNormalizer.cs | 2 +- 41 files changed, 41 insertions(+), 2959 deletions(-) delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyCacheStatus.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshot.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyObjectView.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyTagLookup.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchySnapshotStore.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayBrowseScopeProvider.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs index c7b159e..52eb4de 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs @@ -1,6 +1,6 @@ +using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; -using ZB.MOM.WW.MxGateway.Server.Galaxy; namespace ZB.MOM.WW.MxGateway.Server.Alarms; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor index bda9020..6ff5265 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor @@ -4,8 +4,8 @@ @inject IDashboardLiveDataService LiveData @inject IDashboardBrowseService BrowseService @inject IGalaxyDeployNotifier DeployNotifier -@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy -@using ZB.MOM.WW.MxGateway.Server.Galaxy +@using ZB.MOM.WW.GalaxyRepository +@using ZB.MOM.WW.GalaxyRepository.Grpc Dashboard Browse diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor index f1d73ea..1f97af6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor @@ -1,6 +1,6 @@ @page "/galaxy" @inherits DashboardPageBase -@using ZB.MOM.WW.MxGateway.Server.Galaxy +@using ZB.MOM.WW.GalaxyRepository Dashboard Galaxy diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor index cc511e7..ce696d2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor @@ -1,4 +1,4 @@ -@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy +@using ZB.MOM.WW.GalaxyRepository.Grpc @* Recursive Browse hierarchy node. Renders one Galaxy object, its child diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs index 93bab58..5cfc10d 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs @@ -1,4 +1,4 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.GalaxyRepository.Grpc; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs index 95d1953..ea8968e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs @@ -1,6 +1,6 @@ using Grpc.Core; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs index a87e0c4..b4b3f2e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs @@ -1,14 +1,14 @@ -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; -/// Projects the precomputed Galaxy cache dashboard summary. +/// Projects the shared-library Galaxy cache entry into a dashboard Galaxy summary. internal static class DashboardGalaxyProjector { /// Projects the cache entry to a dashboard Galaxy summary. /// The Galaxy hierarchy cache entry. public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) { - return entry.DashboardSummary; + return DashboardGalaxySummaryProjector.Project(entry); } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs index 15852aa..ad5a482 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.MxGateway.Server.Configuration; -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Sessions; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs index 53becb9..ab857f1 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs @@ -1,4 +1,4 @@ -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs deleted file mode 100644 index fa175cf..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// One alarm-bearing attribute discovered by -/// : an attribute whose owning -/// object configures an AlarmExtension primitive (the same -/// is_alarm detection used by ). -/// Used to build the subtag-fallback watch-list. -/// -public sealed class GalaxyAlarmAttributeRow -{ - /// - /// Gets the alarm-bearing attribute reference (e.g. Tank01.Level.HiHi), - /// matching the full_tag_reference projection of - /// . - /// - public string FullTagReference { get; init; } = string.Empty; - - /// - /// Gets the owning object reference (e.g. Tank01). This is the Galaxy - /// tag_name — the segment that precedes the first attribute dot in - /// . - /// - public string SourceObjectReference { get; init; } = string.Empty; - - /// - /// Gets the owning object's Galaxy area (e.g. TestArea) — the alarm group. - /// - /// Resolved via gobject.area_gobject_id in AlarmAttributesSql. The - /// watch-list resolver composes the canonical Galaxy!{area}.{reference} from - /// this so the synthesized reference's group matches the native alarmmgr (wnwrap) - /// for reference parity. May be when the object has no - /// area; the resolver then falls back to the configured area. - /// - /// - public string Area { get; init; } = string.Empty; - - /// - /// Gets the writable ack-comment attribute address. - /// - /// The Galaxy Repository schema does not expose an ack-comment subtag address - /// directly, so this is always here. The watch-list - /// resolver (a later task) composes the concrete address from configuration plus - /// / . - /// - /// - public string AckCommentSubtag { get; init; } = string.Empty; -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs deleted file mode 100644 index 4d77d3f..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 1768fb3..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs +++ /dev/null @@ -1,281 +0,0 @@ -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); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyCacheStatus.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyCacheStatus.cs deleted file mode 100644 index a90641f..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyCacheStatus.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -public enum GalaxyCacheStatus -{ - /// Cache has never completed a refresh. - Unknown = 0, - - /// Cache holds data from a recent successful refresh. - Healthy = 1, - - /// Cache holds data, but the most recent refresh attempt failed - /// or no successful refresh has happened within the staleness threshold. - Stale = 2, - - /// Latest refresh failed and no prior data is available. - Unavailable = 3, -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs deleted file mode 100644 index 889385f..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployEventInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// A single Galaxy deploy notification. Published by -/// whenever a refresh detects that galaxy.time_of_last_deploy has changed (or on -/// the first successful refresh). Consumed by -/// subscribers (the streaming gRPC RPC). -/// -public sealed record GalaxyDeployEventInfo( - long Sequence, - DateTimeOffset ObservedAt, - DateTimeOffset? TimeOfLastDeploy, - int ObjectCount, - int AttributeCount); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs deleted file mode 100644 index f7ae0a0..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using System.Threading.Channels; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// Channel-based fan-out of Galaxy deploy events to streaming gRPC subscribers. Each -/// subscriber gets a private bounded channel so a slow client cannot back-pressure -/// other subscribers or the publisher. When a subscriber's channel is full the oldest -/// event is dropped — clients use the sequence field to detect gaps. -/// -/// -/// Publishes Galaxy deploy events to streaming gRPC subscribers via private bounded channels. -/// -public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier -{ - private const int SubscriberQueueCapacity = 16; - - private readonly ConcurrentDictionary> _subscribers = new(); - private GalaxyDeployEventInfo? _latest; - - /// - /// The most recent deploy event, or null if none has been published. - /// - public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest); - - /// - public void Publish(GalaxyDeployEventInfo info) - { - ArgumentNullException.ThrowIfNull(info); - - Volatile.Write(ref _latest, info); - - foreach (Channel channel in _subscribers.Values) - { - // BoundedChannelFullMode.DropOldest -> writes never wait; we only fail if the - // channel was completed by the subscriber side, which we ignore. - channel.Writer.TryWrite(info); - } - } - - /// - public async IAsyncEnumerable SubscribeAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - Guid subscriberId = Guid.NewGuid(); - Channel channel = Channel.CreateBounded( - new BoundedChannelOptions(SubscriberQueueCapacity) - { - FullMode = BoundedChannelFullMode.DropOldest, - SingleReader = true, - SingleWriter = false, - }); - - _subscribers[subscriberId] = channel; - - // Bootstrap: emit the latest known event so subscribers don't need to wait for - // the next deploy to know current state. - GalaxyDeployEventInfo? bootstrap = Volatile.Read(ref _latest); - if (bootstrap is not null) - { - channel.Writer.TryWrite(bootstrap); - } - - try - { - while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) - { - while (channel.Reader.TryRead(out GalaxyDeployEventInfo? next)) - { - yield return next; - } - } - } - finally - { - _subscribers.TryRemove(subscriberId, out _); - channel.Writer.TryComplete(); - } - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs deleted file mode 100644 index 2ec139e..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Collections.Concurrent; -using System.Text; -using System.Text.RegularExpressions; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -public static class GalaxyGlobMatcher -{ - /// - /// Maximum number of compiled-regex entries retained in . - /// The cache is keyed by glob pattern and patterns flow in from two sources: - /// admin-controlled API-key constraints (naturally bounded) and the - /// client-supplied DiscoverHierarchyRequest.TagNameGlob (unbounded — a - /// client can iterate through generated names and create millions of distinct - /// globs over the process lifetime). Capping the cache bounds memory while - /// keeping the hot working set hit-cached. - /// - internal const int RegexCacheCapacity = 256; - - /// - /// Bounded compiled-regex cache keyed by glob pattern. IsMatch is called - /// once per object per DiscoverHierarchy/WatchDeployEvents - /// evaluation, so the same handful of glob patterns are translated - /// repeatedly; caching avoids rebuilding and recompiling the regex on every - /// call. Beyond entries the oldest insertion - /// is evicted so a client cannot grow the cache without bound by submitting - /// unique patterns. Eviction is approximate (FIFO over insertion order, not - /// true LRU) because we only need the bound, not exact recency tracking. - /// - private static readonly ConcurrentDictionary RegexCache = new(StringComparer.Ordinal); - - /// - /// Insertion-order queue used to evict the oldest cache entry when the cache - /// exceeds . A separate queue keeps the - /// reads lock-free; the lock below only guards the - /// eviction path. - /// - private static readonly ConcurrentQueue InsertionOrder = new(); - private static readonly object EvictionLock = new(); - - /// - /// Current cache size, exposed for tests asserting the cap is honoured. - /// - internal static int CurrentCacheSize => RegexCache.Count; - - /// Determines whether a value matches a glob pattern (with * and ? wildcards). - /// The value to test against the glob pattern. - /// The glob pattern with * and ? wildcards. - public static bool IsMatch(string value, string glob) - { - if (string.IsNullOrWhiteSpace(glob)) - { - return true; - } - - return GetOrCreateRegex(glob).IsMatch(value ?? string.Empty); - } - - private static Regex GetOrCreateRegex(string glob) - { - if (RegexCache.TryGetValue(glob, out Regex? existing)) - { - return existing; - } - - Regex compiled = new( - BuildRegex(glob), - RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); - - // GetOrAdd atomically returns whichever instance is in the cache after the - // call — either the locally-compiled regex (we won the race) or the regex - // another thread inserted (we lost). It also avoids the TryAdd-then-indexer - // pattern where the key could be evicted between the failed TryAdd and the - // indexer read, producing a KeyNotFoundException under contention near the - // cap (Server-024). - Regex result = RegexCache.GetOrAdd(glob, compiled); - if (ReferenceEquals(result, compiled)) - { - // We were the inserter — track for FIFO eviction and bound the cache. - InsertionOrder.Enqueue(glob); - EvictIfOverCapacity(); - } - return result; - } - - private static void EvictIfOverCapacity() - { - if (RegexCache.Count <= RegexCacheCapacity) - { - return; - } - - // Serialize eviction so two threads do not race past the cap together. - lock (EvictionLock) - { - while (RegexCache.Count > RegexCacheCapacity && InsertionOrder.TryDequeue(out string? oldest)) - { - RegexCache.TryRemove(oldest, out _); - } - } - } - - private static string BuildRegex(string glob) - { - StringBuilder builder = new("^", glob.Length + 2); - foreach (char character in glob) - { - switch (character) - { - case '*': - builder.Append(".*"); - break; - case '?': - builder.Append('.'); - break; - default: - builder.Append(Regex.Escape(character.ToString())); - break; - } - } - - builder.Append('$'); - return builder.ToString(); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs deleted file mode 100644 index 8bafe52..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs +++ /dev/null @@ -1,489 +0,0 @@ -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Grpc; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same -/// entry — the materialized is produced once per -/// refresh and reused across requests. Refreshes are deploy-time gated: every tick -/// queries galaxy.time_of_last_deploy (cheap), and the heavy hierarchy + -/// attributes rowsets are pulled only when that timestamp has advanced. -/// Each successful heavy refresh is persisted to disk through -/// ; the first refresh restores that -/// snapshot (as ) so clients can browse -/// last-known data when the Galaxy database is unreachable on a cold start. -/// -public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache -{ - private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5); - - private readonly IGalaxyRepository _repository; - private readonly IGalaxyDeployNotifier _notifier; - private readonly IGalaxyHierarchySnapshotStore? _snapshotStore; - private readonly TimeProvider _timeProvider; - private readonly ILogger? _logger; - private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly SemaphoreSlim _refreshGate = new(1, 1); - private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty; - private bool _restoreAttempted; - - /// Initializes a new instance of the class. - /// Galaxy Repository client for SQL queries. - /// Galaxy deploy event notifier. - /// Provider for current time; defaults to system time. - /// Optional logger for diagnostic output. - /// - /// Optional on-disk snapshot store. When supplied, the cache persists each - /// successful refresh and restores the last snapshot on first load. - /// - public GalaxyHierarchyCache( - IGalaxyRepository repository, - IGalaxyDeployNotifier notifier, - TimeProvider? timeProvider = null, - ILogger? logger = null, - IGalaxyHierarchySnapshotStore? snapshotStore = null) - { - _repository = repository; - _notifier = notifier; - _timeProvider = timeProvider ?? TimeProvider.System; - _logger = logger; - _snapshotStore = snapshotStore; - } - - /// Gets the current Galaxy hierarchy cache entry with projected status. - public GalaxyHierarchyCacheEntry Current - { - get - { - GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current); - GalaxyCacheStatus projected = ProjectStatus(snapshot); - return projected == snapshot.Status - ? snapshot - : snapshot with - { - Status = projected, - DashboardSummary = snapshot.DashboardSummary with - { - Status = MapDashboardStatus(projected), - }, - }; - } - } - - /// Refreshes the Galaxy hierarchy cache if the deploy time has advanced. - /// Token to cancel the asynchronous operation. - /// Asynchronous task representing the refresh operation. - public async Task RefreshAsync(CancellationToken cancellationToken) - { - await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await RefreshCoreAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - _refreshGate.Release(); - } - } - - /// Waits for the Galaxy hierarchy cache to complete its first load. - /// Token to cancel the asynchronous operation. - /// Asynchronous task representing the wait operation. - public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) - { - return _firstLoad.Task.WaitAsync(cancellationToken); - } - - private async Task RefreshCoreAsync(CancellationToken cancellationToken) - { - // First refresh only: seed the cache from the on-disk snapshot before - // querying SQL, so a cold start with an unreachable Galaxy database can - // still serve last-known browse data. Runs under the refresh gate. - if (!_restoreAttempted) - { - _restoreAttempted = true; - await TryRestoreFromDiskAsync(cancellationToken).ConfigureAwait(false); - } - - GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current); - DateTimeOffset queriedAt = _timeProvider.GetUtcNow(); - - try - { - DateTime? deployRaw = await _repository.GetLastDeployTimeAsync(cancellationToken).ConfigureAwait(false); - DateTimeOffset? deployTime = deployRaw.HasValue - ? new DateTimeOffset(DateTime.SpecifyKind(deployRaw.Value, DateTimeKind.Utc)) - : null; - - bool hasPriorData = previous.HasData; - bool deployChanged = !hasPriorData || deployTime != previous.LastDeployTime; - - if (!deployChanged) - { - // No deploy change — skip heavy queries; just bump LastSuccessAt. - GalaxyHierarchyCacheEntry refreshed = previous with - { - Status = GalaxyCacheStatus.Healthy, - LastQueriedAt = queriedAt, - LastSuccessAt = queriedAt, - LastError = null, - DashboardSummary = previous.DashboardSummary with - { - Status = DashboardGalaxyStatus.Healthy, - LastQueriedAt = queriedAt, - LastSuccessAt = queriedAt, - LastDeployTime = deployTime, - LastError = null, - }, - }; - Volatile.Write(ref _current, refreshed); - _firstLoad.TrySetResult(); - return; - } - - Task> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken); - Task> attributesTask = _repository.GetAttributesAsync(cancellationToken); - await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false); - - List hierarchy = hierarchyTask.Result; - List attributes = attributesTask.Result; - - long nextSequence = previous.Sequence + 1; - GalaxyHierarchyCacheEntry next = BuildEntry( - status: GalaxyCacheStatus.Healthy, - sequence: nextSequence, - lastQueriedAt: queriedAt, - lastSuccessAt: queriedAt, - lastDeployTime: deployTime, - lastError: null, - hierarchy: hierarchy, - attributes: attributes); - - Volatile.Write(ref _current, next); - _firstLoad.TrySetResult(); - - _notifier.Publish(new GalaxyDeployEventInfo( - Sequence: nextSequence, - ObservedAt: queriedAt, - TimeOfLastDeploy: deployTime, - ObjectCount: hierarchy.Count, - AttributeCount: attributes.Count)); - - await PersistSnapshotAsync(deployTime, queriedAt, hierarchy, attributes, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - // Catch every non-cancellation failure — not just SqlException / - // InvalidOperationException. A TimeoutException or Win32Exception - // from connection establishment, or another DbException subtype, - // must still degrade gracefully to Stale/Unavailable and complete - // _firstLoad rather than escape and fault the refresh BackgroundService. - _logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed."); - GalaxyHierarchyCacheEntry failed = previous with - { - Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable, - LastQueriedAt = queriedAt, - LastError = exception.Message, - DashboardSummary = previous.DashboardSummary with - { - Status = MapDashboardStatus(previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable), - LastQueriedAt = queriedAt, - LastError = exception.Message, - }, - }; - Volatile.Write(ref _current, failed); - _firstLoad.TrySetResult(); - } - } - - /// - /// Materializes a complete from raw - /// hierarchy and attribute rowsets. Shared by the live refresh path and the - /// on-disk restore path so both produce an identical object list, index, and - /// dashboard summary. - /// - private static GalaxyHierarchyCacheEntry BuildEntry( - GalaxyCacheStatus status, - long sequence, - DateTimeOffset? lastQueriedAt, - DateTimeOffset? lastSuccessAt, - DateTimeOffset? lastDeployTime, - string? lastError, - IReadOnlyList hierarchy, - IReadOnlyList attributes) - { - IReadOnlyList objects = BuildObjects(hierarchy, attributes); - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects); - - int areaCount = hierarchy.Count(row => row.IsArea); - int historized = attributes.Count(row => row.IsHistorized); - int alarms = attributes.Count(row => row.IsAlarm); - DashboardGalaxySummary dashboardSummary = BuildDashboardSummary( - status: status, - lastQueriedAt: lastQueriedAt, - lastSuccessAt: lastSuccessAt, - lastDeployTime: lastDeployTime, - lastError: lastError, - hierarchy: hierarchy, - objectCount: hierarchy.Count, - areaCount: areaCount, - attributeCount: attributes.Count, - historizedAttributeCount: historized, - alarmAttributeCount: alarms); - - return new GalaxyHierarchyCacheEntry( - Status: status, - Sequence: sequence, - LastQueriedAt: lastQueriedAt, - LastSuccessAt: lastSuccessAt, - LastDeployTime: lastDeployTime, - LastError: lastError, - Objects: objects, - Index: index, - DashboardSummary: dashboardSummary, - ObjectCount: hierarchy.Count, - AreaCount: areaCount, - AttributeCount: attributes.Count, - HistorizedAttributeCount: historized, - AlarmAttributeCount: alarms); - } - - /// - /// Seeds the cache from the on-disk snapshot when no live data has loaded yet. - /// The restored entry is marked — it is - /// last-known data, not live. A later refresh that observes the same deploy - /// time promotes it to healthy; one that observes a newer deploy replaces it. - /// - private async Task TryRestoreFromDiskAsync(CancellationToken cancellationToken) - { - if (_snapshotStore is null) - { - return; - } - - if (Volatile.Read(ref _current).HasData) - { - return; - } - - GalaxyHierarchySnapshot? snapshot; - try - { - snapshot = await _snapshotStore.TryLoadAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - _logger?.LogWarning(exception, "Failed to restore the Galaxy hierarchy from the on-disk snapshot."); - return; - } - - if (snapshot is null) - { - return; - } - - long sequence = Volatile.Read(ref _current).Sequence + 1; - GalaxyHierarchyCacheEntry restored = BuildEntry( - status: GalaxyCacheStatus.Stale, - sequence: sequence, - lastQueriedAt: snapshot.SavedAt, - lastSuccessAt: snapshot.SavedAt, - lastDeployTime: snapshot.LastDeployTime, - lastError: null, - hierarchy: snapshot.Hierarchy, - attributes: snapshot.Attributes); - Volatile.Write(ref _current, restored); - - // Restored data is a valid completed first load: unblock callers waiting on - // the bootstrap gate immediately, rather than making them wait out the full - // wait budget for a live query that — when the database is unreachable, the - // scenario this restore exists for — may not return for seconds. - _firstLoad.TrySetResult(); - - _notifier.Publish(new GalaxyDeployEventInfo( - Sequence: sequence, - ObservedAt: _timeProvider.GetUtcNow(), - TimeOfLastDeploy: snapshot.LastDeployTime, - ObjectCount: snapshot.Hierarchy.Count, - AttributeCount: snapshot.Attributes.Count)); - - _logger?.LogInformation( - "Restored Galaxy hierarchy from on-disk snapshot saved {SavedAt:o}: {ObjectCount} objects, {AttributeCount} attributes (status Stale until the Galaxy database confirms).", - snapshot.SavedAt, - snapshot.Hierarchy.Count, - snapshot.Attributes.Count); - } - - /// - /// Persists a successful refresh to disk. Persistence failures are logged and - /// swallowed — a cache that cannot write its backup is still fully usable. - /// - private async Task PersistSnapshotAsync( - DateTimeOffset? deployTime, - DateTimeOffset savedAt, - IReadOnlyList hierarchy, - IReadOnlyList attributes, - CancellationToken cancellationToken) - { - if (_snapshotStore is null) - { - return; - } - - try - { - await _snapshotStore.SaveAsync( - new GalaxyHierarchySnapshot(deployTime, savedAt, hierarchy, attributes), - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // The refresh was cancelled (gateway shutdown) before the write finished. - // That is not a persistence failure — do not log it as a warning. - } - catch (Exception exception) - { - _logger?.LogWarning(exception, "Failed to persist the Galaxy hierarchy snapshot to disk."); - } - } - - private static IReadOnlyList BuildObjects( - IReadOnlyList hierarchy, - IReadOnlyList attributes) - { - Dictionary> attributesByGobjectId = attributes - .GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - - List objects = new(hierarchy.Count); - foreach (GalaxyHierarchyRow row in hierarchy) - { - objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId)); - } - return objects; - } - - private static DashboardGalaxySummary BuildDashboardSummary( - GalaxyCacheStatus status, - DateTimeOffset? lastQueriedAt, - DateTimeOffset? lastSuccessAt, - DateTimeOffset? lastDeployTime, - string? lastError, - IReadOnlyList hierarchy, - int objectCount, - int areaCount, - int attributeCount, - int historizedAttributeCount, - int alarmAttributeCount) - { - IReadOnlyList topTemplates; - IReadOnlyList objectCategories; - - if (hierarchy.Count == 0) - { - topTemplates = Array.Empty(); - objectCategories = Array.Empty(); - } - else - { - Dictionary objectsByCategory = new(); - Dictionary templateUsage = new(StringComparer.OrdinalIgnoreCase); - - foreach (GalaxyHierarchyRow row in hierarchy) - { - objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount); - objectsByCategory[row.CategoryId] = categoryCount + 1; - - if (row.TemplateChain.Count > 0) - { - string immediate = row.TemplateChain[0]; - if (!string.IsNullOrWhiteSpace(immediate)) - { - templateUsage.TryGetValue(immediate, out int templateCount); - templateUsage[immediate] = templateCount + 1; - } - } - } - - topTemplates = templateUsage - .OrderByDescending(entry => entry.Value) - .ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase) - .Take(10) - .Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value)) - .ToArray(); - - objectCategories = objectsByCategory - .OrderByDescending(entry => entry.Value) - .ThenBy(entry => entry.Key) - .Select(entry => new DashboardGalaxyCategoryCount( - entry.Key, - ResolveCategoryName(entry.Key), - entry.Value)) - .ToArray(); - } - - return new DashboardGalaxySummary( - Status: MapDashboardStatus(status), - LastQueriedAt: lastQueriedAt, - LastSuccessAt: lastSuccessAt, - LastDeployTime: lastDeployTime, - LastError: lastError, - ObjectCount: objectCount, - AreaCount: areaCount, - AttributeCount: attributeCount, - HistorizedAttributeCount: historizedAttributeCount, - AlarmAttributeCount: alarmAttributeCount, - TopTemplates: topTemplates, - ObjectCategories: 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}", - }; - - private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot) - { - if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable) - { - return snapshot.Status; - } - - if (snapshot.LastSuccessAt is { } success - && _timeProvider.GetUtcNow() - success > StaleThreshold) - { - return GalaxyCacheStatus.Stale; - } - - return snapshot.Status; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs deleted file mode 100644 index 2db9129..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs +++ /dev/null @@ -1,46 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Dashboard; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// Immutable snapshot of the Galaxy Repository browse data held by -/// . Multiple gRPC clients share the same -/// materialized object list and precomputed dashboard projection. -/// -public sealed record GalaxyHierarchyCacheEntry( - GalaxyCacheStatus Status, - long Sequence, - DateTimeOffset? LastQueriedAt, - DateTimeOffset? LastSuccessAt, - DateTimeOffset? LastDeployTime, - string? LastError, - IReadOnlyList Objects, - GalaxyHierarchyIndex Index, - DashboardGalaxySummary DashboardSummary, - int ObjectCount, - int AreaCount, - int AttributeCount, - int HistorizedAttributeCount, - int AlarmAttributeCount) -{ - /// Gets an empty Galaxy hierarchy cache entry. - public static GalaxyHierarchyCacheEntry Empty { get; } = new( - Status: GalaxyCacheStatus.Unknown, - Sequence: 0, - LastQueriedAt: null, - LastSuccessAt: null, - LastDeployTime: null, - LastError: null, - Objects: Array.Empty(), - Index: GalaxyHierarchyIndex.Empty, - DashboardSummary: DashboardGalaxySummary.Unknown, - ObjectCount: 0, - AreaCount: 0, - AttributeCount: 0, - HistorizedAttributeCount: 0, - AlarmAttributeCount: 0); - - /// Gets a value indicating whether the cache entry contains usable data. - public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale; -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs deleted file mode 100644 index 676244d..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs +++ /dev/null @@ -1,200 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -public sealed class GalaxyHierarchyIndex -{ - private GalaxyHierarchyIndex( - IReadOnlyList objectViews, - IReadOnlyDictionary objectViewsById, - IReadOnlyDictionary tagsByAddress, - IReadOnlyDictionary> childrenByParent, - IReadOnlyDictionary objectViewsByTagName, - IReadOnlyDictionary objectViewsByContainedPath) - { - ObjectViews = objectViews; - ObjectViewsById = objectViewsById; - TagsByAddress = tagsByAddress; - ChildrenByParent = childrenByParent; - ObjectViewsByTagName = objectViewsByTagName; - ObjectViewsByContainedPath = objectViewsByContainedPath; - } - - /// Gets an empty Galaxy hierarchy index. - public static GalaxyHierarchyIndex Empty { get; } = new( - Array.Empty(), - new Dictionary(), - new Dictionary(StringComparer.OrdinalIgnoreCase), - new Dictionary>(), - new Dictionary(StringComparer.OrdinalIgnoreCase), - new Dictionary(StringComparer.OrdinalIgnoreCase)); - - /// Gets the object views. - public IReadOnlyList ObjectViews { get; } - - /// Gets the object views indexed by GUID. - public IReadOnlyDictionary ObjectViewsById { get; } - - /// Gets tags indexed by address. - public IReadOnlyDictionary TagsByAddress { get; } - - /// Gets direct children grouped by parent gobject id. Root objects (no parent, or self-parented) live under key 0. Each list is sorted areas-first, then by display name (OrdinalIgnoreCase). - public IReadOnlyDictionary> ChildrenByParent { get; } - - /// Gets object views indexed by (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by tag name in O(1) instead of scanning . - public IReadOnlyDictionary ObjectViewsByTagName { get; } - - /// Gets object views indexed by contained path (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by path in O(1) instead of scanning . - public IReadOnlyDictionary ObjectViewsByContainedPath { get; } - - /// Builds a Galaxy hierarchy index from the given objects. - /// The Galaxy objects to index. - /// A new Galaxy hierarchy index. - public static GalaxyHierarchyIndex Build(IReadOnlyList objects) - { - if (objects.Count == 0) - { - return Empty; - } - - Dictionary objectsById = new(); - foreach (GalaxyObject obj in objects) - { - objectsById.TryAdd(obj.GobjectId, obj); - } - - List views = new(objects.Count); - Dictionary viewsById = new(); - Dictionary tagsByAddress = new(StringComparer.OrdinalIgnoreCase); - Dictionary viewsByTagName = new(StringComparer.OrdinalIgnoreCase); - Dictionary viewsByContainedPath = new(StringComparer.OrdinalIgnoreCase); - - foreach (GalaxyObject obj in objects) - { - string path = BuildContainedPath(obj, objectsById); - int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/'); - GalaxyObjectView view = new(obj, path, depth); - views.Add(view); - viewsById.TryAdd(obj.GobjectId, view); - - if (!string.IsNullOrWhiteSpace(obj.TagName)) - { - tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path)); - viewsByTagName.TryAdd(obj.TagName, view); - } - - if (!string.IsNullOrWhiteSpace(path)) - { - viewsByContainedPath.TryAdd(path, view); - } - - foreach (GalaxyAttribute attribute in obj.Attributes) - { - if (!string.IsNullOrWhiteSpace(attribute.FullTagReference)) - { - tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path)); - } - } - } - - Dictionary> childrenByParent = new(); - foreach (GalaxyObjectView view in views) - { - int parentKey = view.Object.ParentGobjectId; - // Treat self-parented (corrupt) rows as roots; matches DashboardBrowseTreeBuilder. - if (parentKey == view.Object.GobjectId) - { - parentKey = 0; - } - // Re-root orphans whose parent object is absent from the set (e.g. a deleted or - // never-loaded container area). Otherwise they bucket under a phantom parent id - // that is never reached from the root, so they vanish from browse entirely. - else if (parentKey != 0 && !objectsById.ContainsKey(parentKey)) - { - parentKey = 0; - } - if (!childrenByParent.TryGetValue(parentKey, out List? bucket)) - { - bucket = []; - childrenByParent[parentKey] = bucket; - } - bucket.Add(view); - } - - foreach (List bucket in childrenByParent.Values) - { - bucket.Sort(CompareByAreaThenDisplayName); - } - - Dictionary> readOnlyChildren = new(childrenByParent.Count); - foreach (KeyValuePair> kvp in childrenByParent) - { - readOnlyChildren[kvp.Key] = kvp.Value; - } - - return new GalaxyHierarchyIndex( - views, - viewsById, - tagsByAddress, - readOnlyChildren, - viewsByTagName, - viewsByContainedPath); - } - - private static string BuildContainedPath( - GalaxyObject obj, - IReadOnlyDictionary objectsById) - { - Stack names = new(); - HashSet seen = []; - GalaxyObject? current = obj; - while (current is not null && seen.Add(current.GobjectId)) - { - names.Push(ResolvePathSegment(current)); - current = current.ParentGobjectId != 0 - && objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent) - ? parent - : null; - } - - return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name))); - } - - private static string ResolvePathSegment(GalaxyObject obj) - { - if (!string.IsNullOrWhiteSpace(obj.ContainedName)) - { - return obj.ContainedName; - } - - if (!string.IsNullOrWhiteSpace(obj.BrowseName)) - { - return obj.BrowseName; - } - - return obj.TagName; - } - - private static int CompareByAreaThenDisplayName(GalaxyObjectView left, GalaxyObjectView right) - { - if (left.Object.IsArea != right.Object.IsArea) - { - return left.Object.IsArea ? -1 : 1; - } - return string.Compare(DisplayNameOf(left), DisplayNameOf(right), StringComparison.OrdinalIgnoreCase); - } - - private static string DisplayNameOf(GalaxyObjectView view) - { - GalaxyObject obj = view.Object; - if (!string.IsNullOrWhiteSpace(obj.BrowseName)) - { - return obj.BrowseName; - } - if (!string.IsNullOrWhiteSpace(obj.ContainedName)) - { - return obj.ContainedName; - } - return obj.TagName; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs deleted file mode 100644 index d74df56..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs +++ /dev/null @@ -1,311 +0,0 @@ -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); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs deleted file mode 100644 index 66ce19c..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -public sealed record GalaxyHierarchyQueryResult( - IReadOnlyList Objects, - int TotalObjectCount, - string FilterSignature); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs deleted file mode 100644 index 9a82506..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path. -public sealed class GalaxyHierarchyRefreshService( - IGalaxyHierarchyCache cache, - IOptions options, - ILogger logger, - TimeProvider? timeProvider = null) : BackgroundService -{ - private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds)); - - try - { - await cache.RefreshAsync(stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - return; - } - catch (Exception exception) - { - // A transient first-load failure (e.g. a TimeoutException or - // Win32Exception from connection establishment, or a DbException - // subtype the cache does not catch) must not fault this - // BackgroundService and stop the whole gateway. The cache records - // its own Unavailable/Stale status; the periodic tick below retries. - logger.LogWarning(exception, "Initial Galaxy hierarchy cache load failed; will retry on the refresh interval."); - } - - using PeriodicTimer timer = new(interval, _timeProvider); - try - { - while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) - { - try - { - await cache.RefreshAsync(stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - return; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Galaxy hierarchy cache refresh tick failed."); - } - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - } - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs deleted file mode 100644 index 08d345b..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// One row from : a deployed Galaxy -/// gobject with its hierarchy parent and template-derivation chain. -/// -public sealed class GalaxyHierarchyRow -{ - /// Gets the Galaxy object identifier. - public int GobjectId { get; init; } - - /// Gets the tag name. - public string TagName { get; init; } = string.Empty; - - /// Gets the contained name. - public string ContainedName { get; init; } = string.Empty; - - /// Gets the browse name. - public string BrowseName { get; init; } = string.Empty; - - /// Gets the parent Galaxy object identifier. - public int ParentGobjectId { get; init; } - - /// Gets a value indicating whether this is an area. - public bool IsArea { get; init; } - - /// Gets the category identifier. - public int CategoryId { get; init; } - - /// Gets the Galaxy object identifier of the host. - public int HostedByGobjectId { get; init; } - - /// Gets the template derivation chain. - public IReadOnlyList TemplateChain { get; init; } = Array.Empty(); -} - -/// One row from . -public sealed class GalaxyAttributeRow -{ - /// Gets the Galaxy object identifier. - public int GobjectId { get; init; } - - /// Gets the tag name. - public string TagName { get; init; } = string.Empty; - - /// Gets the attribute name. - public string AttributeName { get; init; } = string.Empty; - - /// Gets the full tag reference. - public string FullTagReference { get; init; } = string.Empty; - - /// Gets the MXAccess data type code. - public int MxDataType { get; init; } - - /// Gets the data type name. - public string? DataTypeName { get; init; } - - /// Gets a value indicating whether this is an array. - public bool IsArray { get; init; } - - /// Gets the array dimension, if applicable. - public int? ArrayDimension { get; init; } - - /// Gets the MXAccess attribute category code. - public int MxAttributeCategory { get; init; } - - /// Gets the security classification code. - public int SecurityClassification { get; init; } - - /// Gets a value indicating whether this is historized. - public bool IsHistorized { get; init; } - - /// Gets a value indicating whether this is an alarm. - public bool IsAlarm { get; init; } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshot.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshot.cs deleted file mode 100644 index d1879a7..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshot.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// A serializable point-in-time copy of the Galaxy Repository browse data. -/// Holds the raw hierarchy and attribute rowsets — not the materialized -/// protobuf objects — so the restore path runs the exact same -/// materialization as a live refresh. Persisted by -/// after a successful refresh -/// and reloaded at startup when the Galaxy database is unreachable. -/// -/// -/// The galaxy.time_of_last_deploy the rowsets were pulled at, or -/// when the Galaxy table reported no deploy. A later -/// live refresh that observes this same timestamp can promote the restored -/// entry to healthy without re-running the heavy queries. -/// -/// UTC wall-clock when the snapshot was written to disk. -/// The persisted object-hierarchy rowset. -/// The persisted attribute rowset. -public sealed record GalaxyHierarchySnapshot( - DateTimeOffset? LastDeployTime, - DateTimeOffset SavedAt, - IReadOnlyList Hierarchy, - IReadOnlyList Attributes); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs deleted file mode 100644 index 7054af1..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchySnapshotStore.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// JSON-file implementation of . -/// Writes the on-disk snapshot atomically (temp file + rename) so a crash -/// mid-write can never leave a torn file, and ignores files whose schema -/// version it does not recognize. When -/// is -/// both operations are no-ops. -/// -public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore -{ - /// - /// On-disk format version. Bump this whenever the persisted shape changes - /// in a way an older or newer gateway cannot read; a mismatched file is - /// ignored rather than misparsed. - /// - private const int CurrentSchemaVersion = 1; - - private static readonly JsonSerializerOptions SerializerOptions = new() - { - WriteIndented = false, - }; - - private readonly string? _path; - private readonly TimeSpan _writeTimeout; - private readonly ILogger? _logger; - private readonly SemaphoreSlim _ioGate = new(1, 1); - - /// Initializes a new instance of the class. - /// Galaxy repository options carrying the snapshot path and enable flag. - /// Optional logger for diagnostic output. - public GalaxyHierarchySnapshotStore( - IOptions options, - ILogger? logger = null) - { - GalaxyRepositoryOptions value = options.Value; - _path = value.PersistSnapshot && !string.IsNullOrWhiteSpace(value.SnapshotCachePath) - ? value.SnapshotCachePath - : null; - _writeTimeout = TimeSpan.FromSeconds(Math.Max(1, value.CommandTimeoutSeconds)); - _logger = logger; - } - - /// - public async Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(snapshot); - if (_path is null) - { - return; - } - - PersistedFile file = new(CurrentSchemaVersion, snapshot); - - await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Bound the write so a stuck disk — e.g. a SnapshotCachePath on an - // unresponsive network share — cannot stall the caller. On the cache - // refresh path that would otherwise pin the whole refresh loop. - using CancellationTokenSource writeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - writeCts.CancelAfter(_writeTimeout); - - string? directory = Path.GetDirectoryName(_path); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - string tempPath = _path + ".tmp"; - await using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await JsonSerializer.SerializeAsync(stream, file, SerializerOptions, writeCts.Token).ConfigureAwait(false); - } - - File.Move(tempPath, _path, overwrite: true); - _logger?.LogDebug( - "Persisted Galaxy hierarchy snapshot to {Path} ({ObjectCount} objects, {AttributeCount} attributes).", - _path, - snapshot.Hierarchy.Count, - snapshot.Attributes.Count); - } - finally - { - _ioGate.Release(); - } - } - - /// - public async Task TryLoadAsync(CancellationToken cancellationToken) - { - if (_path is null || !File.Exists(_path)) - { - return null; - } - - await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - PersistedFile? file; - await using (FileStream stream = new(_path, FileMode.Open, FileAccess.Read, FileShare.Read)) - { - file = await JsonSerializer.DeserializeAsync( - stream, SerializerOptions, cancellationToken).ConfigureAwait(false); - } - - if (file is null || file.SchemaVersion != CurrentSchemaVersion || file.Snapshot is null) - { - _logger?.LogWarning( - "Ignoring Galaxy hierarchy snapshot at {Path}: unrecognized or empty schema version.", - _path); - return null; - } - - return file.Snapshot; - } - catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException) - { - // A corrupt, truncated, locked, or access-denied snapshot file is an - // expected failure mode for a disk cache — honor the Try contract and - // return null rather than throwing. - _logger?.LogWarning( - exception, - "Ignoring Galaxy hierarchy snapshot at {Path}: the file is unreadable or not valid JSON.", - _path); - return null; - } - finally - { - _ioGate.Release(); - } - } - - /// On-disk envelope: a schema version plus the snapshot payload. - private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyObjectView.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyObjectView.cs deleted file mode 100644 index 626ebaf..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyObjectView.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -public sealed record GalaxyObjectView( - GalaxyObject Object, - string ContainedPath, - int Depth); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs deleted file mode 100644 index aad3183..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs +++ /dev/null @@ -1,372 +0,0 @@ -using Microsoft.Data.SqlClient; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. -/// -/// is still the query originally ported from the OtOpcUa -/// project. has diverged: it additionally enumerates the -/// built-in attributes contributed by each object's primitives (from -/// attribute_definition via primitive_instance), so engine/platform objects -/// and extension sub-attributes (e.g. TestAlarm001.Acked) are surfaced. The -/// OtOpcUa query is not kept in sync — see docs/GalaxyRepository.md. -/// -/// -public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository -{ - /// Tests the connection to the Galaxy Repository database. - /// Token to cancel the asynchronous operation. - public async Task TestConnectionAsync(CancellationToken ct = default) - { - try - { - using SqlConnection conn = new(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds }; - object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - return result is int i && i == 1; - } - catch (SqlException) { return false; } - catch (InvalidOperationException) { return false; } - } - - /// Retrieves the last deployment time from the Galaxy Repository. - /// Token to cancel the asynchronous operation. - public async Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - using SqlConnection conn = new(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn) - { CommandTimeout = options.CommandTimeoutSeconds }; - object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - return result is DateTime dt ? dt : null; - } - - /// Retrieves the complete hierarchy of Galaxy objects from the repository. - /// Token to cancel the asynchronous operation. - public async Task> GetHierarchyAsync(CancellationToken ct = default) - { - List rows = new(); - - using SqlConnection conn = new(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - - using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; - using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - - while (await reader.ReadAsync(ct).ConfigureAwait(false)) - { - string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8); - string[] templateChain = templateChainRaw.Length == 0 - ? Array.Empty() - : templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Where(s => s.Length > 0) - .ToArray(); - - rows.Add(new GalaxyHierarchyRow - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), - BrowseName = reader.GetString(3), - ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), - IsArea = Convert.ToInt32(reader.GetValue(5)) == 1, - CategoryId = Convert.ToInt32(reader.GetValue(6)), - HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)), - TemplateChain = templateChain, - }); - } - return rows; - } - - /// Retrieves all attributes for Galaxy objects from the repository. - /// Token to cancel the asynchronous operation. - public async Task> GetAttributesAsync(CancellationToken ct = default) - { - List rows = new(); - - using SqlConnection conn = new(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - - using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; - using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - - while (await reader.ReadAsync(ct).ConfigureAwait(false)) - { - rows.Add(new GalaxyAttributeRow - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - AttributeName = reader.GetString(2), - FullTagReference = reader.GetString(3), - MxDataType = Convert.ToInt32(reader.GetValue(4)), - DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5), - IsArray = Convert.ToInt32(reader.GetValue(6)) == 1, - ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)), - MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)), - SecurityClassification = Convert.ToInt32(reader.GetValue(9)), - IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, - IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1, - }); - } - return rows; - } - - /// - /// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list. - /// Alarm detection is identical to : a row is - /// alarm-bearing when its owning object configures an AlarmExtension - /// primitive (the same is_alarm projection, here applied as a SQL filter). - /// - /// Token to cancel the asynchronous operation. - public async Task> GetAlarmAttributesAsync(CancellationToken ct = default) - { - List rows = new(); - - using SqlConnection conn = new(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - - using SqlCommand cmd = new(AlarmAttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; - using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - - while (await reader.ReadAsync(ct).ConfigureAwait(false)) - { - rows.Add(MapAlarmRow( - fullTagReference: reader.GetString(0), - sourceObjectReference: reader.GetString(1), - area: reader.GetString(2))); - } - return rows; - } - - /// - /// Maps a raw alarm-attribute reader row to a . - /// - /// is the Galaxy tag_name (the - /// owning object), and is - /// tag_name + '.' + attribute_name — the same composition the - /// full_tag_reference projection of produces. - /// is left empty here; the - /// schema does not expose an ack-comment address and the watch-list resolver - /// composes it later. - /// - /// is the owning object's real Galaxy area (its alarm - /// group), resolved via gobject.area_gobject_id; the watch-list resolver - /// composes the canonical reference from it so the synthesized reference's group - /// matches what the native alarmmgr (wnwrap) emits. - /// Exposed internally so the derivation can be unit-tested without a database. - /// - /// The alarm-bearing attribute reference. - /// The owning object reference (tag name). - /// The owning object's Galaxy area (the alarm group). - internal static GalaxyAlarmAttributeRow MapAlarmRow( - string fullTagReference, - string sourceObjectReference, - string area) => new() - { - FullTagReference = fullTagReference, - SourceObjectReference = sourceObjectReference, - Area = area, - AckCommentSubtag = string.Empty, - }; - - // Area objects (category 13) are returned even when undeployed (deployed_package_id = 0): - // they are organizational/model nodes that group deployed objects, so excluding them - // orphans every area whose containing area is not itself deployed. All non-area objects - // still require deployment. Orphans left by a missing/deleted parent area are re-rooted - // by GalaxyHierarchyIndex.Build so nothing disappears from browse. - private const string HierarchySql = @" -;WITH template_chain AS ( - SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id, - t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth - FROM gobject g - INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id - WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0 - UNION ALL - SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1 - FROM template_chain tc - INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id - WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10 -) -SELECT DISTINCT - g.gobject_id, - g.tag_name, - g.contained_name, - CASE WHEN g.contained_name IS NULL OR g.contained_name = '' - THEN g.tag_name - ELSE g.contained_name - END AS browse_name, - CASE WHEN g.contained_by_gobject_id = 0 - THEN g.area_gobject_id - ELSE g.contained_by_gobject_id - END AS parent_gobject_id, - CASE WHEN td.category_id = 13 - THEN 1 - ELSE 0 - END AS is_area, - td.category_id AS category_id, - g.hosted_by_gobject_id AS hosted_by_gobject_id, - ISNULL( - STUFF(( - SELECT '|' + tc.template_tag_name - FROM template_chain tc - WHERE tc.instance_gobject_id = g.gobject_id - ORDER BY tc.depth - FOR XML PATH('') - ), 1, 1, ''), - '' - ) AS template_chain -FROM gobject g -INNER JOIN template_definition td - ON g.template_definition_id = td.template_definition_id -WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) - AND g.is_template = 0 - AND (g.deployed_package_id <> 0 OR td.category_id = 13) -ORDER BY parent_gobject_id, g.tag_name"; - - // Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two - // kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute` - // body, src_pri 0) and the built-in attributes every object inherits from its primitives - // (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in - // attributes are why engine/platform objects and extension sub-attributes such as - // `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the - // `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the - // `_`-prefix and `.Description` name exclusions apply) and are never flagged - // `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an - // extension, not the extension's machinery leaves. See docs/GalaxyRepository.md. - private const string AttributesSql = @" -;WITH deployed_package_chain AS ( - SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth - FROM gobject g - INNER JOIN package p ON p.package_id = g.deployed_package_id - WHERE g.is_template = 0 AND g.deployed_package_id <> 0 - UNION ALL - SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1 - FROM deployed_package_chain dpc - INNER JOIN package p ON p.package_id = dpc.derived_from_package_id - WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 -), -candidate AS ( - SELECT - dpc.gobject_id, g.tag_name, da.attribute_name, da.mx_data_type, da.is_array, - CASE WHEN da.is_array = 1 - THEN CONVERT(int, CONVERT(varbinary(2), - SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2)) - ELSE NULL END AS array_dimension, - da.mx_attribute_category, da.security_classification, dpc.depth, 0 AS src_pri - FROM deployed_package_chain dpc - INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id - INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id - INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id - WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) - AND da.attribute_name NOT LIKE '[_]%' - AND da.attribute_name NOT LIKE '%.Description' - AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) - UNION ALL - SELECT - dpc.gobject_id, g.tag_name, - CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = '' - THEN ad.attribute_name - ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name, - ad.mx_data_type, ad.is_array, - CASE WHEN ad.is_array = 1 - THEN CONVERT(int, CONVERT(varbinary(2), - SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2)) - ELSE NULL END AS array_dimension, - ad.mx_attribute_category, ad.security_classification, dpc.depth, 1 AS src_pri - FROM deployed_package_chain dpc - INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id - INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id - INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id - INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id - WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) - AND ad.attribute_name NOT LIKE '[_]%' - AND ad.attribute_name NOT LIKE '%.Description' -), -ranked AS ( - SELECT c.*, ROW_NUMBER() OVER ( - PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn - FROM candidate c -) -SELECT - r.gobject_id, r.tag_name, r.attribute_name, - r.tag_name + '.' + r.attribute_name - + CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference, - r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension, - r.mx_attribute_category, r.security_classification, - CASE WHEN r.src_pri = 0 AND EXISTS ( - SELECT 1 FROM deployed_package_chain dpc2 - INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name - INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension' - WHERE dpc2.gobject_id = r.gobject_id - ) THEN 1 ELSE 0 END AS is_historized, - CASE WHEN r.src_pri = 0 AND EXISTS ( - SELECT 1 FROM deployed_package_chain dpc2 - INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name - INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' - WHERE dpc2.gobject_id = r.gobject_id - ) THEN 1 ELSE 0 END AS is_alarm -FROM ranked r -LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type -WHERE r.rn = 1 -ORDER BY r.tag_name, r.attribute_name"; - - // Alarm-only discovery for the subtag-fallback watch-list. This reuses the candidate/ranked - // CTE shape and the same `AlarmExtension`-based detection as AttributesSql. Unlike - // AttributesSql it keeps only the user-attribute (dynamic_attribute) candidate branch: an - // alarm anchor is always a user attribute, so the primitive-instance branch AttributesSql - // carries would be filtered out here anyway — a row qualifies only when its user attribute - // anchors an `AlarmExtension` primitive on the owning object. It projects just what the - // watch-list needs — full_tag_reference (tag_name + - // '.' + attribute_name, matching AttributesSql) and the owning object's tag_name as - // source_object_reference. The array `[]` suffix is intentionally omitted: an - // alarm-bearing attribute is a scalar anchor, not an array body. It also projects the - // owning object's real Galaxy area (via gobject.area_gobject_id) as area_name so the - // watch-list resolver composes a reference whose group matches the native alarmmgr. - private const string AlarmAttributesSql = @" -;WITH deployed_package_chain AS ( - SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth - FROM gobject g - INNER JOIN package p ON p.package_id = g.deployed_package_id - WHERE g.is_template = 0 AND g.deployed_package_id <> 0 - UNION ALL - SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1 - FROM deployed_package_chain dpc - INNER JOIN package p ON p.package_id = dpc.derived_from_package_id - WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 -), -candidate AS ( - SELECT - dpc.gobject_id, g.tag_name, da.attribute_name, dpc.depth - FROM deployed_package_chain dpc - INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id - INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id - INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id - WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) - AND da.attribute_name NOT LIKE '[_]%' - AND da.attribute_name NOT LIKE '%.Description' - AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) -), -ranked AS ( - SELECT c.*, ROW_NUMBER() OVER ( - PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.depth) AS rn - FROM candidate c -) -SELECT - r.tag_name + '.' + r.attribute_name AS full_tag_reference, - r.tag_name AS source_object_reference, - ISNULL(area.tag_name, '') AS area_name -FROM ranked r -INNER JOIN gobject g ON g.gobject_id = r.gobject_id -LEFT JOIN gobject area ON area.gobject_id = g.area_gobject_id -WHERE r.rn = 1 - AND EXISTS ( - SELECT 1 FROM deployed_package_chain dpc2 - INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name - INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' - WHERE dpc2.gobject_id = r.gobject_id - ) -ORDER BY r.tag_name, r.attribute_name"; -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs deleted file mode 100644 index c58e43a..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// Connection settings for the AVEVA System Platform Galaxy Repository (ZB) database. -/// Bound to the MxGateway:Galaxy configuration section. -/// -public sealed class GalaxyRepositoryOptions -{ - public const string SectionName = "MxGateway:Galaxy"; - - /// - /// Default SQL Server connection string for the Galaxy Repository database. - /// Single source of truth shared with the integration-test fallback so the - /// production default and the live-test default cannot drift. - /// - public const string DefaultConnectionString = - "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; - - /// The SQL Server connection string for the Galaxy Repository database. - public string ConnectionString { get; init; } = DefaultConnectionString; - - /// The timeout in seconds for SQL commands executed against the Galaxy Repository. - public int CommandTimeoutSeconds { get; init; } = 60; - - /// - /// Interval (seconds) between background refreshes of the dashboard Galaxy summary - /// cache. SQL is hit at most once per interval regardless of dashboard render rate. - /// - public int DashboardRefreshIntervalSeconds { get; init; } = 30; - - /// Default on-disk path for the persisted Galaxy browse snapshot. - public const string DefaultSnapshotCachePath = - @"C:\ProgramData\MxGateway\galaxy-snapshot.json"; - - /// - /// Whether the gateway persists the latest successful Galaxy browse dataset to - /// disk. When enabled, the cache reloads that snapshot at startup so clients can - /// still browse last-known data while the Galaxy database is unreachable. - /// - public bool PersistSnapshot { get; init; } = true; - - /// - /// File path for the persisted Galaxy browse snapshot. Ignored when - /// is . - /// - public string SnapshotCachePath { get; init; } = DefaultSnapshotCachePath; -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs deleted file mode 100644 index c8729ff..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -public static class GalaxyRepositoryServiceCollectionExtensions -{ - /// Registers Galaxy Repository services in the dependency injection container. - /// The service collection. - /// The service collection for chaining. - public static IServiceCollection AddGalaxyRepository(this IServiceCollection services) - { - services - .AddOptions() - .BindConfiguration(GalaxyRepositoryOptions.SectionName) - .ValidateOnStart(); - - services.AddSingleton(sp => - new GalaxyRepository(sp.GetRequiredService>().Value)); - services.AddSingleton(sp => sp.GetRequiredService()); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddHostedService(); - - return services; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyTagLookup.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyTagLookup.cs deleted file mode 100644 index d8073f1..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyTagLookup.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -public sealed record GalaxyTagLookup( - GalaxyObject Object, - GalaxyAttribute? Attribute, - string ContainedPath); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs deleted file mode 100644 index 8dfd032..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// Publishes Galaxy repository deploy events to subscribers. -public interface IGalaxyDeployNotifier -{ - /// The most recently published event, or null if no event has fired yet. - GalaxyDeployEventInfo? Latest { get; } - - /// Publishes a deploy event to all current subscribers and stores it as Latest. - /// The deploy event to publish. - void Publish(GalaxyDeployEventInfo info); - - /// Subscribes to deploy events. The sequence yields the latest event first (if available) then streams new events as they fire. - /// Token to cancel the asynchronous operation. - /// Async enumerable of deploy events. - IAsyncEnumerable SubscribeAsync(CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs deleted file mode 100644 index 0737276..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// Cache for Galaxy Repository hierarchy data. -public interface IGalaxyHierarchyCache -{ - /// The latest cache entry. Status freshness is recomputed against the clock. - GalaxyHierarchyCacheEntry Current { get; } - - /// - /// Forces a refresh against the Galaxy Repository. Performs a cheap - /// time_of_last_deploy probe first and only re-queries the heavy hierarchy + - /// attributes rowsets when the deploy time has changed since the last successful - /// refresh. - /// - /// Token to cancel the asynchronous operation. - Task RefreshAsync(CancellationToken cancellationToken); - - /// - /// Awaits the first completed refresh attempt (success or failure). Useful for - /// gRPC handlers that want to serve from cache without returning Unavailable on the - /// very first request after gateway start. - /// - /// Token to cancel the asynchronous operation. - Task WaitForFirstLoadAsync(CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchySnapshotStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchySnapshotStore.cs deleted file mode 100644 index 53556d5..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyHierarchySnapshotStore.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// Persists the latest Galaxy Repository browse dataset to disk and reloads -/// it at startup. Lets serve last-known -/// browse data when the Galaxy database is unreachable on a cold start. -/// -public interface IGalaxyHierarchySnapshotStore -{ - /// - /// Writes to disk, replacing any previous - /// snapshot atomically. A no-op when snapshot persistence is disabled. - /// - /// The browse dataset to persist. - /// Token to cancel the asynchronous operation. - Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken); - - /// - /// Reads the persisted Galaxy browse dataset. - /// - /// Token to cancel the asynchronous operation. - /// - /// The persisted snapshot, or when none exists, - /// persistence is disabled, or the on-disk file uses an unrecognized - /// schema version. - /// - Task TryLoadAsync(CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs deleted file mode 100644 index 73fb8d2..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Galaxy; - -/// -/// Abstraction over consumed by -/// . Exists so the cache can be unit-tested -/// against an in-memory fake that throws a -/// from (the unavailable-backend code -/// path) without standing up a real Microsoft.Data.SqlClient -/// SqlConnection against a bogus host/port. The production gateway -/// wires the concrete ; the SQL surface itself -/// stays covered by ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy.GalaxyRepositoryLiveTests. -/// -public interface IGalaxyRepository -{ - /// Tests the connection to the Galaxy Repository database. - /// Token to cancel the asynchronous operation. - Task TestConnectionAsync(CancellationToken ct = default); - - /// Retrieves the last deployment time from the Galaxy Repository. - /// Token to cancel the asynchronous operation. - Task GetLastDeployTimeAsync(CancellationToken ct = default); - - /// Retrieves the complete hierarchy of Galaxy objects from the repository. - /// Token to cancel the asynchronous operation. - Task> GetHierarchyAsync(CancellationToken ct = default); - - /// Retrieves all attributes for Galaxy objects from the repository. - /// Token to cancel the asynchronous operation. - Task> GetAttributesAsync(CancellationToken ct = default); - - /// - /// Retrieves only the alarm-bearing attributes (those whose owning object - /// configures an AlarmExtension primitive) for building the - /// subtag-fallback watch-list. - /// - /// Token to cancel the asynchronous operation. - Task> GetAlarmAttributesAsync(CancellationToken ct = default); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index 82a68db..3dda7f5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -2,13 +2,13 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Configuration; +using ZB.MOM.WW.GalaxyRepository.DependencyInjection; using ZB.MOM.WW.Health; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Diagnostics; -using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Grpc; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; @@ -92,7 +92,11 @@ public static class GatewayApplication builder.Services.AddGatewaySessions(); builder.Services.AddGatewayAlarms(); builder.Services.AddGatewayDashboard(builder.Configuration); - builder.Services.AddGalaxyRepository(); + // Register the gateway's browse-scope provider before AddZbGalaxyRepository so the + // library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win. + builder.Services.AddSingleton(); + builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy"); return builder; } @@ -193,7 +197,7 @@ public static class GatewayApplication endpoints.MapZbMetrics(); endpoints.MapGrpcService(); - endpoints.MapGrpcService(); + endpoints.MapZbGalaxyRepository(); endpoints.MapGatewayDashboard(); return endpoints; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs deleted file mode 100644 index 5102709..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs +++ /dev/null @@ -1,77 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Server.Grpc; - -/// -/// Maps + rows produced -/// by into galaxy_repository.v1 proto messages. -/// Pure function, separated so it can be unit-tested without a SQL connection. -/// -public static class GalaxyProtoMapper -{ - /// Maps Galaxy hierarchy and attribute rows to Galaxy object protos. - /// Hierarchy rows from Galaxy Repository. - /// Attribute rows from Galaxy Repository. - public static IEnumerable MapHierarchy( - IReadOnlyList hierarchy, - IReadOnlyList attributes) - { - Dictionary> attributesByGobjectId = attributes - .GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - - foreach (GalaxyHierarchyRow row in hierarchy) - { - yield return MapObject(row, attributesByGobjectId); - } - } - - /// Maps a Galaxy hierarchy row to a Galaxy object proto. - /// Hierarchy row from Galaxy Repository. - /// Attributes indexed by gobject ID. - public static GalaxyObject MapObject( - GalaxyHierarchyRow row, - IReadOnlyDictionary> attributesByGobjectId) - { - GalaxyObject obj = new() - { - GobjectId = row.GobjectId, - TagName = row.TagName, - ContainedName = row.ContainedName, - BrowseName = row.BrowseName, - ParentGobjectId = row.ParentGobjectId, - IsArea = row.IsArea, - CategoryId = row.CategoryId, - HostedByGobjectId = row.HostedByGobjectId, - }; - obj.TemplateChain.AddRange(row.TemplateChain); - - if (attributesByGobjectId.TryGetValue(row.GobjectId, out List? attrs)) - { - foreach (GalaxyAttributeRow attr in attrs) - { - obj.Attributes.Add(MapAttribute(attr)); - } - } - - return obj; - } - - /// Maps a Galaxy attribute row to a Galaxy attribute proto. - /// Attribute row from Galaxy Repository. - public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new() - { - AttributeName = row.AttributeName, - FullTagReference = row.FullTagReference, - MxDataType = row.MxDataType, - DataTypeName = row.DataTypeName ?? string.Empty, - IsArray = row.IsArray, - ArrayDimension = row.ArrayDimension ?? 0, - ArrayDimensionPresent = row.ArrayDimension.HasValue, - MxAttributeCategory = row.MxAttributeCategory, - SecurityClassification = row.SecurityClassification, - IsHistorized = row.IsHistorized, - IsAlarm = row.IsAlarm, - }; -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs deleted file mode 100644 index d247a6d..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs +++ /dev/null @@ -1,348 +0,0 @@ -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using GalaxyDb = ZB.MOM.WW.MxGateway.Server.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -using ZB.MOM.WW.MxGateway.Server.Security.Authorization; -using ProtoGalaxyRepository = ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepository; - -namespace ZB.MOM.WW.MxGateway.Server.Grpc; - -/// -/// gRPC surface that exposes the Galaxy Repository to clients. DiscoverHierarchy -/// and GetLastDeployTime serve from -/// so many clients share a single SQL pull. WatchDeployEvents streams events -/// from . TestConnection remains a -/// direct SQL probe since callers use it as a health check. -/// -public sealed class GalaxyRepositoryGrpcService( - GalaxyDb.IGalaxyRepository repository, - GalaxyDb.IGalaxyHierarchyCache cache, - GalaxyDb.IGalaxyDeployNotifier notifier, - IGatewayRequestIdentityAccessor identityAccessor) : ProtoGalaxyRepository.GalaxyRepositoryBase -{ - private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5); - private const int DefaultDiscoverPageSize = 1000; - private const int MaxDiscoverPageSize = 5000; - private const int DefaultBrowsePageSize = 500; - // MaxBrowsePageSize reuses MaxDiscoverPageSize (5000) — same cap. - - /// - public override async Task TestConnection( - TestConnectionRequest request, - ServerCallContext context) - { - bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false); - return new TestConnectionReply { Ok = ok }; - } - - /// - public override async Task GetLastDeployTime( - GetLastDeployTimeRequest request, - ServerCallContext context) - { - await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false); - GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current; - - if (!entry.HasData) - { - throw new RpcException(new Status( - StatusCode.Unavailable, - ResolveUnavailableMessage(entry))); - } - - GetLastDeployTimeReply reply = new() { Present = entry.LastDeployTime.HasValue }; - if (entry.LastDeployTime.HasValue) - { - reply.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(entry.LastDeployTime.Value); - } - return reply; - } - - /// - public override async Task DiscoverHierarchy( - DiscoverHierarchyRequest request, - ServerCallContext context) - { - await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false); - GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current; - - if (!entry.HasData) - { - throw new RpcException(new Status( - StatusCode.Unavailable, - ResolveUnavailableMessage(entry))); - } - - int pageSize = ResolvePageSize(request.PageSize); - IReadOnlyList browseSubtrees = ResolveBrowseSubtrees(); - string filterSignature = GalaxyDb.GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees); - PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature); - GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project( - entry, - request, - browseSubtrees, - pageToken.Offset, - pageSize); - int offset = pageToken.Offset; - if (offset > query.TotalObjectCount) - { - throw new RpcException(new Status( - StatusCode.InvalidArgument, - "DiscoverHierarchy page_token is outside the current hierarchy.")); - } - - DiscoverHierarchyReply reply = new() - { - TotalObjectCount = query.TotalObjectCount, - }; - reply.Objects.Add(query.Objects); - - int nextOffset = offset + query.Objects.Count; - if (nextOffset < query.TotalObjectCount) - { - reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset); - } - - return reply; - } - - /// - public override async Task BrowseChildren( - BrowseChildrenRequest request, - ServerCallContext context) - { - await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false); - GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current; - - if (!entry.HasData) - { - throw new RpcException(new Status( - StatusCode.Unavailable, - ResolveUnavailableMessage(entry))); - } - - int pageSize = ResolveBrowsePageSize(request.PageSize); - IReadOnlyList browseSubtrees = ResolveBrowseSubtrees(); - - // Resolve the parent id once so the page-token signature can include it - // and the projector sees the same resolved id when memoizing. The projector - // re-resolves internally; with the by-name/by-path indexes on - // GalaxyHierarchyIndex that second call is O(1), so the redundancy is cheap - // and keeps the projector self-contained. - int parentId = GalaxyDb.GalaxyBrowseProjector.ResolveParentId(entry, request); - string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature( - request, browseSubtrees, parentId); - PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature); - - GalaxyDb.GalaxyBrowseChildrenResult result = GalaxyDb.GalaxyBrowseProjector.ProjectChildren( - entry, - request, - browseSubtrees, - pageToken.Offset, - pageSize); - - if (pageToken.Offset > result.TotalChildCount) - { - throw new RpcException(new Status( - StatusCode.InvalidArgument, - "BrowseChildren page_token is outside the current children set.")); - } - - BrowseChildrenReply reply = new() - { - TotalChildCount = result.TotalChildCount, - CacheSequence = (ulong)entry.Sequence, - }; - reply.Children.Add(result.Children); - reply.ChildHasChildren.Add(result.ChildHasChildren); - - int nextOffset = pageToken.Offset + result.Children.Count; - if (nextOffset < result.TotalChildCount) - { - reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset); - } - - return reply; - } - - /// - public override async Task WatchDeployEvents( - WatchDeployEventsRequest request, - IServerStreamWriter responseStream, - ServerCallContext context) - { - DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset(); - - // The caller's identity (and therefore its browse-subtree constraints) is fixed - // for the lifetime of the stream, so resolve the subtrees once rather than per - // streamed event. - IReadOnlyList browseSubtrees = ResolveBrowseSubtrees(); - - await foreach (GalaxyDb.GalaxyDeployEventInfo info in notifier - .SubscribeAsync(context.CancellationToken) - .ConfigureAwait(false)) - { - // Suppress the initial bootstrap event when the client already knows about - // this deploy time. We only suppress the first one — subsequent events fire - // on actual changes, so they always pass. - if (lastSeen is { } seen && info.TimeOfLastDeploy == seen) - { - lastSeen = null; - continue; - } - lastSeen = null; - - await responseStream.WriteAsync(MapDeployEvent(info, browseSubtrees), context.CancellationToken).ConfigureAwait(false); - } - } - - private async Task WaitForCacheBootstrap(CancellationToken cancellationToken) - { - if (cache.Current.HasData || cache.Current.Status == GalaxyDb.GalaxyCacheStatus.Unavailable) - { - return; - } - - using CancellationTokenSource budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - budget.CancelAfter(FirstLoadWaitBudget); - try - { - await cache.WaitForFirstLoadAsync(budget.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (OperationCanceledException) - { - // Budget elapsed; fall through and let the caller see the current - // (possibly Unknown/Unavailable) entry. - } - } - - private DeployEvent MapDeployEvent( - GalaxyDb.GalaxyDeployEventInfo info, - IReadOnlyList browseSubtrees) - { - int objectCount = info.ObjectCount; - int attributeCount = info.AttributeCount; - if (browseSubtrees.Count > 0 && cache.Current.HasData) - { - GalaxyDb.GalaxyHierarchyQueryResult scoped = GalaxyDb.GalaxyHierarchyProjector.Project( - cache.Current, - new DiscoverHierarchyRequest(), - browseSubtrees); - objectCount = scoped.TotalObjectCount; - attributeCount = scoped.Objects.Sum(obj => obj.Attributes.Count); - } - - DeployEvent ev = new() - { - Sequence = (ulong)info.Sequence, - ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt), - ObjectCount = objectCount, - AttributeCount = attributeCount, - TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue, - }; - if (info.TimeOfLastDeploy.HasValue) - { - ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(info.TimeOfLastDeploy.Value); - } - return ev; - } - - private static string ResolveUnavailableMessage(GalaxyDb.GalaxyHierarchyCacheEntry entry) => entry.Status switch - { - GalaxyDb.GalaxyCacheStatus.Unknown => "Galaxy cache has not completed its initial load yet.", - GalaxyDb.GalaxyCacheStatus.Unavailable => "Galaxy repository is unavailable.", - _ => "Galaxy cache has no data available.", - }; - - private static int ResolvePageSize(int requestedPageSize) - { - if (requestedPageSize < 0) - { - throw new RpcException(new Status( - StatusCode.InvalidArgument, - "DiscoverHierarchy page_size must be greater than zero when provided.")); - } - - int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize; - return Math.Min(pageSize, MaxDiscoverPageSize); - } - - private static int ResolveBrowsePageSize(int requested) - { - if (requested < 0) - { - throw new RpcException(new Status( - StatusCode.InvalidArgument, - "BrowseChildren page_size must be greater than zero when provided.")); - } - int pageSize = requested == 0 ? DefaultBrowsePageSize : requested; - return Math.Min(pageSize, MaxDiscoverPageSize); - } - - private IReadOnlyList ResolveBrowseSubtrees() - { - ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty; - return constraints.BrowseSubtrees; - } - - private static string FormatPageToken(long sequence, string filterSignature, int offset) - { - return string.Concat( - sequence.ToString(System.Globalization.CultureInfo.InvariantCulture), - ":", - filterSignature, - ":", - offset.ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - - private static PageToken ParsePageToken(string pageToken, long currentSequence, string currentFilterSignature) - { - if (string.IsNullOrWhiteSpace(pageToken)) - { - return new PageToken(currentSequence, currentFilterSignature, Offset: 0); - } - - string[] parts = pageToken.Split(':', count: 3); - if (parts.Length != 3 - || !long.TryParse( - parts[0], - System.Globalization.NumberStyles.None, - System.Globalization.CultureInfo.InvariantCulture, - out long sequence) - || !int.TryParse( - parts[2], - System.Globalization.NumberStyles.None, - System.Globalization.CultureInfo.InvariantCulture, - out int offset) - || offset < 0) - { - throw new RpcException(new Status( - StatusCode.InvalidArgument, - "page_token is invalid.")); - } - - if (sequence != currentSequence) - { - throw new RpcException(new Status( - StatusCode.InvalidArgument, - "page_token is stale.")); - } - - if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal)) - { - throw new RpcException(new Status( - StatusCode.InvalidArgument, - "page_token does not match the current filters.")); - } - - return new PageToken(sequence, parts[1], offset); - } - - private sealed record PageToken(long Sequence, string FilterSignature, int Offset); - -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs index 812815a..6aebccc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -1,7 +1,7 @@ using System.Text.Json; using ZB.MOM.WW.Audit; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Server.Security.Audit; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Sessions; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayBrowseScopeProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayBrowseScopeProvider.cs new file mode 100644 index 0000000..e651cfd --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayBrowseScopeProvider.cs @@ -0,0 +1,17 @@ +using Grpc.Core; +using ZB.MOM.WW.GalaxyRepository.Grpc; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +/// Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint. +public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor) + : IGalaxyBrowseScopeProvider +{ + /// + public IReadOnlyList? ResolveBrowseSubtrees(ServerCallContext context) + { + ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + return constraints.BrowseSubtrees; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs index 6773f86..08bcd83 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs @@ -1,5 +1,5 @@ +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Contracts.Proto; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs index 9dfc0cd..5e8e932 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs @@ -1,4 +1,4 @@ -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; namespace ZB.MOM.WW.MxGateway.Server.Sessions;