refactor(gateway): adopt ZB.MOM.WW.GalaxyRepository 0.2.0; delete inline Galaxy code
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
@inject IDashboardLiveDataService LiveData
|
@inject IDashboardLiveDataService LiveData
|
||||||
@inject IDashboardBrowseService BrowseService
|
@inject IDashboardBrowseService BrowseService
|
||||||
@inject IGalaxyDeployNotifier DeployNotifier
|
@inject IGalaxyDeployNotifier DeployNotifier
|
||||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
@using ZB.MOM.WW.GalaxyRepository
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Galaxy
|
@using ZB.MOM.WW.GalaxyRepository.Grpc
|
||||||
|
|
||||||
<PageTitle>Dashboard Browse</PageTitle>
|
<PageTitle>Dashboard Browse</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@page "/galaxy"
|
@page "/galaxy"
|
||||||
@inherits DashboardPageBase
|
@inherits DashboardPageBase
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Galaxy
|
@using ZB.MOM.WW.GalaxyRepository
|
||||||
|
|
||||||
<PageTitle>Dashboard Galaxy</PageTitle>
|
<PageTitle>Dashboard Galaxy</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Recursive Browse hierarchy node. Renders one Galaxy object, its child
|
||||||
|
|||||||
@@ -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;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
|
/// <summary>Projects the shared-library Galaxy cache entry into a dashboard Galaxy summary.</summary>
|
||||||
internal static class DashboardGalaxyProjector
|
internal static class DashboardGalaxyProjector
|
||||||
{
|
{
|
||||||
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
|
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||||
{
|
{
|
||||||
return entry.DashboardSummary;
|
return DashboardGalaxySummaryProjector.Project(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
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.Metrics;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One alarm-bearing attribute discovered by
|
|
||||||
/// <see cref="GalaxyRepository.GetAlarmAttributesAsync"/>: an attribute whose owning
|
|
||||||
/// object configures an <c>AlarmExtension</c> primitive (the same
|
|
||||||
/// <c>is_alarm</c> detection used by <see cref="GalaxyRepository.GetAttributesAsync"/>).
|
|
||||||
/// Used to build the subtag-fallback watch-list.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyAlarmAttributeRow
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the alarm-bearing attribute reference (e.g. <c>Tank01.Level.HiHi</c>),
|
|
||||||
/// matching the <c>full_tag_reference</c> projection of
|
|
||||||
/// <see cref="GalaxyRepository.GetAttributesAsync"/>.
|
|
||||||
/// </summary>
|
|
||||||
public string FullTagReference { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the owning object reference (e.g. <c>Tank01</c>). This is the Galaxy
|
|
||||||
/// <c>tag_name</c> — the segment that precedes the first attribute dot in
|
|
||||||
/// <see cref="FullTagReference"/>.
|
|
||||||
/// </summary>
|
|
||||||
public string SourceObjectReference { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the owning object's Galaxy area (e.g. <c>TestArea</c>) — the alarm group.
|
|
||||||
/// <para>
|
|
||||||
/// Resolved via <c>gobject.area_gobject_id</c> in <c>AlarmAttributesSql</c>. The
|
|
||||||
/// watch-list resolver composes the canonical <c>Galaxy!{area}.{reference}</c> from
|
|
||||||
/// this so the synthesized reference's group matches the native alarmmgr (wnwrap)
|
|
||||||
/// for reference parity. May be <see cref="string.Empty"/> when the object has no
|
|
||||||
/// area; the resolver then falls back to the configured area.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public string Area { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the writable ack-comment attribute address.
|
|
||||||
/// <para>
|
|
||||||
/// The Galaxy Repository schema does not expose an ack-comment subtag address
|
|
||||||
/// directly, so this is always <see cref="string.Empty"/> here. The watch-list
|
|
||||||
/// resolver (a later task) composes the concrete address from configuration plus
|
|
||||||
/// <see cref="SourceObjectReference"/> / <see cref="FullTagReference"/>.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public string AckCommentSubtag { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of one <see cref="GalaxyBrowseProjector.ProjectChildren"/> call. Holds a
|
|
||||||
/// materialized page of direct children for the requested parent, along with a
|
|
||||||
/// parallel-indexed <see cref="ChildHasChildren"/> hint and the total post-filter
|
|
||||||
/// sibling count for paging.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Children">The page of direct children, sorted areas-first then by display name.</param>
|
|
||||||
/// <param name="ChildHasChildren">Parallel array indicating whether each child has at least one matching descendant under the same filter set.</param>
|
|
||||||
/// <param name="TotalChildCount">Total matching direct children of the parent (post-filter).</param>
|
|
||||||
/// <param name="FilterSignature">Stable signature of the filter and parent selector, used to bind page tokens.</param>
|
|
||||||
public sealed record GalaxyBrowseChildrenResult(
|
|
||||||
IReadOnlyList<GalaxyObject> Children,
|
|
||||||
IReadOnlyList<bool> ChildHasChildren,
|
|
||||||
int TotalChildCount,
|
|
||||||
string FilterSignature);
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Projects one level of children of a parent object out of an immutable
|
|
||||||
/// <see cref="GalaxyHierarchyCacheEntry"/>. 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class GalaxyBrowseProjector
|
|
||||||
{
|
|
||||||
private static readonly ConditionalWeakTable<
|
|
||||||
GalaxyHierarchyCacheEntry,
|
|
||||||
ConcurrentDictionary<string, FilteredChildren>> FilteredChildrenCache = new();
|
|
||||||
|
|
||||||
/// <summary>Projects one page of direct children of the resolved parent.</summary>
|
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
|
||||||
/// <param name="request">The browse-children request.</param>
|
|
||||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
|
||||||
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
|
||||||
/// <param name="pageSize">Maximum number of children to return.</param>
|
|
||||||
public static GalaxyBrowseChildrenResult ProjectChildren(
|
|
||||||
GalaxyHierarchyCacheEntry entry,
|
|
||||||
BrowseChildrenRequest request,
|
|
||||||
IReadOnlyList<string>? 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<GalaxyObject> page = new(Math.Max(0, end - offset));
|
|
||||||
List<bool> 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves the request's parent oneof to a gobject id, throwing
|
|
||||||
/// <see cref="RpcException"/> with <see cref="StatusCode.NotFound"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
|
||||||
/// <param name="request">The browse-children request.</param>
|
|
||||||
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<string>? browseSubtreeGlobs,
|
|
||||||
int parentId,
|
|
||||||
string filterSignature)
|
|
||||||
{
|
|
||||||
ConcurrentDictionary<string, FilteredChildren> memo =
|
|
||||||
FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary<string, FilteredChildren>(StringComparer.Ordinal));
|
|
||||||
|
|
||||||
return memo.GetOrAdd(
|
|
||||||
filterSignature,
|
|
||||||
static (_, state) =>
|
|
||||||
{
|
|
||||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> map = state.Entry.Index.ChildrenByParent;
|
|
||||||
IReadOnlyList<GalaxyObjectView> directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList<GalaxyObjectView>? list)
|
|
||||||
? list
|
|
||||||
: Array.Empty<GalaxyObjectView>();
|
|
||||||
|
|
||||||
List<GalaxyObjectView> matched = [];
|
|
||||||
List<bool> 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<string>? browseSubtreeGlobs)
|
|
||||||
{
|
|
||||||
if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? 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<int> visited = new() { parent.Object.GobjectId };
|
|
||||||
Stack<GalaxyObjectView> 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<GalaxyObjectView>? grandchildren))
|
|
||||||
{
|
|
||||||
foreach (GalaxyObjectView grandchild in grandchildren)
|
|
||||||
{
|
|
||||||
if (visited.Add(grandchild.Object.GobjectId))
|
|
||||||
{
|
|
||||||
stack.Push(grandchild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList<string>? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
|
||||||
/// <param name="request">The browse-children request.</param>
|
|
||||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
|
||||||
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
|
||||||
public static string ComputeFilterSignature(
|
|
||||||
BrowseChildrenRequest request,
|
|
||||||
IReadOnlyList<string>? 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<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
|
||||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
|
||||||
return Convert.ToHexString(hash, 0, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record FilteredChildren(
|
|
||||||
IReadOnlyList<GalaxyObjectView> Children,
|
|
||||||
IReadOnlyList<bool> HasMatchingDescendant);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
public enum GalaxyCacheStatus
|
|
||||||
{
|
|
||||||
/// <summary>Cache has never completed a refresh.</summary>
|
|
||||||
Unknown = 0,
|
|
||||||
|
|
||||||
/// <summary>Cache holds data from a recent successful refresh.</summary>
|
|
||||||
Healthy = 1,
|
|
||||||
|
|
||||||
/// <summary>Cache holds data, but the most recent refresh attempt failed
|
|
||||||
/// or no successful refresh has happened within the staleness threshold.</summary>
|
|
||||||
Stale = 2,
|
|
||||||
|
|
||||||
/// <summary>Latest refresh failed and no prior data is available.</summary>
|
|
||||||
Unavailable = 3,
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single Galaxy deploy notification. Published by <see cref="GalaxyHierarchyCache"/>
|
|
||||||
/// whenever a refresh detects that <c>galaxy.time_of_last_deploy</c> has changed (or on
|
|
||||||
/// the first successful refresh). Consumed by <see cref="IGalaxyDeployNotifier"/>
|
|
||||||
/// subscribers (the streaming gRPC RPC).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GalaxyDeployEventInfo(
|
|
||||||
long Sequence,
|
|
||||||
DateTimeOffset ObservedAt,
|
|
||||||
DateTimeOffset? TimeOfLastDeploy,
|
|
||||||
int ObjectCount,
|
|
||||||
int AttributeCount);
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Threading.Channels;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <summary>
|
|
||||||
/// Publishes Galaxy deploy events to streaming gRPC subscribers via private bounded channels.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
|
|
||||||
{
|
|
||||||
private const int SubscriberQueueCapacity = 16;
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
|
||||||
private GalaxyDeployEventInfo? _latest;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The most recent deploy event, or null if none has been published.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Publish(GalaxyDeployEventInfo info)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(info);
|
|
||||||
|
|
||||||
Volatile.Write(ref _latest, info);
|
|
||||||
|
|
||||||
foreach (Channel<GalaxyDeployEventInfo> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Guid subscriberId = Guid.NewGuid();
|
|
||||||
Channel<GalaxyDeployEventInfo> channel = Channel.CreateBounded<GalaxyDeployEventInfo>(
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum number of compiled-regex entries retained in <see cref="RegexCache"/>.
|
|
||||||
/// 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 <c>DiscoverHierarchyRequest.TagNameGlob</c> (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.
|
|
||||||
/// </summary>
|
|
||||||
internal const int RegexCacheCapacity = 256;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bounded compiled-regex cache keyed by glob pattern. <c>IsMatch</c> is called
|
|
||||||
/// once per object per <c>DiscoverHierarchy</c>/<c>WatchDeployEvents</c>
|
|
||||||
/// evaluation, so the same handful of glob patterns are translated
|
|
||||||
/// repeatedly; caching avoids rebuilding and recompiling the regex on every
|
|
||||||
/// call. Beyond <see cref="RegexCacheCapacity"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Insertion-order queue used to evict the oldest cache entry when the cache
|
|
||||||
/// exceeds <see cref="RegexCacheCapacity"/>. A separate queue keeps the
|
|
||||||
/// <see cref="RegexCache"/> reads lock-free; the lock below only guards the
|
|
||||||
/// eviction path.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly ConcurrentQueue<string> InsertionOrder = new();
|
|
||||||
private static readonly object EvictionLock = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current cache size, exposed for tests asserting the cap is honoured.
|
|
||||||
/// </summary>
|
|
||||||
internal static int CurrentCacheSize => RegexCache.Count;
|
|
||||||
|
|
||||||
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
|
|
||||||
/// <param name="value">The value to test against the glob pattern.</param>
|
|
||||||
/// <param name="glob">The glob pattern with * and ? wildcards.</param>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
|
|
||||||
/// entry — the materialized <see cref="DiscoverHierarchyReply"/> is produced once per
|
|
||||||
/// refresh and reused across requests. Refreshes are deploy-time gated: every tick
|
|
||||||
/// queries <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy +
|
|
||||||
/// attributes rowsets are pulled only when that timestamp has advanced.
|
|
||||||
/// Each successful heavy refresh is persisted to disk through
|
|
||||||
/// <see cref="IGalaxyHierarchySnapshotStore"/>; the first refresh restores that
|
|
||||||
/// snapshot (as <see cref="GalaxyCacheStatus.Stale"/>) so clients can browse
|
|
||||||
/// last-known data when the Galaxy database is unreachable on a cold start.
|
|
||||||
/// </summary>
|
|
||||||
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<GalaxyHierarchyCache>? _logger;
|
|
||||||
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
|
||||||
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
|
||||||
private bool _restoreAttempted;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
|
||||||
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
|
||||||
/// <param name="notifier">Galaxy deploy event notifier.</param>
|
|
||||||
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
|
||||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
|
||||||
/// <param name="snapshotStore">
|
|
||||||
/// Optional on-disk snapshot store. When supplied, the cache persists each
|
|
||||||
/// successful refresh and restores the last snapshot on first load.
|
|
||||||
/// </param>
|
|
||||||
public GalaxyHierarchyCache(
|
|
||||||
IGalaxyRepository repository,
|
|
||||||
IGalaxyDeployNotifier notifier,
|
|
||||||
TimeProvider? timeProvider = null,
|
|
||||||
ILogger<GalaxyHierarchyCache>? logger = null,
|
|
||||||
IGalaxyHierarchySnapshotStore? snapshotStore = null)
|
|
||||||
{
|
|
||||||
_repository = repository;
|
|
||||||
_notifier = notifier;
|
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
||||||
_logger = logger;
|
|
||||||
_snapshotStore = snapshotStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
|
||||||
public async Task RefreshAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_refreshGate.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the wait operation.</returns>
|
|
||||||
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<List<GalaxyHierarchyRow>> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken);
|
|
||||||
Task<List<GalaxyAttributeRow>> attributesTask = _repository.GetAttributesAsync(cancellationToken);
|
|
||||||
await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false);
|
|
||||||
|
|
||||||
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
|
||||||
List<GalaxyAttributeRow> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Materializes a complete <see cref="GalaxyHierarchyCacheEntry"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
private static GalaxyHierarchyCacheEntry BuildEntry(
|
|
||||||
GalaxyCacheStatus status,
|
|
||||||
long sequence,
|
|
||||||
DateTimeOffset? lastQueriedAt,
|
|
||||||
DateTimeOffset? lastSuccessAt,
|
|
||||||
DateTimeOffset? lastDeployTime,
|
|
||||||
string? lastError,
|
|
||||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
|
||||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
|
||||||
{
|
|
||||||
IReadOnlyList<GalaxyObject> 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds the cache from the on-disk snapshot when no live data has loaded yet.
|
|
||||||
/// The restored entry is marked <see cref="GalaxyCacheStatus.Stale"/> — 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persists a successful refresh to disk. Persistence failures are logged and
|
|
||||||
/// swallowed — a cache that cannot write its backup is still fully usable.
|
|
||||||
/// </summary>
|
|
||||||
private async Task PersistSnapshotAsync(
|
|
||||||
DateTimeOffset? deployTime,
|
|
||||||
DateTimeOffset savedAt,
|
|
||||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
|
||||||
IReadOnlyList<GalaxyAttributeRow> 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<GalaxyObject> BuildObjects(
|
|
||||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
|
||||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
|
||||||
{
|
|
||||||
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
|
||||||
.GroupBy(a => a.GobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
|
|
||||||
List<GalaxyObject> 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<GalaxyHierarchyRow> hierarchy,
|
|
||||||
int objectCount,
|
|
||||||
int areaCount,
|
|
||||||
int attributeCount,
|
|
||||||
int historizedAttributeCount,
|
|
||||||
int alarmAttributeCount)
|
|
||||||
{
|
|
||||||
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
|
||||||
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
|
||||||
|
|
||||||
if (hierarchy.Count == 0)
|
|
||||||
{
|
|
||||||
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
|
||||||
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Dictionary<int, int> objectsByCategory = new();
|
|
||||||
Dictionary<string, int> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Immutable snapshot of the Galaxy Repository browse data held by
|
|
||||||
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
|
|
||||||
/// materialized object list and precomputed dashboard projection.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GalaxyHierarchyCacheEntry(
|
|
||||||
GalaxyCacheStatus Status,
|
|
||||||
long Sequence,
|
|
||||||
DateTimeOffset? LastQueriedAt,
|
|
||||||
DateTimeOffset? LastSuccessAt,
|
|
||||||
DateTimeOffset? LastDeployTime,
|
|
||||||
string? LastError,
|
|
||||||
IReadOnlyList<GalaxyObject> Objects,
|
|
||||||
GalaxyHierarchyIndex Index,
|
|
||||||
DashboardGalaxySummary DashboardSummary,
|
|
||||||
int ObjectCount,
|
|
||||||
int AreaCount,
|
|
||||||
int AttributeCount,
|
|
||||||
int HistorizedAttributeCount,
|
|
||||||
int AlarmAttributeCount)
|
|
||||||
{
|
|
||||||
/// <summary>Gets an empty Galaxy hierarchy cache entry.</summary>
|
|
||||||
public static GalaxyHierarchyCacheEntry Empty { get; } = new(
|
|
||||||
Status: GalaxyCacheStatus.Unknown,
|
|
||||||
Sequence: 0,
|
|
||||||
LastQueriedAt: null,
|
|
||||||
LastSuccessAt: null,
|
|
||||||
LastDeployTime: null,
|
|
||||||
LastError: null,
|
|
||||||
Objects: Array.Empty<GalaxyObject>(),
|
|
||||||
Index: GalaxyHierarchyIndex.Empty,
|
|
||||||
DashboardSummary: DashboardGalaxySummary.Unknown,
|
|
||||||
ObjectCount: 0,
|
|
||||||
AreaCount: 0,
|
|
||||||
AttributeCount: 0,
|
|
||||||
HistorizedAttributeCount: 0,
|
|
||||||
AlarmAttributeCount: 0);
|
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether the cache entry contains usable data.</summary>
|
|
||||||
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
|
|
||||||
}
|
|
||||||
@@ -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<GalaxyObjectView> objectViews,
|
|
||||||
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
|
|
||||||
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress,
|
|
||||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent,
|
|
||||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByTagName,
|
|
||||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByContainedPath)
|
|
||||||
{
|
|
||||||
ObjectViews = objectViews;
|
|
||||||
ObjectViewsById = objectViewsById;
|
|
||||||
TagsByAddress = tagsByAddress;
|
|
||||||
ChildrenByParent = childrenByParent;
|
|
||||||
ObjectViewsByTagName = objectViewsByTagName;
|
|
||||||
ObjectViewsByContainedPath = objectViewsByContainedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
|
|
||||||
public static GalaxyHierarchyIndex Empty { get; } = new(
|
|
||||||
Array.Empty<GalaxyObjectView>(),
|
|
||||||
new Dictionary<int, GalaxyObjectView>(),
|
|
||||||
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase),
|
|
||||||
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>(),
|
|
||||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase),
|
|
||||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
/// <summary>Gets the object views.</summary>
|
|
||||||
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
|
|
||||||
|
|
||||||
/// <summary>Gets the object views indexed by GUID.</summary>
|
|
||||||
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
|
|
||||||
|
|
||||||
/// <summary>Gets tags indexed by address.</summary>
|
|
||||||
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
|
|
||||||
|
|
||||||
/// <summary>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).</summary>
|
|
||||||
public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
|
|
||||||
|
|
||||||
/// <summary>Gets object views indexed by <see cref="GalaxyObject.TagName"/> (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by tag name in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
|
||||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByTagName { get; }
|
|
||||||
|
|
||||||
/// <summary>Gets object views indexed by contained path (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by path in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
|
||||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByContainedPath { get; }
|
|
||||||
|
|
||||||
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
|
|
||||||
/// <param name="objects">The Galaxy objects to index.</param>
|
|
||||||
/// <returns>A new Galaxy hierarchy index.</returns>
|
|
||||||
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
|
|
||||||
{
|
|
||||||
if (objects.Count == 0)
|
|
||||||
{
|
|
||||||
return Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<int, GalaxyObject> objectsById = new();
|
|
||||||
foreach (GalaxyObject obj in objects)
|
|
||||||
{
|
|
||||||
objectsById.TryAdd(obj.GobjectId, obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<GalaxyObjectView> views = new(objects.Count);
|
|
||||||
Dictionary<int, GalaxyObjectView> viewsById = new();
|
|
||||||
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
Dictionary<string, GalaxyObjectView> viewsByTagName = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
Dictionary<string, GalaxyObjectView> 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<int, List<GalaxyObjectView>> 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<GalaxyObjectView>? bucket))
|
|
||||||
{
|
|
||||||
bucket = [];
|
|
||||||
childrenByParent[parentKey] = bucket;
|
|
||||||
}
|
|
||||||
bucket.Add(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (List<GalaxyObjectView> bucket in childrenByParent.Values)
|
|
||||||
{
|
|
||||||
bucket.Sort(CompareByAreaThenDisplayName);
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<int, IReadOnlyList<GalaxyObjectView>> readOnlyChildren = new(childrenByParent.Count);
|
|
||||||
foreach (KeyValuePair<int, List<GalaxyObjectView>> kvp in childrenByParent)
|
|
||||||
{
|
|
||||||
readOnlyChildren[kvp.Key] = kvp.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new GalaxyHierarchyIndex(
|
|
||||||
views,
|
|
||||||
viewsById,
|
|
||||||
tagsByAddress,
|
|
||||||
readOnlyChildren,
|
|
||||||
viewsByTagName,
|
|
||||||
viewsByContainedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildContainedPath(
|
|
||||||
GalaxyObject obj,
|
|
||||||
IReadOnlyDictionary<int, GalaxyObject> objectsById)
|
|
||||||
{
|
|
||||||
Stack<string> names = new();
|
|
||||||
HashSet<int> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Per-cache-entry memo of filtered, ordered <see cref="GalaxyObjectView"/> lists
|
|
||||||
/// keyed by filter signature. Without it, paging through a large hierarchy
|
|
||||||
/// re-applies every filter and re-scans the full <see cref="GalaxyHierarchyIndex.ObjectViews"/>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new();
|
|
||||||
|
|
||||||
/// <summary>Projects a discovery request against a cache entry and returns all matching objects.</summary>
|
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
|
||||||
/// <param name="request">The discovery hierarchy request.</param>
|
|
||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
|
||||||
public static GalaxyHierarchyQueryResult Project(
|
|
||||||
GalaxyHierarchyCacheEntry entry,
|
|
||||||
DiscoverHierarchyRequest request,
|
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs = null)
|
|
||||||
{
|
|
||||||
return Project(
|
|
||||||
entry,
|
|
||||||
request,
|
|
||||||
browseSubtreeGlobs,
|
|
||||||
offset: 0,
|
|
||||||
pageSize: int.MaxValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Projects a discovery request with paging against a cache entry and returns a page of matching objects.</summary>
|
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
|
||||||
/// <param name="request">The discovery hierarchy request.</param>
|
|
||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
|
||||||
/// <param name="offset">The zero-based offset into the result set.</param>
|
|
||||||
/// <param name="pageSize">The maximum number of results to return.</param>
|
|
||||||
public static GalaxyHierarchyQueryResult Project(
|
|
||||||
GalaxyHierarchyCacheEntry entry,
|
|
||||||
DiscoverHierarchyRequest request,
|
|
||||||
IReadOnlyList<string>? 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<GalaxyObjectView> matchedViews = GetFilteredViews(
|
|
||||||
entry,
|
|
||||||
request,
|
|
||||||
browseSubtreeGlobs,
|
|
||||||
maxDepth,
|
|
||||||
filterSignature);
|
|
||||||
|
|
||||||
bool includeAttributes = IncludeAttributes(request);
|
|
||||||
List<GalaxyObject> 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<GalaxyObjectView> GetFilteredViews(
|
|
||||||
GalaxyHierarchyCacheEntry entry,
|
|
||||||
DiscoverHierarchyRequest request,
|
|
||||||
IReadOnlyList<string>? 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<GalaxyObjectView> views = entry.Index.ObjectViews;
|
|
||||||
GalaxyObjectView? root = ResolveRoot(request, entry.Index);
|
|
||||||
|
|
||||||
ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>> memo =
|
|
||||||
FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>(StringComparer.Ordinal));
|
|
||||||
|
|
||||||
return memo.GetOrAdd(
|
|
||||||
filterSignature,
|
|
||||||
static (_, state) =>
|
|
||||||
{
|
|
||||||
List<GalaxyObjectView> 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
|
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
|
||||||
/// <param name="tagAddress">The tag address to search for.</param>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
|
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
|
||||||
/// <param name="tagAddress">The tag address to search for.</param>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
|
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
|
||||||
/// <param name="gobjectId">The Galaxy object ID.</param>
|
|
||||||
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<string>? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
|
||||||
/// <param name="request">The discovery hierarchy request.</param>
|
|
||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
|
||||||
public static string ComputeFilterSignature(
|
|
||||||
DiscoverHierarchyRequest request,
|
|
||||||
IReadOnlyList<string>? 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<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
|
||||||
return Convert.ToHexString(hash, 0, 12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
public sealed record GalaxyHierarchyQueryResult(
|
|
||||||
IReadOnlyList<GalaxyObject> Objects,
|
|
||||||
int TotalObjectCount,
|
|
||||||
string FilterSignature);
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path.</summary>
|
|
||||||
public sealed class GalaxyHierarchyRefreshService(
|
|
||||||
IGalaxyHierarchyCache cache,
|
|
||||||
IOptions<GalaxyRepositoryOptions> options,
|
|
||||||
ILogger<GalaxyHierarchyRefreshService> logger,
|
|
||||||
TimeProvider? timeProvider = null) : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One row from <see cref="GalaxyRepository.GetHierarchyAsync"/>: a deployed Galaxy
|
|
||||||
/// <c>gobject</c> with its hierarchy parent and template-derivation chain.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyHierarchyRow
|
|
||||||
{
|
|
||||||
/// <summary>Gets the Galaxy object identifier.</summary>
|
|
||||||
public int GobjectId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the tag name.</summary>
|
|
||||||
public string TagName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Gets the contained name.</summary>
|
|
||||||
public string ContainedName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Gets the browse name.</summary>
|
|
||||||
public string BrowseName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Gets the parent Galaxy object identifier.</summary>
|
|
||||||
public int ParentGobjectId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether this is an area.</summary>
|
|
||||||
public bool IsArea { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the category identifier.</summary>
|
|
||||||
public int CategoryId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the Galaxy object identifier of the host.</summary>
|
|
||||||
public int HostedByGobjectId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the template derivation chain.</summary>
|
|
||||||
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
|
|
||||||
public sealed class GalaxyAttributeRow
|
|
||||||
{
|
|
||||||
/// <summary>Gets the Galaxy object identifier.</summary>
|
|
||||||
public int GobjectId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the tag name.</summary>
|
|
||||||
public string TagName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Gets the attribute name.</summary>
|
|
||||||
public string AttributeName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Gets the full tag reference.</summary>
|
|
||||||
public string FullTagReference { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Gets the MXAccess data type code.</summary>
|
|
||||||
public int MxDataType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the data type name.</summary>
|
|
||||||
public string? DataTypeName { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether this is an array.</summary>
|
|
||||||
public bool IsArray { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the array dimension, if applicable.</summary>
|
|
||||||
public int? ArrayDimension { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the MXAccess attribute category code.</summary>
|
|
||||||
public int MxAttributeCategory { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the security classification code.</summary>
|
|
||||||
public int SecurityClassification { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether this is historized.</summary>
|
|
||||||
public bool IsHistorized { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether this is an alarm.</summary>
|
|
||||||
public bool IsAlarm { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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
|
|
||||||
/// <see cref="IGalaxyHierarchySnapshotStore"/> after a successful refresh
|
|
||||||
/// and reloaded at startup when the Galaxy database is unreachable.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="LastDeployTime">
|
|
||||||
/// The <c>galaxy.time_of_last_deploy</c> the rowsets were pulled at, or
|
|
||||||
/// <see langword="null"/> 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.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="SavedAt">UTC wall-clock when the snapshot was written to disk.</param>
|
|
||||||
/// <param name="Hierarchy">The persisted object-hierarchy rowset.</param>
|
|
||||||
/// <param name="Attributes">The persisted attribute rowset.</param>
|
|
||||||
public sealed record GalaxyHierarchySnapshot(
|
|
||||||
DateTimeOffset? LastDeployTime,
|
|
||||||
DateTimeOffset SavedAt,
|
|
||||||
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
|
|
||||||
IReadOnlyList<GalaxyAttributeRow> Attributes);
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// JSON-file implementation of <see cref="IGalaxyHierarchySnapshotStore"/>.
|
|
||||||
/// 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
|
|
||||||
/// <see cref="GalaxyRepositoryOptions.PersistSnapshot"/> is <see langword="false"/>
|
|
||||||
/// both operations are no-ops.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private const int CurrentSchemaVersion = 1;
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
|
||||||
{
|
|
||||||
WriteIndented = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly string? _path;
|
|
||||||
private readonly TimeSpan _writeTimeout;
|
|
||||||
private readonly ILogger<GalaxyHierarchySnapshotStore>? _logger;
|
|
||||||
private readonly SemaphoreSlim _ioGate = new(1, 1);
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchySnapshotStore"/> class.</summary>
|
|
||||||
/// <param name="options">Galaxy repository options carrying the snapshot path and enable flag.</param>
|
|
||||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
|
||||||
public GalaxyHierarchySnapshotStore(
|
|
||||||
IOptions<GalaxyRepositoryOptions> options,
|
|
||||||
ILogger<GalaxyHierarchySnapshotStore>? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<GalaxyHierarchySnapshot?> 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<PersistedFile>(
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>On-disk envelope: a schema version plus the snapshot payload.</summary>
|
|
||||||
private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database.
|
|
||||||
/// <para>
|
|
||||||
/// <see cref="HierarchySql" /> is still the query originally ported from the OtOpcUa
|
|
||||||
/// project. <see cref="AttributesSql" /> has diverged: it additionally enumerates the
|
|
||||||
/// built-in attributes contributed by each object's primitives (from
|
|
||||||
/// <c>attribute_definition</c> via <c>primitive_instance</c>), so engine/platform objects
|
|
||||||
/// and extension sub-attributes (e.g. <c>TestAlarm001.Acked</c>) are surfaced. The
|
|
||||||
/// OtOpcUa query is not kept in sync — see docs/GalaxyRepository.md.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<bool> 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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<DateTime?> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
List<GalaxyHierarchyRow> 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<string>()
|
|
||||||
: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
List<GalaxyAttributeRow> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list.
|
|
||||||
/// Alarm detection is identical to <see cref="GetAttributesAsync"/>: a row is
|
|
||||||
/// alarm-bearing when its owning object configures an <c>AlarmExtension</c>
|
|
||||||
/// primitive (the same <c>is_alarm</c> projection, here applied as a SQL filter).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
List<GalaxyAlarmAttributeRow> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps a raw alarm-attribute reader row to a <see cref="GalaxyAlarmAttributeRow"/>.
|
|
||||||
/// <para>
|
|
||||||
/// <paramref name="sourceObjectReference"/> is the Galaxy <c>tag_name</c> (the
|
|
||||||
/// owning object), and <paramref name="fullTagReference"/> is
|
|
||||||
/// <c>tag_name + '.' + attribute_name</c> — the same composition the
|
|
||||||
/// <c>full_tag_reference</c> projection of <see cref="AttributesSql"/> produces.
|
|
||||||
/// <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is left empty here; the
|
|
||||||
/// schema does not expose an ack-comment address and the watch-list resolver
|
|
||||||
/// composes it later.
|
|
||||||
/// </para>
|
|
||||||
/// <paramref name="area"/> is the owning object's real Galaxy area (its alarm
|
|
||||||
/// group), resolved via <c>gobject.area_gobject_id</c>; 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The alarm-bearing attribute reference.</param>
|
|
||||||
/// <param name="sourceObjectReference">The owning object reference (tag name).</param>
|
|
||||||
/// <param name="area">The owning object's Galaxy area (the alarm group).</param>
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Connection settings for the AVEVA System Platform Galaxy Repository (ZB) database.
|
|
||||||
/// Bound to the <c>MxGateway:Galaxy</c> configuration section.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyRepositoryOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "MxGateway:Galaxy";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public const string DefaultConnectionString =
|
|
||||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
|
|
||||||
|
|
||||||
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
|
|
||||||
public string ConnectionString { get; init; } = DefaultConnectionString;
|
|
||||||
|
|
||||||
/// <summary>The timeout in seconds for SQL commands executed against the Galaxy Repository.</summary>
|
|
||||||
public int CommandTimeoutSeconds { get; init; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interval (seconds) between background refreshes of the dashboard Galaxy summary
|
|
||||||
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
|
|
||||||
/// </summary>
|
|
||||||
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
|
|
||||||
|
|
||||||
/// <summary>Default on-disk path for the persisted Galaxy browse snapshot.</summary>
|
|
||||||
public const string DefaultSnapshotCachePath =
|
|
||||||
@"C:\ProgramData\MxGateway\galaxy-snapshot.json";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public bool PersistSnapshot { get; init; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// File path for the persisted Galaxy browse snapshot. Ignored when
|
|
||||||
/// <see cref="PersistSnapshot"/> is <see langword="false"/>.
|
|
||||||
/// </summary>
|
|
||||||
public string SnapshotCachePath { get; init; } = DefaultSnapshotCachePath;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
public static class GalaxyRepositoryServiceCollectionExtensions
|
|
||||||
{
|
|
||||||
/// <summary>Registers Galaxy Repository services in the dependency injection container.</summary>
|
|
||||||
/// <param name="services">The service collection.</param>
|
|
||||||
/// <returns>The service collection for chaining.</returns>
|
|
||||||
public static IServiceCollection AddGalaxyRepository(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services
|
|
||||||
.AddOptions<GalaxyRepositoryOptions>()
|
|
||||||
.BindConfiguration(GalaxyRepositoryOptions.SectionName)
|
|
||||||
.ValidateOnStart();
|
|
||||||
|
|
||||||
services.AddSingleton(sp =>
|
|
||||||
new GalaxyRepository(sp.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value));
|
|
||||||
services.AddSingleton<IGalaxyRepository>(sp => sp.GetRequiredService<GalaxyRepository>());
|
|
||||||
|
|
||||||
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
|
|
||||||
services.AddSingleton<IGalaxyHierarchySnapshotStore, GalaxyHierarchySnapshotStore>();
|
|
||||||
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
|
|
||||||
services.AddHostedService<GalaxyHierarchyRefreshService>();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>Publishes Galaxy repository deploy events to subscribers.</summary>
|
|
||||||
public interface IGalaxyDeployNotifier
|
|
||||||
{
|
|
||||||
/// <summary>The most recently published event, or null if no event has fired yet.</summary>
|
|
||||||
GalaxyDeployEventInfo? Latest { get; }
|
|
||||||
|
|
||||||
/// <summary>Publishes a deploy event to all current subscribers and stores it as Latest.</summary>
|
|
||||||
/// <param name="info">The deploy event to publish.</param>
|
|
||||||
void Publish(GalaxyDeployEventInfo info);
|
|
||||||
|
|
||||||
/// <summary>Subscribes to deploy events. The sequence yields the latest event first (if available) then streams new events as they fire.</summary>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Async enumerable of deploy events.</returns>
|
|
||||||
IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>Cache for Galaxy Repository hierarchy data.</summary>
|
|
||||||
public interface IGalaxyHierarchyCache
|
|
||||||
{
|
|
||||||
/// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary>
|
|
||||||
GalaxyHierarchyCacheEntry Current { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Forces a refresh against the Galaxy Repository. Performs a cheap
|
|
||||||
/// <c>time_of_last_deploy</c> probe first and only re-queries the heavy hierarchy +
|
|
||||||
/// attributes rowsets when the deploy time has changed since the last successful
|
|
||||||
/// refresh.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task RefreshAsync(CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persists the latest Galaxy Repository browse dataset to disk and reloads
|
|
||||||
/// it at startup. Lets <see cref="GalaxyHierarchyCache"/> serve last-known
|
|
||||||
/// browse data when the Galaxy database is unreachable on a cold start.
|
|
||||||
/// </summary>
|
|
||||||
public interface IGalaxyHierarchySnapshotStore
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Writes <paramref name="snapshot"/> to disk, replacing any previous
|
|
||||||
/// snapshot atomically. A no-op when snapshot persistence is disabled.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="snapshot">The browse dataset to persist.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the persisted Galaxy browse dataset.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// The persisted snapshot, or <see langword="null"/> when none exists,
|
|
||||||
/// persistence is disabled, or the on-disk file uses an unrecognized
|
|
||||||
/// schema version.
|
|
||||||
/// </returns>
|
|
||||||
Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Abstraction over <see cref="GalaxyRepository"/> consumed by
|
|
||||||
/// <see cref="GalaxyHierarchyCache"/>. Exists so the cache can be unit-tested
|
|
||||||
/// against an in-memory fake that throws a <see cref="System.Exception"/>
|
|
||||||
/// from <see cref="GetLastDeployTimeAsync"/> (the unavailable-backend code
|
|
||||||
/// path) without standing up a real <c>Microsoft.Data.SqlClient</c>
|
|
||||||
/// <c>SqlConnection</c> against a bogus host/port. The production gateway
|
|
||||||
/// wires the concrete <see cref="GalaxyRepository"/>; the SQL surface itself
|
|
||||||
/// stays covered by <c>ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy.GalaxyRepositoryLiveTests</c>.
|
|
||||||
/// </summary>
|
|
||||||
public interface IGalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves only the alarm-bearing attributes (those whose owning object
|
|
||||||
/// configures an <c>AlarmExtension</c> primitive) for building the
|
|
||||||
/// subtag-fallback watch-list.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,13 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Configuration;
|
using Microsoft.Extensions.Logging.Configuration;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.DependencyInjection;
|
||||||
using ZB.MOM.WW.Health;
|
using ZB.MOM.WW.Health;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
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.Grpc;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
@@ -92,7 +92,11 @@ public static class GatewayApplication
|
|||||||
builder.Services.AddGatewaySessions();
|
builder.Services.AddGatewaySessions();
|
||||||
builder.Services.AddGatewayAlarms();
|
builder.Services.AddGatewayAlarms();
|
||||||
builder.Services.AddGatewayDashboard(builder.Configuration);
|
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<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||||
|
Security.Authorization.GatewayBrowseScopeProvider>();
|
||||||
|
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
@@ -193,7 +197,7 @@ public static class GatewayApplication
|
|||||||
endpoints.MapZbMetrics();
|
endpoints.MapZbMetrics();
|
||||||
|
|
||||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||||
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
endpoints.MapZbGalaxyRepository();
|
||||||
endpoints.MapGatewayDashboard();
|
endpoints.MapGatewayDashboard();
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps <see cref="GalaxyHierarchyRow"/> + <see cref="GalaxyAttributeRow"/> rows produced
|
|
||||||
/// by <see cref="GalaxyRepository"/> into <c>galaxy_repository.v1</c> proto messages.
|
|
||||||
/// Pure function, separated so it can be unit-tested without a SQL connection.
|
|
||||||
/// </summary>
|
|
||||||
public static class GalaxyProtoMapper
|
|
||||||
{
|
|
||||||
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
|
|
||||||
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
|
|
||||||
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
|
|
||||||
public static IEnumerable<GalaxyObject> MapHierarchy(
|
|
||||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
|
||||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
|
||||||
{
|
|
||||||
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
|
||||||
.GroupBy(a => a.GobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
|
|
||||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
|
||||||
{
|
|
||||||
yield return MapObject(row, attributesByGobjectId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
|
|
||||||
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
|
|
||||||
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
|
|
||||||
public static GalaxyObject MapObject(
|
|
||||||
GalaxyHierarchyRow row,
|
|
||||||
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> 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<GalaxyAttributeRow>? attrs))
|
|
||||||
{
|
|
||||||
foreach (GalaxyAttributeRow attr in attrs)
|
|
||||||
{
|
|
||||||
obj.Attributes.Add(MapAttribute(attr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
|
|
||||||
/// <param name="row">Attribute row from Galaxy Repository.</param>
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// gRPC surface that exposes the Galaxy Repository to clients. <c>DiscoverHierarchy</c>
|
|
||||||
/// and <c>GetLastDeployTime</c> serve from <see cref="GalaxyDb.IGalaxyHierarchyCache"/>
|
|
||||||
/// so many clients share a single SQL pull. <c>WatchDeployEvents</c> streams events
|
|
||||||
/// from <see cref="GalaxyDb.IGalaxyDeployNotifier"/>. <c>TestConnection</c> remains a
|
|
||||||
/// direct SQL probe since callers use it as a health check.
|
|
||||||
/// </summary>
|
|
||||||
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.
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<TestConnectionReply> TestConnection(
|
|
||||||
TestConnectionRequest request,
|
|
||||||
ServerCallContext context)
|
|
||||||
{
|
|
||||||
bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false);
|
|
||||||
return new TestConnectionReply { Ok = ok };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<GetLastDeployTimeReply> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<DiscoverHierarchyReply> 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<string> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<BrowseChildrenReply> 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<string> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task WatchDeployEvents(
|
|
||||||
WatchDeployEventsRequest request,
|
|
||||||
IServerStreamWriter<DeployEvent> 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<string> 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<string> 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<string> 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);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ZB.MOM.WW.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
|
||||||
|
public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor)
|
||||||
|
: IGalaxyBrowseScopeProvider
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
|
||||||
|
{
|
||||||
|
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||||
|
return constraints.BrowseSubtrees;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
|
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user