diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyBrowseChildrenResult.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyBrowseChildrenResult.cs
new file mode 100644
index 0000000..3883529
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyBrowseChildrenResult.cs
@@ -0,0 +1,19 @@
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyBrowseProjector.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyBrowseProjector.cs
new file mode 100644
index 0000000..cf0b05b
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyBrowseProjector.cs
@@ -0,0 +1,281 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using Grpc.Core;
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyCacheStatus.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyCacheStatus.cs
new file mode 100644
index 0000000..d9bfb99
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyCacheStatus.cs
@@ -0,0 +1,18 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+/// Freshness state of the shared Galaxy hierarchy cache entry.
+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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyDeployEventInfo.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyDeployEventInfo.cs
new file mode 100644
index 0000000..1a0dfdd
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyDeployEventInfo.cs
@@ -0,0 +1,19 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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).
+///
+/// Monotonically increasing per process start; gaps indicate dropped events.
+/// Server wall-clock when the cache observed the deploy.
+/// The galaxy.time_of_last_deploy value, or when the Galaxy table reports none.
+/// Number of objects in the hierarchy at the time of the event.
+/// Number of attributes in the hierarchy at the time of the event.
+public sealed record GalaxyDeployEventInfo(
+ long Sequence,
+ DateTimeOffset ObservedAt,
+ DateTimeOffset? TimeOfLastDeploy,
+ int ObjectCount,
+ int AttributeCount);
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyDeployNotifier.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyDeployNotifier.cs
new file mode 100644
index 0000000..9658d81
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyDeployNotifier.cs
@@ -0,0 +1,79 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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.
+///
+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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyGlobMatcher.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyGlobMatcher.cs
new file mode 100644
index 0000000..6efbf59
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyGlobMatcher.cs
@@ -0,0 +1,131 @@
+using System.Collections.Concurrent;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Anchored, case-insensitive glob matcher (* and ? wildcards) used by the
+/// hierarchy and browse projectors to filter object tag names and browse subtrees.
+/// Compiled regexes are cached and the cache is bounded so an unbounded stream of distinct
+/// client-supplied globs cannot grow memory without limit.
+///
+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.
+ 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs
new file mode 100644
index 0000000..5b14d17
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs
@@ -0,0 +1,355 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
+/// entry — the materialized object list 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 };
+ }
+ }
+
+ /// 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,
+ };
+ 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,
+ };
+ 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 and index.
+ ///
+ 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);
+
+ return new GalaxyHierarchyCacheEntry(
+ Status: status,
+ Sequence: sequence,
+ LastQueriedAt: lastQueriedAt,
+ LastSuccessAt: lastSuccessAt,
+ LastDeployTime: lastDeployTime,
+ LastError: lastError,
+ Objects: objects,
+ Index: index,
+ 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 (service 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 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCacheEntry.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCacheEntry.cs
new file mode 100644
index 0000000..59e6f45
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCacheEntry.cs
@@ -0,0 +1,56 @@
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Immutable snapshot of the Galaxy Repository browse data held by
+/// . Multiple gRPC clients share the same
+/// materialized object list and precomputed hierarchy index.
+///
+/// The cache freshness state at the time the entry was produced.
+/// Monotonically increasing per process start; bumped on each heavy refresh.
+/// UTC wall-clock of the most recent refresh attempt.
+/// UTC wall-clock of the most recent successful refresh.
+/// The galaxy.time_of_last_deploy the data was pulled at.
+/// The most recent refresh error message, or .
+/// The materialized Galaxy object list.
+/// Precomputed lookup structures over .
+/// Number of objects in the hierarchy.
+/// Number of area objects in the hierarchy.
+/// Number of attributes across all objects.
+/// Number of historized attributes.
+/// Number of alarm-bearing attributes.
+public sealed record GalaxyHierarchyCacheEntry(
+ GalaxyCacheStatus Status,
+ long Sequence,
+ DateTimeOffset? LastQueriedAt,
+ DateTimeOffset? LastSuccessAt,
+ DateTimeOffset? LastDeployTime,
+ string? LastError,
+ IReadOnlyList Objects,
+ GalaxyHierarchyIndex Index,
+ 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,
+ 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyIndex.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyIndex.cs
new file mode 100644
index 0000000..3b1441a
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyIndex.cs
@@ -0,0 +1,206 @@
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Precomputed lookup structures over a materialized Galaxy object list. Built once per
+/// cache entry so browse/discover handlers can resolve roots/parents by id, tag name, or
+/// contained path in O(1), enumerate direct children, and resolve tag addresses to objects
+/// or attributes without rescanning the full object list.
+///
+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 gobject id.
+ 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.
+ 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyProjector.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyProjector.cs
new file mode 100644
index 0000000..27042c4
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyProjector.cs
@@ -0,0 +1,317 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using Grpc.Core;
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Projects a DiscoverHierarchy request against an immutable
+/// : applies the root/depth/category/template/glob
+/// filters, pages the result, and memoizes the filtered list per cache-entry instance so
+/// paging is O(pageSize) rather than O(total) per page. Pure and side-effect free.
+///
+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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyQueryResult.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyQueryResult.cs
new file mode 100644
index 0000000..1f2de7f
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyQueryResult.cs
@@ -0,0 +1,16 @@
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Result of one
+/// call: a materialized page of matching objects, the total post-filter object count, and
+/// the stable filter signature used to bind page tokens.
+///
+/// The page of matching objects.
+/// Total matching objects across the whole hierarchy (post-filter).
+/// Stable signature of the filter set, used to bind page tokens.
+public sealed record GalaxyHierarchyQueryResult(
+ IReadOnlyList Objects,
+ int TotalObjectCount,
+ string FilterSignature);
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRefreshService.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRefreshService.cs
new file mode 100644
index 0000000..77b048c
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRefreshService.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+/// 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 host. 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshot.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshot.cs
new file mode 100644
index 0000000..e7c5356
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshot.cs
@@ -0,0 +1,24 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshotStore.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshotStore.cs
new file mode 100644
index 0000000..de2236c
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshotStore.cs
@@ -0,0 +1,143 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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
+/// — or is empty —
+/// both operations are no-ops. The snapshot path is fully consumer-supplied;
+/// this store imposes no platform-specific default, so it is cross-platform.
+///
+public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore
+{
+ ///
+ /// On-disk format version. Bump this whenever the persisted shape changes
+ /// in a way an older or newer consumer 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyObjectView.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyObjectView.cs
new file mode 100644
index 0000000..1013a47
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyObjectView.cs
@@ -0,0 +1,16 @@
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// A paired with its computed contained path and hierarchy
+/// depth. Materialized once per cache entry by so
+/// browse/discover projection can filter and page without recomputing paths.
+///
+/// The projected Galaxy object.
+/// The slash-delimited contained path from the hierarchy root.
+/// The number of path segments from the root (zero for top-level objects).
+public sealed record GalaxyObjectView(
+ GalaxyObject Object,
+ string ContainedPath,
+ int Depth);
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyProtoMapper.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyProtoMapper.cs
new file mode 100644
index 0000000..6a4c2b3
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyProtoMapper.cs
@@ -0,0 +1,76 @@
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyTagLookup.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyTagLookup.cs
new file mode 100644
index 0000000..86ecdc9
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyTagLookup.cs
@@ -0,0 +1,16 @@
+using ZB.MOM.WW.GalaxyRepository.Grpc;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Resolution result for a tag address: the owning , the
+/// specific when the address names an attribute (otherwise
+/// ), and the object's contained path.
+///
+/// The Galaxy object that owns the looked-up address.
+/// The matched attribute, or when the address names an object.
+/// The owning object's contained path.
+public sealed record GalaxyTagLookup(
+ GalaxyObject Object,
+ GalaxyAttribute? Attribute,
+ string ContainedPath);
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyDeployNotifier.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyDeployNotifier.cs
new file mode 100644
index 0000000..c6cdf64
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyDeployNotifier.cs
@@ -0,0 +1,17 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+/// 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/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyHierarchyCache.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyHierarchyCache.cs
new file mode 100644
index 0000000..b869a69
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyHierarchyCache.cs
@@ -0,0 +1,25 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+/// 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 the service starts.
+ ///
+ /// Token to cancel the asynchronous operation.
+ Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyHierarchySnapshotStore.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyHierarchySnapshotStore.cs
new file mode 100644
index 0000000..873cca2
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyHierarchySnapshotStore.cs
@@ -0,0 +1,28 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// 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);
+}