Add Galaxy repository API and clients
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using MxGateway.Server.Galaxy;
|
||||
|
||||
namespace 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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using GalaxyDb = MxGateway.Server.Galaxy;
|
||||
using ProtoGalaxyRepository = MxGateway.Contracts.Proto.Galaxy.GalaxyRepository;
|
||||
|
||||
namespace 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.GalaxyRepository repository,
|
||||
GalaxyDb.IGalaxyHierarchyCache cache,
|
||||
GalaxyDb.IGalaxyDeployNotifier notifier,
|
||||
ILogger<GalaxyRepositoryGrpcService> logger) : ProtoGalaxyRepository.GalaxyRepositoryBase
|
||||
{
|
||||
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
||||
|
||||
public override async Task<TestConnectionReply> TestConnection(
|
||||
TestConnectionRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
return new TestConnectionReply { Ok = ok };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
|
||||
DiscoverHierarchyRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
if (!entry.HasData || entry.Reply is null)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
ResolveUnavailableMessage(entry)));
|
||||
}
|
||||
|
||||
// Same materialized reply is shared across all clients — gRPC serialization is
|
||||
// read-only and the entry is replaced atomically on the next refresh.
|
||||
return entry.Reply;
|
||||
}
|
||||
|
||||
public override async Task WatchDeployEvents(
|
||||
WatchDeployEventsRequest request,
|
||||
IServerStreamWriter<DeployEvent> responseStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
|
||||
|
||||
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), 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 static DeployEvent MapDeployEvent(GalaxyDb.GalaxyDeployEventInfo info)
|
||||
{
|
||||
DeployEvent ev = new()
|
||||
{
|
||||
Sequence = (ulong)info.Sequence,
|
||||
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
|
||||
ObjectCount = info.ObjectCount,
|
||||
AttributeCount = info.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.",
|
||||
};
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Style",
|
||||
"IDE0051:Remove unused private members",
|
||||
Justification = "Kept for parity with prior SQL exception mapping; future direct-SQL paths reuse it.")]
|
||||
private RpcException MapSqlException(SqlException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Galaxy repository query failed.");
|
||||
return new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
"Galaxy repository is unavailable."));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using Grpc.Core;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
@@ -13,6 +15,7 @@ public sealed class MxAccessGatewayService(
|
||||
MxAccessGrpcRequestValidator requestValidator,
|
||||
MxAccessGrpcMapper mapper,
|
||||
IEventStreamService eventStreamService,
|
||||
GatewayMetrics metrics,
|
||||
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
|
||||
{
|
||||
public override async Task<OpenSessionReply> OpenSession(
|
||||
@@ -110,7 +113,9 @@ public sealed class MxAccessGatewayService(
|
||||
.WithCancellation(context.CancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
|
||||
metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not RpcException)
|
||||
|
||||
Reference in New Issue
Block a user