Fix Galaxy projection and constraint review findings

This commit is contained in:
Joseph Doherty
2026-04-29 14:24:11 -04:00
parent b995c174eb
commit cf20142634
13 changed files with 527 additions and 165 deletions
@@ -1,4 +1,6 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration; using MxGateway.Server.Configuration;
using MxGateway.Server.Galaxy; using MxGateway.Server.Galaxy;
@@ -21,8 +23,12 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _gatewayStartedAt; private readonly DateTimeOffset _gatewayStartedAt;
private readonly TimeSpan _snapshotInterval; private readonly TimeSpan _snapshotInterval;
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
private readonly int _recentFaultLimit; private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit; private readonly int _recentSessionLimit;
private readonly ILogger<DashboardSnapshotService> _logger;
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
public DashboardSnapshotService( public DashboardSnapshotService(
ISessionRegistry sessionRegistry, ISessionRegistry sessionRegistry,
@@ -31,7 +37,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
IGalaxyHierarchyCache galaxyHierarchyCache, IGalaxyHierarchyCache galaxyHierarchyCache,
IApiKeyAdminStore apiKeyAdminStore, IApiKeyAdminStore apiKeyAdminStore,
IOptions<GatewayOptions> options, IOptions<GatewayOptions> options,
TimeProvider? timeProvider = null) TimeProvider? timeProvider = null,
ILogger<DashboardSnapshotService>? logger = null)
{ {
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry)); _sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
@@ -45,6 +52,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds); _snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit; _recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit; _recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
} }
public DashboardSnapshot GetSnapshot() public DashboardSnapshot GetSnapshot()
@@ -73,7 +81,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Workers: workerSummaries, Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot), Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt), Faults: CreateFaultSummaries(sessions, generatedAt),
ApiKeys: CreateApiKeySummaries(), ApiKeys: Volatile.Read(ref _apiKeySummaries),
Configuration: _configurationProvider.GetEffectiveConfiguration(), Configuration: _configurationProvider.GetEffectiveConfiguration(),
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current)); Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
} }
@@ -86,6 +94,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
yield break; yield break;
} }
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot(); yield return GetSnapshot();
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider); using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
@@ -106,6 +115,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
yield break; yield break;
} }
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot(); yield return GetSnapshot();
} }
} }
@@ -197,13 +207,19 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
.ToArray(); .ToArray();
} }
private IReadOnlyList<DashboardApiKeySummary> CreateApiKeySummaries() private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
{ {
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try try
{ {
return _apiKeyAdminStore.ListAsync(CancellationToken.None) using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
.GetAwaiter() timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
.GetResult() IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
.ConfigureAwait(false))
.Select(key => new DashboardApiKeySummary( .Select(key => new DashboardApiKeySummary(
KeyId: key.KeyId, KeyId: key.KeyId,
DisplayName: key.DisplayName, DisplayName: key.DisplayName,
@@ -213,10 +229,26 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
LastUsedUtc: key.LastUsedUtc, LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc)) RevokedUtc: key.RevokedUtc))
.ToArray(); .ToArray();
Volatile.Write(ref _apiKeySummaries, summaries);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"Timed out refreshing dashboard API key summaries after {Timeout}.",
_apiKeySummaryRefreshTimeout);
} }
catch (Exception) catch (Exception)
{ {
return Array.Empty<DashboardApiKeySummary>(); _logger.LogWarning("Failed to refresh dashboard API key summaries.");
}
finally
{
_apiKeySummaryRefreshGate.Release();
} }
} }
@@ -120,6 +120,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result; List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
List<GalaxyAttributeRow> attributes = attributesTask.Result; List<GalaxyAttributeRow> attributes = attributesTask.Result;
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes); IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
int areaCount = hierarchy.Count(row => row.IsArea); int areaCount = hierarchy.Count(row => row.IsArea);
int historized = attributes.Count(row => row.IsHistorized); int historized = attributes.Count(row => row.IsHistorized);
@@ -146,6 +147,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
LastDeployTime: deployTime, LastDeployTime: deployTime,
LastError: null, LastError: null,
Objects: objects, Objects: objects,
Index: index,
DashboardSummary: dashboardSummary, DashboardSummary: dashboardSummary,
ObjectCount: hierarchy.Count, ObjectCount: hierarchy.Count,
AreaCount: areaCount, AreaCount: areaCount,
@@ -16,6 +16,7 @@ public sealed record GalaxyHierarchyCacheEntry(
DateTimeOffset? LastDeployTime, DateTimeOffset? LastDeployTime,
string? LastError, string? LastError,
IReadOnlyList<GalaxyObject> Objects, IReadOnlyList<GalaxyObject> Objects,
GalaxyHierarchyIndex Index,
DashboardGalaxySummary DashboardSummary, DashboardGalaxySummary DashboardSummary,
int ObjectCount, int ObjectCount,
int AreaCount, int AreaCount,
@@ -31,6 +32,7 @@ public sealed record GalaxyHierarchyCacheEntry(
LastDeployTime: null, LastDeployTime: null,
LastError: null, LastError: null,
Objects: Array.Empty<GalaxyObject>(), Objects: Array.Empty<GalaxyObject>(),
Index: GalaxyHierarchyIndex.Empty,
DashboardSummary: DashboardGalaxySummary.Unknown, DashboardSummary: DashboardGalaxySummary.Unknown,
ObjectCount: 0, ObjectCount: 0,
AreaCount: 0, AreaCount: 0,
@@ -0,0 +1,106 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed class GalaxyHierarchyIndex
{
private GalaxyHierarchyIndex(
IReadOnlyList<GalaxyObjectView> objectViews,
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress)
{
ObjectViews = objectViews;
ObjectViewsById = objectViewsById;
TagsByAddress = tagsByAddress;
}
public static GalaxyHierarchyIndex Empty { get; } = new(
Array.Empty<GalaxyObjectView>(),
new Dictionary<int, GalaxyObjectView>(),
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
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);
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));
}
foreach (GalaxyAttribute attribute in obj.Attributes)
{
if (!string.IsNullOrWhiteSpace(attribute.FullTagReference))
{
tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path));
}
}
}
return new GalaxyHierarchyIndex(
views,
viewsById,
tagsByAddress);
}
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;
}
}
@@ -11,12 +11,36 @@ public static class GalaxyHierarchyProjector
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs = null) IReadOnlyList<string>? browseSubtreeGlobs = null)
{
return Project(
entry,
request,
browseSubtreeGlobs,
offset: 0,
pageSize: int.MaxValue);
}
public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs,
int offset,
int pageSize)
{ {
ArgumentNullException.ThrowIfNull(entry); ArgumentNullException.ThrowIfNull(entry);
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
}
IReadOnlyList<ObjectView> views = BuildViews(entry.Objects); if (pageSize <= 0)
ObjectView? root = ResolveRoot(request, views); {
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
}
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
GalaxyObjectView? root = ResolveRoot(request, views);
int? maxDepth = request.MaxDepth; int? maxDepth = request.MaxDepth;
if (maxDepth < 0) if (maxDepth < 0)
{ {
@@ -25,8 +49,10 @@ public static class GalaxyHierarchyProjector
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided.")); "DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
} }
List<GalaxyObject> filtered = []; List<GalaxyObject> page = [];
foreach (ObjectView view in views) int matchedCount = 0;
bool includeAttributes = IncludeAttributes(request);
foreach (GalaxyObjectView view in views)
{ {
if (!MatchesRoot(view, root, maxDepth) if (!MatchesRoot(view, root, maxDepth)
|| !MatchesBrowseSubtrees(view, browseSubtreeGlobs) || !MatchesBrowseSubtrees(view, browseSubtreeGlobs)
@@ -35,12 +61,17 @@ public static class GalaxyHierarchyProjector
continue; continue;
} }
filtered.Add(CloneObject(view.Object, IncludeAttributes(request))); if (matchedCount >= offset && page.Count < pageSize)
{
page.Add(CloneObject(view.Object, includeAttributes));
}
matchedCount++;
} }
return new GalaxyHierarchyQueryResult( return new GalaxyHierarchyQueryResult(
filtered, page,
filtered.Count, matchedCount,
ComputeFilterSignature(request, browseSubtreeGlobs)); ComputeFilterSignature(request, browseSubtreeGlobs));
} }
@@ -53,23 +84,9 @@ public static class GalaxyHierarchyProjector
return null; return null;
} }
foreach (GalaxyObject obj in entry.Objects) return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
{ ? lookup.Object
if (string.Equals(obj.TagName, tagAddress, StringComparison.OrdinalIgnoreCase)) : null;
{
return obj;
}
foreach (GalaxyAttribute attribute in obj.Attributes)
{
if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase))
{
return obj;
}
}
}
return null;
} }
public static GalaxyAttribute? FindAttributeForTag( public static GalaxyAttribute? FindAttributeForTag(
@@ -81,81 +98,25 @@ public static class GalaxyHierarchyProjector
return null; return null;
} }
foreach (GalaxyObject obj in entry.Objects) return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
{ ? lookup.Attribute
foreach (GalaxyAttribute attribute in obj.Attributes) : null;
{
if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase))
{
return attribute;
}
}
}
return null;
} }
public static string GetContainedPath( public static string GetContainedPath(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
int gobjectId) int gobjectId)
{ {
return BuildViews(entry.Objects) return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view)
.FirstOrDefault(view => view.Object.GobjectId == gobjectId) ? view.ContainedPath
?.ContainedPath ?? string.Empty; : string.Empty;
} }
private static IReadOnlyList<ObjectView> BuildViews(IReadOnlyList<GalaxyObject> objects) private static GalaxyObjectView? ResolveRoot(
{
Dictionary<int, GalaxyObject> byId = objects.ToDictionary(obj => obj.GobjectId);
List<ObjectView> views = new(objects.Count);
foreach (GalaxyObject obj in objects)
{
string path = BuildContainedPath(obj, byId);
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
views.Add(new ObjectView(obj, path, depth));
}
return views;
}
private static string BuildContainedPath(
GalaxyObject obj,
IReadOnlyDictionary<int, GalaxyObject> byId)
{
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 && byId.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 ObjectView? ResolveRoot(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
IReadOnlyList<ObjectView> views) IReadOnlyList<GalaxyObjectView> views)
{ {
ObjectView? root = request.RootCase switch GalaxyObjectView? root = request.RootCase switch
{ {
DiscoverHierarchyRequest.RootOneofCase.None => null, DiscoverHierarchyRequest.RootOneofCase.None => null,
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault( DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault(
@@ -176,8 +137,8 @@ public static class GalaxyHierarchyProjector
} }
private static bool MatchesRoot( private static bool MatchesRoot(
ObjectView view, GalaxyObjectView view,
ObjectView? root, GalaxyObjectView? root,
int? maxDepth) int? maxDepth)
{ {
if (root is null) if (root is null)
@@ -196,7 +157,7 @@ public static class GalaxyHierarchyProjector
} }
private static bool MatchesBrowseSubtrees( private static bool MatchesBrowseSubtrees(
ObjectView view, GalaxyObjectView view,
IReadOnlyList<string>? browseSubtreeGlobs) IReadOnlyList<string>? browseSubtreeGlobs)
{ {
return browseSubtreeGlobs is null return browseSubtreeGlobs is null
@@ -256,7 +217,7 @@ public static class GalaxyHierarchyProjector
return clone; return clone;
} }
private static string ComputeFilterSignature( public static string ComputeFilterSignature(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs) IReadOnlyList<string>? browseSubtreeGlobs)
{ {
@@ -282,6 +243,4 @@ public static class GalaxyHierarchyProjector
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash, 0, 12); return Convert.ToHexString(hash, 0, 12);
} }
private sealed record ObjectView(GalaxyObject Object, string ContainedPath, int Depth);
} }
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed record GalaxyObjectView(
GalaxyObject Object,
string ContainedPath,
int Depth);
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed record GalaxyTagLookup(
GalaxyObject Object,
GalaxyAttribute? Attribute,
string ContainedPath);
@@ -71,34 +71,32 @@ public sealed class GalaxyRepositoryGrpcService(
ResolveUnavailableMessage(entry))); ResolveUnavailableMessage(entry)));
} }
int pageSize = ResolvePageSize(request.PageSize);
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees(); 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( GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project(
entry, entry,
request, request,
browseSubtrees); browseSubtrees,
pageToken.Offset,
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, query.FilterSignature); pageSize);
int offset = pageToken.Offset; int offset = pageToken.Offset;
if (offset > query.Objects.Count) if (offset > query.TotalObjectCount)
{ {
throw new RpcException(new Status( throw new RpcException(new Status(
StatusCode.InvalidArgument, StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is outside the current hierarchy.")); "DiscoverHierarchy page_token is outside the current hierarchy."));
} }
int pageSize = ResolvePageSize(request.PageSize);
int take = Math.Min(pageSize, query.Objects.Count - offset);
DiscoverHierarchyReply reply = new() DiscoverHierarchyReply reply = new()
{ {
TotalObjectCount = query.TotalObjectCount, TotalObjectCount = query.TotalObjectCount,
}; };
for (int index = offset; index < offset + take; index++) reply.Objects.Add(query.Objects);
{
reply.Objects.Add(query.Objects[index]);
}
int nextOffset = offset + take; int nextOffset = offset + query.Objects.Count;
if (nextOffset < query.Objects.Count) if (nextOffset < query.TotalObjectCount)
{ {
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset); reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
} }
@@ -62,22 +62,20 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
} }
GalaxyHierarchyCacheEntry entry = cache.Current; GalaxyTagLookup? target = ResolveTarget(registration.TagAddress);
GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, registration.TagAddress); if (target is null)
if (obj is null)
{ {
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.")); return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
} }
string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId); if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
if (!MatchesPathOrTag(containedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
{ {
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope.")); return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
} }
if (constraints.MaxWriteClassification is { } maxClassification) if (constraints.MaxWriteClassification is { } maxClassification)
{ {
GalaxyAttribute? attribute = GalaxyHierarchyProjector.FindAttributeForTag(entry, registration.TagAddress); GalaxyAttribute? attribute = target.Attribute;
if (attribute is null) if (attribute is null)
{ {
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available.")); return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
@@ -115,32 +113,39 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints, ApiKeyConstraints constraints,
string tagAddress) string tagAddress)
{ {
GalaxyHierarchyCacheEntry entry = cache.Current; GalaxyTagLookup? target = ResolveTarget(tagAddress);
GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, tagAddress); if (target is null)
if (obj is null)
{ {
return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."); return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.");
} }
string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId); if (!MatchesPathOrTag(target.ContainedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs))
if (!MatchesPathOrTag(containedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs))
{ {
return new ConstraintFailure("read_scope", "Tag is outside the API key read scope."); return new ConstraintFailure("read_scope", "Tag is outside the API key read scope.");
} }
if (constraints.ReadAlarmOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm)) if (constraints.ReadAlarmOnly && target.Attribute is not { IsAlarm: true })
{ {
return new ConstraintFailure("read_alarm_only", "Object has no alarm-bearing attributes."); return new ConstraintFailure("read_alarm_only", "Tag is not an alarm-bearing attribute.");
} }
if (constraints.ReadHistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized)) if (constraints.ReadHistorizedOnly && target.Attribute is not { IsHistorized: true })
{ {
return new ConstraintFailure("read_historized_only", "Object has no historized attributes."); return new ConstraintFailure("read_historized_only", "Tag is not a historized attribute.");
} }
return null; return null;
} }
private GalaxyTagLookup? ResolveTarget(string tagAddress)
{
GalaxyHierarchyCacheEntry entry = cache.Current;
return !string.IsNullOrWhiteSpace(tagAddress)
&& entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
? lookup
: null;
}
private static bool MatchesPathOrTag( private static bool MatchesPathOrTag(
string containedPath, string containedPath,
string tagAddress, string tagAddress,
@@ -1,4 +1,5 @@
using MxGateway.Server.Galaxy; using MxGateway.Server.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Tests.Galaxy; namespace MxGateway.Tests.Galaxy;
@@ -52,6 +53,53 @@ public sealed class GalaxyHierarchyCacheTests
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData); Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
} }
[Fact]
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
{
GalaxyObject root = new()
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
};
GalaxyObject duplicate = new()
{
GobjectId = 1,
TagName = "DuplicateArea",
ContainedName = "DuplicateArea",
};
GalaxyObject child = new()
{
GobjectId = 2,
ParentGobjectId = 1,
TagName = "Pump_001",
ContainedName = "Pump",
Attributes =
{
new GalaxyAttribute
{
FullTagReference = "Pump_001.PV",
IsHistorized = true,
},
},
};
GalaxyObject orphan = new()
{
GobjectId = 3,
ParentGobjectId = 99,
TagName = "Orphan_001",
ContainedName = "Orphan",
};
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]);
Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath);
Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath);
Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object);
Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute);
Assert.Same(root, index.ObjectViewsById[1].Object);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock) private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
{ {
GalaxyRepositoryOptions options = new() GalaxyRepositoryOptions options = new()
@@ -59,7 +107,7 @@ public sealed class GalaxyHierarchyCacheTests
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;", ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1, CommandTimeoutSeconds = 1,
}; };
GalaxyRepository repository = new(options); MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock); return new GalaxyHierarchyCache(repository, notifier, clock);
} }
@@ -5,6 +5,7 @@ using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy; using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics; using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication; using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions; using MxGateway.Server.Sessions;
using MxGateway.Server.Workers; using MxGateway.Server.Workers;
@@ -228,6 +229,101 @@ public sealed class DashboardSnapshotServiceTests
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1); Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
} }
[Fact]
public void GetSnapshot_DoesNotSynchronouslyListApiKeys()
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new();
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
apiKeyAdminStore: apiKeyAdminStore);
DashboardSnapshot snapshot = service.GetSnapshot();
Assert.Empty(snapshot.ApiKeys);
Assert.Equal(0, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with
{
BrowseSubtrees = ["Area1/*"],
},
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
apiKeyAdminStore: apiKeyAdminStore);
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
.WatchSnapshotsAsync(cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot snapshot = enumerator.Current;
DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys);
Assert.Equal("operator01", key.KeyId);
Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees);
Assert.Equal(1, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
{
using GatewayMetrics metrics = new();
SequencedApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
new GatewayOptions
{
Dashboard = new DashboardOptions
{
SnapshotIntervalMilliseconds = 1,
},
},
apiKeyAdminStore: apiKeyAdminStore);
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
.WatchSnapshotsAsync(cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot first = enumerator.Current;
apiKeyAdminStore.FailNext = true;
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot second = enumerator.Current;
Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
Assert.Equal(2, apiKeyAdminStore.ListCount);
}
[Fact] [Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly() public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{ {
@@ -258,7 +354,8 @@ public sealed class DashboardSnapshotServiceTests
SessionRegistry registry, SessionRegistry registry,
GatewayMetrics metrics, GatewayMetrics metrics,
GatewayOptions? options = null, GatewayOptions? options = null,
IGalaxyHierarchyCache? galaxyHierarchyCache = null) IGalaxyHierarchyCache? galaxyHierarchyCache = null,
IApiKeyAdminStore? apiKeyAdminStore = null)
{ {
GatewayOptions resolvedOptions = options ?? new GatewayOptions GatewayOptions resolvedOptions = options ?? new GatewayOptions
{ {
@@ -274,7 +371,7 @@ public sealed class DashboardSnapshotServiceTests
metrics, metrics,
configurationProvider, configurationProvider,
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty), galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
new FakeApiKeyAdminStore(), apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
Options.Create(resolvedOptions)); Options.Create(resolvedOptions));
} }
@@ -287,14 +384,14 @@ public sealed class DashboardSnapshotServiceTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore private class FakeApiKeyAdminStore : IApiKeyAdminStore
{ {
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken) public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{ {
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]); return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
} }
@@ -317,6 +414,34 @@ public sealed class DashboardSnapshotServiceTests
} }
} }
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
{
public int ListCount { get; protected set; }
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
ListCount++;
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
}
}
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
{
public bool FailNext { get; set; }
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
if (FailNext)
{
FailNext = false;
ListCount++;
throw new InvalidOperationException("Simulated SQLite failure.");
}
return base.ListAsync(cancellationToken);
}
}
private static GatewaySession CreateSession( private static GatewaySession CreateSession(
string sessionId, string sessionId,
string? clientIdentity, string? clientIdentity,
@@ -217,6 +217,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Sequence = 7, Sequence = 7,
LastSuccessAt = DateTimeOffset.UtcNow, LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects, Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with DashboardSummary = DashboardGalaxySummary.Unknown with
{ {
Status = DashboardGalaxyStatus.Healthy, Status = DashboardGalaxyStatus.Healthy,
@@ -70,6 +70,60 @@ public sealed class ConstraintEnforcerTests
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
} }
[Fact]
public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized()
{
ConstraintEnforcer enforcer = CreateEnforcer(out _);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
ReadHistorizedOnly = true,
});
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
identity,
"Pump_001.NonHistorized",
CancellationToken.None);
Assert.NotNull(failure);
Assert.Equal("read_historized_only", failure.ConstraintName);
}
[Fact]
public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm()
{
ConstraintEnforcer enforcer = CreateEnforcer(out _);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
ReadAlarmOnly = true,
});
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
identity,
"Pump_001.PV",
CancellationToken.None);
Assert.NotNull(failure);
Assert.Equal("read_alarm_only", failure.ConstraintName);
}
[Fact]
public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag()
{
ConstraintEnforcer enforcer = CreateEnforcer(out _);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
ReadHistorizedOnly = true,
});
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
identity,
"Pump_001",
CancellationToken.None);
Assert.NotNull(failure);
Assert.Equal("read_historized_only", failure.ConstraintName);
}
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore) private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore)
{ {
auditStore = new FakeAuditStore(); auditStore = new FakeAuditStore();
@@ -105,49 +159,63 @@ public sealed class ConstraintEnforcerTests
private static GalaxyHierarchyCacheEntry CreateEntry() private static GalaxyHierarchyCacheEntry CreateEntry()
{ {
return GalaxyHierarchyCacheEntry.Empty with IReadOnlyList<GalaxyObject> objects =
{ [
Status = GalaxyCacheStatus.Healthy, new GalaxyObject
Objects = {
[ GobjectId = 1,
new GalaxyObject TagName = "Area1",
ContainedName = "Area1",
},
new GalaxyObject
{
GobjectId = 2,
TagName = "Pump_001",
ContainedName = "Pump",
ParentGobjectId = 1,
Attributes =
{ {
GobjectId = 1, new GalaxyAttribute
TagName = "Area1",
ContainedName = "Area1",
},
new GalaxyObject
{
GobjectId = 2,
TagName = "Pump_001",
ContainedName = "Pump",
ParentGobjectId = 1,
Attributes =
{
new GalaxyAttribute
{ {
AttributeName = "PV", AttributeName = "PV",
FullTagReference = "Pump_001.PV", FullTagReference = "Pump_001.PV",
SecurityClassification = 2, SecurityClassification = 2,
IsHistorized = true, IsHistorized = true,
}, },
},
},
new GalaxyObject
{
GobjectId = 3,
TagName = "Other_001",
ContainedName = "Other",
Attributes =
{
new GalaxyAttribute new GalaxyAttribute
{ {
AttributeName = "PV", AttributeName = "Alarm",
FullTagReference = "Other_001.PV", FullTagReference = "Pump_001.Alarm",
IsAlarm = true,
},
new GalaxyAttribute
{
AttributeName = "NonHistorized",
FullTagReference = "Pump_001.NonHistorized",
}, },
}, },
}, },
], new GalaxyObject
{
GobjectId = 3,
TagName = "Other_001",
ContainedName = "Other",
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Other_001.PV",
},
},
},
];
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown, DashboardSummary = DashboardGalaxySummary.Unknown,
}; };
} }