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;