Add Galaxy repository API and clients

This commit is contained in:
Joseph Doherty
2026-04-29 07:27:00 -04:00
parent 047d875fe6
commit 133c83029b
103 changed files with 22788 additions and 39 deletions
@@ -0,0 +1,90 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyDeployNotifierTests
{
[Fact]
public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish()
{
GalaxyDeployNotifier notifier = new();
using CancellationTokenSource cts = new();
IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
ValueTask<bool> moveNext = enumerator.MoveNextAsync();
Assert.False(moveNext.IsCompleted);
GalaxyDeployEventInfo published = new(
Sequence: 1,
ObservedAt: DateTimeOffset.UtcNow,
TimeOfLastDeploy: DateTimeOffset.UtcNow,
ObjectCount: 5,
AttributeCount: 25);
notifier.Publish(published);
Assert.True(await moveNext.AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(published, enumerator.Current);
await cts.CancelAsync();
await enumerator.DisposeAsync();
}
[Fact]
public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately()
{
GalaxyDeployNotifier notifier = new();
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, 9);
notifier.Publish(first);
using CancellationTokenSource cts = new();
await using IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(first, enumerator.Current);
await cts.CancelAsync();
}
[Fact]
public async Task Publish_FansOutToAllSubscribers()
{
GalaxyDeployNotifier notifier = new();
using CancellationTokenSource cts = new();
await using IAsyncEnumerator<GalaxyDeployEventInfo> a = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
await using IAsyncEnumerator<GalaxyDeployEventInfo> b = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
GalaxyDeployEventInfo info = new(1, DateTimeOffset.UtcNow, null, 0, 0);
notifier.Publish(info);
Assert.True(await a.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.True(await b.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(info, a.Current);
Assert.Same(info, b.Current);
await cts.CancelAsync();
}
[Fact]
public void Latest_TracksMostRecentPublish()
{
GalaxyDeployNotifier notifier = new();
Assert.Null(notifier.Latest);
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, null, 0, 0);
GalaxyDeployEventInfo second = new(2, DateTimeOffset.UtcNow, null, 0, 0);
notifier.Publish(first);
notifier.Publish(second);
Assert.Same(second, notifier.Latest);
}
}
@@ -0,0 +1,74 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyHierarchyCacheTests
{
[Fact]
public void Current_BeforeAnyRefresh_ReturnsEmpty()
{
GalaxyDeployNotifier notifier = new();
GalaxyHierarchyCache cache = CreateCache(notifier, new ManualTimeProvider());
GalaxyHierarchyCacheEntry entry = cache.Current;
Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status);
Assert.False(entry.HasData);
Assert.Equal(0, entry.ObjectCount);
Assert.Null(entry.Reply);
}
[Fact]
public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish()
{
GalaxyDeployNotifier notifier = new();
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z"));
GalaxyHierarchyCache cache = CreateCache(notifier, clock);
await cache.RefreshAsync(CancellationToken.None);
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
Assert.False(string.IsNullOrWhiteSpace(cache.Current.LastError));
Assert.Null(notifier.Latest);
Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
}
[Fact]
public void HasData_OnHealthyEntry_IsTrue()
{
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
LastSuccessAt = DateTimeOffset.UtcNow,
ObjectCount = 1,
};
Assert.True(entry.HasData);
}
[Fact]
public void HasData_OnUnknownEntry_IsFalse()
{
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1,
};
GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock);
}
private sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider
{
private DateTimeOffset _now = start == default ? DateTimeOffset.UtcNow : start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now += duration;
}
}
@@ -0,0 +1,109 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Grpc;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyProtoMapperTests
{
[Fact]
public void MapAttribute_PreservesAllScalarFields()
{
GalaxyAttributeRow row = new()
{
GobjectId = 42,
TagName = "Pump_001",
AttributeName = "Speed",
FullTagReference = "Pump_001.Speed",
MxDataType = 3,
DataTypeName = "Float",
IsArray = false,
ArrayDimension = null,
MxAttributeCategory = 5,
SecurityClassification = 2,
IsHistorized = true,
IsAlarm = false,
};
GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row);
Assert.Equal("Speed", proto.AttributeName);
Assert.Equal("Pump_001.Speed", proto.FullTagReference);
Assert.Equal(3, proto.MxDataType);
Assert.Equal("Float", proto.DataTypeName);
Assert.False(proto.IsArray);
Assert.Equal(0, proto.ArrayDimension);
Assert.False(proto.ArrayDimensionPresent);
Assert.Equal(5, proto.MxAttributeCategory);
Assert.Equal(2, proto.SecurityClassification);
Assert.True(proto.IsHistorized);
Assert.False(proto.IsAlarm);
}
[Fact]
public void MapAttribute_ArrayDimensionPresentFlag_DistinguishesNullFromZero()
{
GalaxyAttributeRow withDim = new() { ArrayDimension = 0, IsArray = true };
GalaxyAttributeRow withoutDim = new() { ArrayDimension = null, IsArray = false };
Assert.True(GalaxyProtoMapper.MapAttribute(withDim).ArrayDimensionPresent);
Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withDim).ArrayDimension);
Assert.False(GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimensionPresent);
Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimension);
}
[Fact]
public void MapAttribute_NullDataTypeName_BecomesEmptyString()
{
GalaxyAttributeRow row = new() { DataTypeName = null };
GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row);
Assert.Equal(string.Empty, proto.DataTypeName);
}
[Fact]
public void MapHierarchy_GroupsAttributesByGobjectId()
{
List<GalaxyHierarchyRow> hierarchy =
[
new() { GobjectId = 1, TagName = "A", BrowseName = "A", TemplateChain = ["RootTpl"] },
new() { GobjectId = 2, TagName = "B", BrowseName = "B", ParentGobjectId = 1 },
new() { GobjectId = 3, TagName = "C", BrowseName = "C", ParentGobjectId = 1 },
];
List<GalaxyAttributeRow> attributes =
[
new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X" },
new() { GobjectId = 2, AttributeName = "Y1", FullTagReference = "B.Y1" },
new() { GobjectId = 2, AttributeName = "Y2", FullTagReference = "B.Y2" },
];
List<GalaxyObject> result = GalaxyProtoMapper.MapHierarchy(hierarchy, attributes).ToList();
Assert.Equal(3, result.Count);
Assert.Single(result[0].Attributes);
Assert.Equal("X", result[0].Attributes[0].AttributeName);
Assert.Equal(2, result[1].Attributes.Count);
Assert.Empty(result[2].Attributes);
}
[Fact]
public void MapObject_CopiesTemplateChain()
{
GalaxyHierarchyRow row = new()
{
GobjectId = 5,
TagName = "Engine_001",
ContainedName = "Engine",
BrowseName = "Engine",
TemplateChain = ["EngineTpl", "AppEngineBase"],
};
GalaxyObject proto = GalaxyProtoMapper.MapObject(
row,
new Dictionary<int, List<GalaxyAttributeRow>>());
Assert.Equal(new[] { "EngineTpl", "AppEngineBase" }, proto.TemplateChain);
}
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -171,6 +172,51 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
}
[Fact]
public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache()
{
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
Hierarchy =
[
new GalaxyHierarchyRow { GobjectId = 1, TagName = "Pump_001", BrowseName = "Pump_001", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump_002", BrowseName = "Pump_002", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
new GalaxyHierarchyRow { GobjectId = 3, TagName = "Area_A", BrowseName = "Area_A", CategoryId = 13, IsArea = true, TemplateChain = ["$Area"] },
],
Attributes =
[
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Speed", IsHistorized = true },
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Status", IsAlarm = true },
],
ObjectCount = 3,
AreaCount = 1,
AttributeCount = 2,
HistorizedAttributeCount = 1,
AlarmAttributeCount = 1,
};
using GatewayMetrics metrics = new();
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
galaxyHierarchyCache: new StubGalaxyHierarchyCache(entry));
DashboardSnapshot snapshot = service.GetSnapshot();
Assert.Equal(DashboardGalaxyStatus.Healthy, snapshot.Galaxy.Status);
Assert.Equal(3, snapshot.Galaxy.ObjectCount);
Assert.Equal(1, snapshot.Galaxy.AreaCount);
Assert.Equal(2, snapshot.Galaxy.AttributeCount);
Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName);
Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -200,7 +246,8 @@ public sealed class DashboardSnapshotServiceTests
private static DashboardSnapshotService CreateService(
SessionRegistry registry,
GatewayMetrics metrics,
GatewayOptions? options = null)
GatewayOptions? options = null,
IGalaxyHierarchyCache? galaxyHierarchyCache = null)
{
GatewayOptions resolvedOptions = options ?? new GatewayOptions
{
@@ -215,9 +262,19 @@ public sealed class DashboardSnapshotServiceTests
registry,
metrics,
configurationProvider,
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
Options.Create(resolvedOptions));
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
public GalaxyHierarchyCacheEntry Current { get; } = current;
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private static GatewaySession CreateSession(
string sessionId,
string? clientIdentity,
@@ -174,6 +174,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
_metrics,
NullLogger<MxAccessGatewayService>.Instance);
}
@@ -1,3 +1,4 @@
using System.Diagnostics.Metrics;
using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
@@ -5,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
@@ -163,6 +165,50 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
}
[Fact]
public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration()
{
using GatewayMetrics metrics = new();
using MeterListener listener = new();
List<string> families = [];
listener.InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == GatewayMetrics.MeterName
&& instrument.Name == "mxgateway.events.stream_send.duration")
{
meterListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<double>(
(instrument, measurement, tags, _) =>
{
if (instrument.Name != "mxgateway.events.stream_send.duration")
{
return;
}
foreach (KeyValuePair<string, object?> tag in tags)
{
if (tag.Key == "family" && tag.Value is string family)
{
families.Add(family);
}
}
});
listener.Start();
FakeSessionManager sessionManager = new();
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
MxAccessGatewayService service = CreateService(sessionManager, metrics: metrics);
TestServerStreamWriter<MxEvent> writer = new();
await service.StreamEvents(
new StreamEventsRequest { SessionId = "session-1" },
writer,
new TestServerCallContext());
Assert.Equal([MxEventFamily.OnDataChange.ToString()], families);
}
[Fact]
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
{
@@ -178,7 +224,8 @@ public sealed class MxAccessGatewayServiceTests
private static MxAccessGatewayService CreateService(
FakeSessionManager sessionManager,
IGatewayRequestIdentityAccessor? identityAccessor = null)
IGatewayRequestIdentityAccessor? identityAccessor = null,
GatewayMetrics? metrics = null)
{
return new MxAccessGatewayService(
sessionManager,
@@ -186,6 +233,7 @@ public sealed class MxAccessGatewayServiceTests
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
metrics ?? new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance);
}
@@ -18,7 +18,7 @@ public sealed class GatewayMetricsTests
metrics.EventReceived("session-1", "OnDataChange");
metrics.EventReceived("session-1", "OnDataChange");
metrics.SetWorkerEventQueueDepth(7);
metrics.SetGrpcEventStreamQueueDepth(3);
metrics.AdjustGrpcEventStreamQueueDepth(3);
metrics.QueueOverflow("session-events");
metrics.Fault("CommandTimeout");
metrics.WorkerKilled("CommandTimeout");
@@ -1,4 +1,5 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Security.Authorization;
@@ -9,6 +10,9 @@ public sealed class GatewayGrpcScopeResolverTests
[InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)]
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
[InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)]
[InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope(
Type requestType,
string expectedScope)