282 lines
12 KiB
C#
282 lines
12 KiB
C#
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);
|
|
}
|