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