rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx

Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 16:22:23 -04:00
parent 867bf18116
commit dc9c0c950c
491 changed files with 32854 additions and 8414 deletions
@@ -0,0 +1,163 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Server.Grpc;
public sealed class EventStreamService(
ISessionManager sessionManager,
IOptions<GatewayOptions> options,
MxAccessGrpcMapper mapper,
GatewayMetrics metrics,
ILogger<EventStreamService> logger) : IEventStreamService
{
/// <summary>
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <param name="request">Stream events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of MX events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession? session) || session is null)
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotFound,
$"Session {request.SessionId} was not found.");
}
using IDisposable subscriber = session.AttachEventSubscriber(
options.Value.Sessions.AllowMultipleEventSubscribers);
using CancellationTokenSource streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
int streamQueueDepth = 0;
Channel<MxEvent> eventQueue = Channel.CreateBounded<MxEvent>(
new BoundedChannelOptions(options.Value.Events.QueueCapacity)
{
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.Wait,
AllowSynchronousContinuations = false,
});
Task producerTask = ProduceEventsAsync(
session,
request.AfterWorkerSequence,
eventQueue.Writer,
() =>
{
Interlocked.Increment(ref streamQueueDepth);
metrics.AdjustGrpcEventStreamQueueDepth(1);
},
streamCts.Token);
try
{
await foreach (MxEvent mxEvent in eventQueue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
Interlocked.Decrement(ref streamQueueDepth);
metrics.AdjustGrpcEventStreamQueueDepth(-1);
yield return mxEvent;
}
await producerTask.ConfigureAwait(false);
}
finally
{
await streamCts.CancelAsync().ConfigureAwait(false);
subscriber.Dispose();
try
{
await producerTask.ConfigureAwait(false);
}
catch (OperationCanceledException) when (streamCts.IsCancellationRequested)
{
}
catch (Exception exception)
{
logger.LogDebug(
exception,
"Event stream producer stopped for session {SessionId}.",
request.SessionId);
}
int remainingDepth = Interlocked.Exchange(ref streamQueueDepth, 0);
if (remainingDepth > 0)
{
metrics.AdjustGrpcEventStreamQueueDepth(-remainingDepth);
}
metrics.StreamDisconnected("Detached");
}
}
private async Task ProduceEventsAsync(
GatewaySession session,
ulong afterWorkerSequence,
ChannelWriter<MxEvent> writer,
Action eventQueued,
CancellationToken cancellationToken)
{
try
{
await foreach (WorkerEvent workerEvent in session
.ReadEventsAsync(cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
MxEvent publicEvent = mapper.MapEvent(workerEvent);
if (publicEvent.WorkerSequence <= afterWorkerSequence)
{
continue;
}
if (!writer.TryWrite(publicEvent))
{
string message = $"Session {session.SessionId} event stream queue overflowed.";
metrics.QueueOverflow("grpc-event-stream");
if (options.Value.Events.BackpressurePolicy == EventBackpressurePolicy.FailFast)
{
session.MarkFaulted(message);
metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
}
else
{
logger.LogDebug(
"Disconnecting event stream for session {SessionId} after queue overflow.",
session.SessionId);
}
writer.TryComplete(new SessionManagerException(
SessionManagerErrorCode.EventQueueOverflow,
message));
return;
}
eventQueued();
}
writer.TryComplete();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
writer.TryComplete();
}
catch (Exception exception)
{
if (exception is WorkerClientException)
{
session.MarkFaulted(exception.Message);
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
}
writer.TryComplete(exception);
}
}
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.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
{
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
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);
}
}
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
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;
}
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
/// <param name="row">Attribute row from Galaxy Repository.</param>
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,288 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using GalaxyDb = ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ProtoGalaxyRepository = ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepository;
namespace ZB.MOM.WW.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.IGalaxyRepository repository,
GalaxyDb.IGalaxyHierarchyCache cache,
GalaxyDb.IGalaxyDeployNotifier notifier,
IGatewayRequestIdentityAccessor identityAccessor,
ILogger<GalaxyRepositoryGrpcService> logger) : ProtoGalaxyRepository.GalaxyRepositoryBase
{
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
private const int DefaultDiscoverPageSize = 1000;
private const int MaxDiscoverPageSize = 5000;
/// <inheritdoc />
public override async Task<TestConnectionReply> TestConnection(
TestConnectionRequest request,
ServerCallContext context)
{
bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false);
return new TestConnectionReply { Ok = ok };
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
DiscoverHierarchyRequest 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)));
}
int pageSize = ResolvePageSize(request.PageSize);
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(
entry,
request,
browseSubtrees,
pageToken.Offset,
pageSize);
int offset = pageToken.Offset;
if (offset > query.TotalObjectCount)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is outside the current hierarchy."));
}
DiscoverHierarchyReply reply = new()
{
TotalObjectCount = query.TotalObjectCount,
};
reply.Objects.Add(query.Objects);
int nextOffset = offset + query.Objects.Count;
if (nextOffset < query.TotalObjectCount)
{
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
}
return reply;
}
/// <inheritdoc />
public override async Task WatchDeployEvents(
WatchDeployEventsRequest request,
IServerStreamWriter<DeployEvent> responseStream,
ServerCallContext context)
{
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
// The caller's identity (and therefore its browse-subtree constraints) is fixed
// for the lifetime of the stream, so resolve the subtrees once rather than per
// streamed event.
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
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, browseSubtrees), 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 DeployEvent MapDeployEvent(
GalaxyDb.GalaxyDeployEventInfo info,
IReadOnlyList<string> browseSubtrees)
{
int objectCount = info.ObjectCount;
int attributeCount = info.AttributeCount;
if (browseSubtrees.Count > 0 && cache.Current.HasData)
{
GalaxyDb.GalaxyHierarchyQueryResult scoped = GalaxyDb.GalaxyHierarchyProjector.Project(
cache.Current,
new DiscoverHierarchyRequest(),
browseSubtrees);
objectCount = scoped.TotalObjectCount;
attributeCount = scoped.Objects.Sum(obj => obj.Attributes.Count);
}
DeployEvent ev = new()
{
Sequence = (ulong)info.Sequence,
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
ObjectCount = objectCount,
AttributeCount = 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.",
};
private static int ResolvePageSize(int requestedPageSize)
{
if (requestedPageSize < 0)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_size must be greater than zero when provided."));
}
int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize;
return Math.Min(pageSize, MaxDiscoverPageSize);
}
private IReadOnlyList<string> ResolveBrowseSubtrees()
{
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
return constraints.BrowseSubtrees;
}
private static string FormatPageToken(long sequence, string filterSignature, int offset)
{
return string.Concat(
sequence.ToString(System.Globalization.CultureInfo.InvariantCulture),
":",
filterSignature,
":",
offset.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
private static PageToken ParsePageToken(string pageToken, long currentSequence, string currentFilterSignature)
{
if (string.IsNullOrWhiteSpace(pageToken))
{
return new PageToken(currentSequence, currentFilterSignature, Offset: 0);
}
string[] parts = pageToken.Split(':', count: 3);
if (parts.Length != 3
|| !long.TryParse(
parts[0],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out long sequence)
|| !int.TryParse(
parts[2],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int offset)
|| offset < 0)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is invalid."));
}
if (sequence != currentSequence)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is stale."));
}
if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal))
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token does not match the current filters."));
}
return new PageToken(sequence, parts[1], offset);
}
private sealed record PageToken(long Sequence, string FilterSignature, int Offset);
[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."));
}
}
@@ -0,0 +1,18 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Grpc;
/// <summary>
/// Streams MXAccess events to gRPC clients.
/// </summary>
public interface IEventStreamService
{
/// <summary>
/// Streams events for the specified session to the caller.
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,907 @@
using System.Diagnostics;
using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Server.Grpc;
/// <summary>gRPC service implementation for MXAccess Gateway operations.</summary>
public sealed class MxAccessGatewayService(
ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor,
IConstraintEnforcer constraintEnforcer,
MxAccessGrpcRequestValidator requestValidator,
MxAccessGrpcMapper mapper,
IEventStreamService eventStreamService,
GatewayMetrics metrics,
ILogger<MxAccessGatewayService> logger,
IGatewayAlarmService alarmService) : MxAccessGateway.MxAccessGatewayBase
{
/// <inheritdoc />
public override async Task<OpenSessionReply> OpenSession(
OpenSessionRequest request,
ServerCallContext context)
{
try
{
requestValidator.ValidateOpenSession(request);
GatewaySession session = await sessionManager
.OpenSessionAsync(
SessionOpenRequest.FromContract(request),
ResolveClientIdentity(),
context.CancellationToken)
.ConfigureAwait(false);
OpenSessionReply reply = new()
{
SessionId = session.SessionId,
BackendName = session.BackendName,
WorkerProcessId = session.WorkerProcessId ?? 0,
WorkerProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
GatewayProtocolVersion = GatewayContractInfo.GatewayProtocolVersion,
DefaultCommandTimeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(session.CommandTimeout),
ProtocolStatus = MxAccessGrpcMapper.Ok(),
};
reply.Capabilities.Add("unary-open-session");
reply.Capabilities.Add("unary-close-session");
reply.Capabilities.Add("unary-invoke");
reply.Capabilities.Add("server-stream-events");
reply.Capabilities.Add("bulk-subscribe-commands");
reply.Capabilities.Add("bulk-read-commands");
reply.Capabilities.Add("bulk-write-commands");
reply.Capabilities.Add("unary-acknowledge-alarm");
reply.Capabilities.Add("server-stream-active-alarms");
return reply;
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
/// <inheritdoc />
public override async Task<CloseSessionReply> CloseSession(
CloseSessionRequest request,
ServerCallContext context)
{
try
{
requestValidator.ValidateCloseSession(request);
SessionCloseResult result = await sessionManager
.CloseSessionAsync(request.SessionId, context.CancellationToken)
.ConfigureAwait(false);
return new CloseSessionReply
{
SessionId = result.SessionId,
FinalState = result.FinalState,
ProtocolStatus = MxAccessGrpcMapper.Ok(result.AlreadyClosed ? "Session was already closed." : "Session closed."),
};
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
/// <inheritdoc />
public override async Task<MxCommandReply> Invoke(
MxCommandRequest request,
ServerCallContext context)
{
try
{
requestValidator.ValidateInvoke(request);
GatewaySession session = ResolveSession(request.SessionId);
MxCommand command = request.Command;
BulkConstraintPlan? bulkConstraintPlan = await ApplyConstraintsAsync(
session,
command,
context.CancellationToken)
.ConfigureAwait(false);
MxCommand commandToInvoke = bulkConstraintPlan?.Command ?? command;
if (bulkConstraintPlan is { HasAllowedItems: false })
{
return bulkConstraintPlan.CreateDeniedReply(request);
}
MxCommandRequest invokeRequest = request.Clone();
invokeRequest.Command = commandToInvoke;
WorkerCommand workerCommand = mapper.MapCommand(invokeRequest);
WorkerCommandReply workerReply = await sessionManager
.InvokeAsync(request.SessionId, workerCommand, context.CancellationToken)
.ConfigureAwait(false);
MxCommandReply publicReply = mapper.MapCommandReply(workerReply);
if (bulkConstraintPlan is not null)
{
publicReply = bulkConstraintPlan.MergeDeniedInto(publicReply);
}
session.TrackCommandReply(commandToInvoke, publicReply);
return publicReply;
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
/// <inheritdoc />
public override async Task StreamEvents(
StreamEventsRequest request,
IServerStreamWriter<MxEvent> responseStream,
ServerCallContext context)
{
try
{
requestValidator.ValidateStreamEvents(request);
await foreach (MxEvent publicEvent in eventStreamService
.StreamEventsAsync(request, context.CancellationToken)
.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)
{
throw MapException(exception);
}
}
/// <inheritdoc />
/// <remarks>
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
/// session-less: the gateway routes it through the always-on
/// <see cref="IGatewayAlarmService"/> monitor session. An
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
/// </remarks>
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
AcknowledgeAlarmRequest request,
ServerCallContext context)
{
try
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrEmpty(request.AlarmFullReference))
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
}
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
/// <inheritdoc />
/// <remarks>
/// Surfaces the public StreamAlarms RPC — the session-less central
/// alarm feed. The stream opens with one <c>active_alarm</c> per
/// currently-active alarm, then a single <c>snapshot_complete</c>, then
/// a <c>transition</c> for every subsequent change. Served by the
/// gateway's always-on <see cref="IGatewayAlarmService"/> monitor; any
/// number of clients fan out from the single monitor.
/// </remarks>
public override async Task StreamAlarms(
StreamAlarmsRequest request,
IServerStreamWriter<AlarmFeedMessage> responseStream,
ServerCallContext context)
{
try
{
ArgumentNullException.ThrowIfNull(request);
await foreach (AlarmFeedMessage message in alarmService
.StreamAsync(request.AlarmFilterPrefix, context.CancellationToken)
.WithCancellation(context.CancellationToken)
.ConfigureAwait(false))
{
await responseStream.WriteAsync(message).ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
private string? ResolveClientIdentity()
{
return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId;
}
private GatewaySession ResolveSession(string sessionId)
{
if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session) || session is null)
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotFound,
$"Session {sessionId} was not found.");
}
return session;
}
private async Task<BulkConstraintPlan?> ApplyConstraintsAsync(
GatewaySession session,
MxCommand command,
CancellationToken cancellationToken)
{
ApiKeyIdentity? identity = identityAccessor.Current;
switch (command.Kind)
{
case MxCommandKind.AddItem:
await EnforceReadTagAsync(identity, command.Kind, command.AddItem.ItemDefinition, cancellationToken)
.ConfigureAwait(false);
return null;
case MxCommandKind.AddItem2:
await EnforceReadTagAsync(identity, command.Kind, command.AddItem2.ItemDefinition, cancellationToken)
.ConfigureAwait(false);
return null;
case MxCommandKind.AddItemBulk:
return await FilterTagBulkAsync(
identity,
command,
command.AddItemBulk.ServerHandle,
command.AddItemBulk.TagAddresses,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.SubscribeBulk:
return await FilterTagBulkAsync(
identity,
command,
command.SubscribeBulk.ServerHandle,
command.SubscribeBulk.TagAddresses,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.AdviseItemBulk:
return await FilterHandleBulkAsync(
identity,
session,
command,
command.AdviseItemBulk.ServerHandle,
command.AdviseItemBulk.ItemHandles,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.ReadBulk:
return await FilterReadBulkAsync(
identity,
command,
command.ReadBulk.ServerHandle,
command.ReadBulk.TagAddresses,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.WriteBulk:
return await FilterWriteBulkAsync(
identity,
session,
command,
command.WriteBulk.ServerHandle,
command.WriteBulk.Entries,
entry => entry.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.Write2Bulk:
return await FilterWriteBulkAsync(
identity,
session,
command,
command.Write2Bulk.ServerHandle,
command.Write2Bulk.Entries,
entry => entry.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.WriteSecuredBulk:
return await FilterWriteBulkAsync(
identity,
session,
command,
command.WriteSecuredBulk.ServerHandle,
command.WriteSecuredBulk.Entries,
entry => entry.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.WriteSecured2Bulk:
return await FilterWriteBulkAsync(
identity,
session,
command,
command.WriteSecured2Bulk.ServerHandle,
command.WriteSecured2Bulk.Entries,
entry => entry.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
case MxCommandKind.Write:
await EnforceWriteHandleAsync(
identity,
session,
command.Kind,
command.Write.ServerHandle,
command.Write.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
return null;
case MxCommandKind.Write2:
await EnforceWriteHandleAsync(
identity,
session,
command.Kind,
command.Write2.ServerHandle,
command.Write2.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
return null;
case MxCommandKind.WriteSecured:
await EnforceWriteHandleAsync(
identity,
session,
command.Kind,
command.WriteSecured.ServerHandle,
command.WriteSecured.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
return null;
case MxCommandKind.WriteSecured2:
await EnforceWriteHandleAsync(
identity,
session,
command.Kind,
command.WriteSecured2.ServerHandle,
command.WriteSecured2.ItemHandle,
cancellationToken)
.ConfigureAwait(false);
return null;
default:
return null;
}
}
private async Task EnforceReadTagAsync(
ApiKeyIdentity? identity,
MxCommandKind commandKind,
string tagAddress,
CancellationToken cancellationToken)
{
ConstraintFailure? failure = await constraintEnforcer
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
.ConfigureAwait(false);
if (failure is null)
{
return;
}
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), tagAddress, failure, cancellationToken)
.ConfigureAwait(false);
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
}
private async Task EnforceWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
MxCommandKind commandKind,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken)
{
ConstraintFailure? failure = await constraintEnforcer
.CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
if (failure is null)
{
return;
}
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken)
.ConfigureAwait(false);
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
}
private async Task<BulkConstraintPlan?> FilterTagBulkAsync(
ApiKeyIdentity? identity,
MxCommand command,
int serverHandle,
IReadOnlyList<string> tagAddresses,
CancellationToken cancellationToken)
{
Dictionary<int, SubscribeResult> denied = [];
List<string> allowed = [];
for (int index = 0; index < tagAddresses.Count; index++)
{
string tagAddress = tagAddresses[index];
ConstraintFailure? failure = await constraintEnforcer
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
.ConfigureAwait(false);
if (failure is null)
{
allowed.Add(tagAddress);
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken)
.ConfigureAwait(false);
denied[index] = new SubscribeResult
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
WasSuccessful = false,
ErrorMessage = failure.Message,
};
}
if (denied.Count == 0)
{
return null;
}
MxCommand filtered = command.Clone();
if (filtered.Kind == MxCommandKind.AddItemBulk)
{
filtered.AddItemBulk.TagAddresses.Clear();
filtered.AddItemBulk.TagAddresses.Add(allowed);
}
else
{
filtered.SubscribeBulk.TagAddresses.Clear();
filtered.SubscribeBulk.TagAddresses.Add(allowed);
}
return new SubscribeBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
}
private async Task<BulkConstraintPlan?> FilterReadBulkAsync(
ApiKeyIdentity? identity,
MxCommand command,
int serverHandle,
IReadOnlyList<string> tagAddresses,
CancellationToken cancellationToken)
{
// Mirrors FilterTagBulkAsync but produces BulkReadResult denial entries
// so the reply payload merges into BulkReadReply.Results, not
// BulkSubscribeReply.Results.
Dictionary<int, BulkReadResult> denied = [];
List<string> allowed = [];
for (int index = 0; index < tagAddresses.Count; index++)
{
string tagAddress = tagAddresses[index];
ConstraintFailure? failure = await constraintEnforcer
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
.ConfigureAwait(false);
if (failure is null)
{
allowed.Add(tagAddress);
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken)
.ConfigureAwait(false);
denied[index] = new BulkReadResult
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
WasSuccessful = false,
WasCached = false,
ErrorMessage = failure.Message,
};
}
if (denied.Count == 0)
{
return null;
}
MxCommand filtered = command.Clone();
filtered.ReadBulk.TagAddresses.Clear();
filtered.ReadBulk.TagAddresses.Add(allowed);
return new ReadBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
}
private async Task<BulkConstraintPlan?> FilterWriteBulkAsync<TEntry>(
ApiKeyIdentity? identity,
GatewaySession session,
MxCommand command,
int serverHandle,
Google.Protobuf.Collections.RepeatedField<TEntry> entries,
Func<TEntry, int> getItemHandle,
CancellationToken cancellationToken) where TEntry : class
{
// The four bulk-write families each carry a different per-entry message
// shape (WriteBulkEntry / Write2BulkEntry / WriteSecuredBulkEntry /
// WriteSecured2BulkEntry), but the constraint check itself is identical
// — "is this caller allowed to write to this server+item handle?".
// Parameterising on TEntry + getItemHandle keeps a single filter
// routine for all four and avoids duplicating CheckWriteHandleAsync
// calls.
Dictionary<int, BulkWriteResult> denied = [];
List<TEntry> allowed = [];
for (int index = 0; index < entries.Count; index++)
{
TEntry entry = entries[index];
int itemHandle = getItemHandle(entry);
ConstraintFailure? failure = await constraintEnforcer
.CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
if (failure is null)
{
allowed.Add(entry);
continue;
}
await constraintEnforcer.RecordDenialAsync(
identity,
command.Kind.ToString(),
itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture),
failure,
cancellationToken)
.ConfigureAwait(false);
denied[index] = new BulkWriteResult
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
WasSuccessful = false,
ErrorMessage = failure.Message,
};
}
if (denied.Count == 0)
{
return null;
}
MxCommand filtered = command.Clone();
ReplaceWriteBulkEntries(filtered, allowed);
return new WriteBulkConstraintPlan(filtered, entries.Count, denied, allowed.Count > 0);
}
private static void ReplaceWriteBulkEntries<TEntry>(MxCommand command, IReadOnlyList<TEntry> allowed)
where TEntry : class
{
switch (command.Kind)
{
case MxCommandKind.WriteBulk:
command.WriteBulk.Entries.Clear();
command.WriteBulk.Entries.Add((IEnumerable<WriteBulkEntry>)allowed);
break;
case MxCommandKind.Write2Bulk:
command.Write2Bulk.Entries.Clear();
command.Write2Bulk.Entries.Add((IEnumerable<Write2BulkEntry>)allowed);
break;
case MxCommandKind.WriteSecuredBulk:
command.WriteSecuredBulk.Entries.Clear();
command.WriteSecuredBulk.Entries.Add((IEnumerable<WriteSecuredBulkEntry>)allowed);
break;
case MxCommandKind.WriteSecured2Bulk:
command.WriteSecured2Bulk.Entries.Clear();
command.WriteSecured2Bulk.Entries.Add((IEnumerable<WriteSecured2BulkEntry>)allowed);
break;
}
}
private async Task<BulkConstraintPlan?> FilterHandleBulkAsync(
ApiKeyIdentity? identity,
GatewaySession session,
MxCommand command,
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken)
{
Dictionary<int, SubscribeResult> denied = [];
List<int> allowed = [];
for (int index = 0; index < itemHandles.Count; index++)
{
int itemHandle = itemHandles[index];
ConstraintFailure? failure = await constraintEnforcer
.CheckReadHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
if (failure is null)
{
allowed.Add(itemHandle);
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken)
.ConfigureAwait(false);
denied[index] = new SubscribeResult
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
WasSuccessful = false,
ErrorMessage = failure.Message,
};
}
if (denied.Count == 0)
{
return null;
}
MxCommand filtered = command.Clone();
filtered.AdviseItemBulk.ItemHandles.Clear();
filtered.AdviseItemBulk.ItemHandles.Add(allowed);
return new SubscribeBulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0);
}
/// <summary>
/// Polymorphic constraint plan returned from <see cref="ApplyConstraintsAsync"/>.
/// Each concrete subtype is keyed to a specific bulk-reply shape — the
/// SubscribeResult-based AddItem/Advise/Subscribe family, the
/// BulkWriteResult-based Write* bulk family, and the BulkReadResult-based
/// ReadBulk command. Subtypes own their own merge / denied-reply build
/// logic so the Invoke dispatch site never branches on reply shape.
/// </summary>
private abstract record BulkConstraintPlan(
MxCommand Command,
int OriginalCount,
bool HasAllowedItems)
{
/// <summary>Builds a reply containing only the denied entries (used when no items survived filtering).</summary>
public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request);
/// <summary>Splices denied entries back into the worker's allowed-only reply in original-index order.</summary>
public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply);
}
private sealed record SubscribeBulkConstraintPlan(
MxCommand Command,
int OriginalCount,
IReadOnlyDictionary<int, SubscribeResult> DeniedResults,
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
Kind = request.Command.Kind,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
};
SetPayload(reply, BuildMerged(new BulkSubscribeReply()));
return reply;
}
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply();
SetPayload(reply, BuildMerged(allowed));
return reply;
}
private BulkSubscribeReply BuildMerged(BulkSubscribeReply allowed)
{
Queue<SubscribeResult> allowedResults = new(allowed.Results);
BulkSubscribeReply merged = new();
for (int index = 0; index < OriginalCount; index++)
{
if (DeniedResults.TryGetValue(index, out SubscribeResult? denied))
{
merged.Results.Add(denied);
}
else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult))
{
merged.Results.Add(allowedResult);
}
}
return merged;
}
private BulkSubscribeReply? GetPayload(MxCommandReply reply) => Command.Kind switch
{
MxCommandKind.AddItemBulk => reply.AddItemBulk,
MxCommandKind.AdviseItemBulk => reply.AdviseItemBulk,
MxCommandKind.SubscribeBulk => reply.SubscribeBulk,
_ => null,
};
private void SetPayload(MxCommandReply reply, BulkSubscribeReply payload)
{
switch (Command.Kind)
{
case MxCommandKind.AddItemBulk:
reply.AddItemBulk = payload;
break;
case MxCommandKind.AdviseItemBulk:
reply.AdviseItemBulk = payload;
break;
case MxCommandKind.SubscribeBulk:
reply.SubscribeBulk = payload;
break;
}
}
}
private sealed record WriteBulkConstraintPlan(
MxCommand Command,
int OriginalCount,
IReadOnlyDictionary<int, BulkWriteResult> DeniedResults,
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
Kind = request.Command.Kind,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
};
SetPayload(reply, BuildMerged(new BulkWriteReply()));
return reply;
}
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply();
SetPayload(reply, BuildMerged(allowed));
return reply;
}
private BulkWriteReply BuildMerged(BulkWriteReply allowed)
{
Queue<BulkWriteResult> allowedResults = new(allowed.Results);
BulkWriteReply merged = new();
for (int index = 0; index < OriginalCount; index++)
{
if (DeniedResults.TryGetValue(index, out BulkWriteResult? denied))
{
merged.Results.Add(denied);
}
else if (allowedResults.TryDequeue(out BulkWriteResult? allowedResult))
{
merged.Results.Add(allowedResult);
}
}
return merged;
}
private BulkWriteReply? GetPayload(MxCommandReply reply) => Command.Kind switch
{
MxCommandKind.WriteBulk => reply.WriteBulk,
MxCommandKind.Write2Bulk => reply.Write2Bulk,
MxCommandKind.WriteSecuredBulk => reply.WriteSecuredBulk,
MxCommandKind.WriteSecured2Bulk => reply.WriteSecured2Bulk,
_ => null,
};
private void SetPayload(MxCommandReply reply, BulkWriteReply payload)
{
switch (Command.Kind)
{
case MxCommandKind.WriteBulk:
reply.WriteBulk = payload;
break;
case MxCommandKind.Write2Bulk:
reply.Write2Bulk = payload;
break;
case MxCommandKind.WriteSecuredBulk:
reply.WriteSecuredBulk = payload;
break;
case MxCommandKind.WriteSecured2Bulk:
reply.WriteSecured2Bulk = payload;
break;
}
}
}
private sealed record ReadBulkConstraintPlan(
MxCommand Command,
int OriginalCount,
IReadOnlyDictionary<int, BulkReadResult> DeniedResults,
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
Kind = request.Command.Kind,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
};
reply.ReadBulk = BuildMerged(new BulkReadReply());
return reply;
}
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply();
reply.ReadBulk = BuildMerged(allowed);
return reply;
}
private BulkReadReply BuildMerged(BulkReadReply allowed)
{
Queue<BulkReadResult> allowedResults = new(allowed.Results);
BulkReadReply merged = new();
for (int index = 0; index < OriginalCount; index++)
{
if (DeniedResults.TryGetValue(index, out BulkReadResult? denied))
{
merged.Results.Add(denied);
}
else if (allowedResults.TryDequeue(out BulkReadResult? allowedResult))
{
merged.Results.Add(allowedResult);
}
}
return merged;
}
}
private RpcException MapException(Exception exception)
{
if (exception is OperationCanceledException)
{
return new RpcException(new Status(StatusCode.Cancelled, "gRPC request was canceled."));
}
if (exception is SessionManagerException sessionException)
{
return MapSessionException(sessionException);
}
if (exception is WorkerClientException workerClientException)
{
return MapWorkerClientException(workerClientException);
}
logger.LogWarning(exception, "Public gRPC request failed.");
return new RpcException(new Status(StatusCode.Unavailable, "Gateway request failed before an MXAccess reply was available."));
}
private static RpcException MapSessionException(SessionManagerException exception)
{
StatusCode statusCode = exception.ErrorCode switch
{
SessionManagerErrorCode.SessionNotFound => StatusCode.NotFound,
SessionManagerErrorCode.SessionNotReady => StatusCode.FailedPrecondition,
SessionManagerErrorCode.EventSubscriberAlreadyActive => StatusCode.ResourceExhausted,
SessionManagerErrorCode.EventQueueOverflow => StatusCode.ResourceExhausted,
SessionManagerErrorCode.SessionLimitExceeded => StatusCode.ResourceExhausted,
SessionManagerErrorCode.OpenFailed => StatusCode.Unavailable,
SessionManagerErrorCode.CloseFailed => StatusCode.Unavailable,
_ => StatusCode.Unavailable,
};
return new RpcException(new Status(statusCode, exception.Message));
}
private static RpcException MapWorkerClientException(WorkerClientException exception)
{
StatusCode statusCode = exception.ErrorCode switch
{
WorkerClientErrorCode.CommandTimeout => StatusCode.DeadlineExceeded,
WorkerClientErrorCode.GatewayShutdown => StatusCode.Cancelled,
WorkerClientErrorCode.InvalidState => StatusCode.FailedPrecondition,
WorkerClientErrorCode.ProtocolViolation => StatusCode.Internal,
_ => StatusCode.Unavailable,
};
return new RpcException(new Status(statusCode, exception.Message));
}
}
@@ -0,0 +1,175 @@
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Grpc;
/// <summary>
/// Maps between worker IPC types and gRPC contract types.
/// </summary>
public sealed class MxAccessGrpcMapper
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes the mapper with an optional time provider.
/// </summary>
/// <param name="timeProvider">Time provider for timestamps; defaults to system time if null.</param>
public MxAccessGrpcMapper(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Maps a gRPC MX command request to a worker command.
/// </summary>
/// <param name="request">Request payload.</param>
public WorkerCommand MapCommand(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Command);
return new WorkerCommand
{
Command = request.Command.Clone(),
EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()),
};
}
/// <summary>
/// Maps a worker command reply to a gRPC MX command reply.
/// </summary>
/// <param name="reply">Worker command reply.</param>
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
if (reply.Reply is null)
{
return new MxCommandReply
{
ProtocolStatus = ProtocolViolation("Worker command reply did not contain a public reply payload."),
};
}
return reply.Reply.Clone();
}
/// <summary>
/// Maps a worker event to a gRPC MX event.
/// </summary>
/// <param name="workerEvent">Worker event to map.</param>
public MxEvent MapEvent(WorkerEvent workerEvent)
{
ArgumentNullException.ThrowIfNull(workerEvent);
return workerEvent.Event?.Clone() ?? new MxEvent
{
Family = MxEventFamily.Unspecified,
RawStatus = "Worker event did not contain a public event payload.",
};
}
/// <summary>
/// Creates an OK protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Ok(string message = "OK")
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
Message = message,
};
}
/// <summary>
/// Creates an InvalidRequest protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus InvalidRequest(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.InvalidRequest,
Message = message,
};
}
/// <summary>
/// Creates a SessionNotFound protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus SessionNotFound(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.SessionNotFound,
Message = message,
};
}
/// <summary>
/// Creates a SessionNotReady protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus SessionNotReady(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.SessionNotReady,
Message = message,
};
}
/// <summary>
/// Creates a WorkerUnavailable protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus WorkerUnavailable(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.WorkerUnavailable,
Message = message,
};
}
/// <summary>
/// Creates a Timeout protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Timeout(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.Timeout,
Message = message,
};
}
/// <summary>
/// Creates a Canceled protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Canceled(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.Canceled,
Message = message,
};
}
/// <summary>
/// Creates a ProtocolViolation protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus ProtocolViolation(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.ProtocolViolation,
Message = message,
};
}
}
@@ -0,0 +1,120 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Grpc;
public sealed class MxAccessGrpcRequestValidator
{
/// <summary>Validates an open session request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateOpenSession(OpenSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.CommandTimeout is not null && request.CommandTimeout.ToTimeSpan() <= TimeSpan.Zero)
{
throw InvalidArgument("Command timeout must be greater than zero when provided.");
}
}
/// <summary>Validates a close session request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateCloseSession(CloseSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
}
/// <summary>Validates a stream events request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateStreamEvents(StreamEventsRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
}
/// <summary>Validates an invoke request with command payload.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateInvoke(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
if (request.Command is null)
{
throw InvalidArgument("Invoke requires a command payload.");
}
if (request.Command.Kind is MxCommandKind.Unspecified)
{
throw InvalidArgument("Invoke requires a command kind.");
}
ValidateCommandPayload(request.Command);
}
private static void RequireSessionId(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw InvalidArgument("Session id is required.");
}
}
private static void ValidateCommandPayload(MxCommand command)
{
MxCommand.PayloadOneofCase expectedPayload = ExpectedPayload(command.Kind);
if (command.PayloadCase != expectedPayload)
{
throw InvalidArgument(
$"Command kind {command.Kind} requires payload {expectedPayload} but received {command.PayloadCase}.");
}
}
private static MxCommand.PayloadOneofCase ExpectedPayload(MxCommandKind kind)
{
return kind switch
{
MxCommandKind.Register => MxCommand.PayloadOneofCase.Register,
MxCommandKind.Unregister => MxCommand.PayloadOneofCase.Unregister,
MxCommandKind.AddItem => MxCommand.PayloadOneofCase.AddItem,
MxCommandKind.AddItem2 => MxCommand.PayloadOneofCase.AddItem2,
MxCommandKind.RemoveItem => MxCommand.PayloadOneofCase.RemoveItem,
MxCommandKind.Advise => MxCommand.PayloadOneofCase.Advise,
MxCommandKind.UnAdvise => MxCommand.PayloadOneofCase.UnAdvise,
MxCommandKind.AdviseSupervisory => MxCommand.PayloadOneofCase.AdviseSupervisory,
MxCommandKind.AddBufferedItem => MxCommand.PayloadOneofCase.AddBufferedItem,
MxCommandKind.SetBufferedUpdateInterval => MxCommand.PayloadOneofCase.SetBufferedUpdateInterval,
MxCommandKind.Suspend => MxCommand.PayloadOneofCase.Suspend,
MxCommandKind.Activate => MxCommand.PayloadOneofCase.Activate,
MxCommandKind.Write => MxCommand.PayloadOneofCase.Write,
MxCommandKind.Write2 => MxCommand.PayloadOneofCase.Write2,
MxCommandKind.WriteSecured => MxCommand.PayloadOneofCase.WriteSecured,
MxCommandKind.WriteSecured2 => MxCommand.PayloadOneofCase.WriteSecured2,
MxCommandKind.AuthenticateUser => MxCommand.PayloadOneofCase.AuthenticateUser,
MxCommandKind.ArchestraUserToId => MxCommand.PayloadOneofCase.ArchestraUserToId,
MxCommandKind.AddItemBulk => MxCommand.PayloadOneofCase.AddItemBulk,
MxCommandKind.AdviseItemBulk => MxCommand.PayloadOneofCase.AdviseItemBulk,
MxCommandKind.RemoveItemBulk => MxCommand.PayloadOneofCase.RemoveItemBulk,
MxCommandKind.UnAdviseItemBulk => MxCommand.PayloadOneofCase.UnAdviseItemBulk,
MxCommandKind.SubscribeBulk => MxCommand.PayloadOneofCase.SubscribeBulk,
MxCommandKind.UnsubscribeBulk => MxCommand.PayloadOneofCase.UnsubscribeBulk,
MxCommandKind.WriteBulk => MxCommand.PayloadOneofCase.WriteBulk,
MxCommandKind.Write2Bulk => MxCommand.PayloadOneofCase.Write2Bulk,
MxCommandKind.WriteSecuredBulk => MxCommand.PayloadOneofCase.WriteSecuredBulk,
MxCommandKind.WriteSecured2Bulk => MxCommand.PayloadOneofCase.WriteSecured2Bulk,
MxCommandKind.ReadBulk => MxCommand.PayloadOneofCase.ReadBulk,
MxCommandKind.Ping => MxCommand.PayloadOneofCase.Ping,
MxCommandKind.GetSessionState => MxCommand.PayloadOneofCase.GetSessionState,
MxCommandKind.GetWorkerInfo => MxCommand.PayloadOneofCase.GetWorkerInfo,
MxCommandKind.DrainEvents => MxCommand.PayloadOneofCase.DrainEvents,
MxCommandKind.ShutdownWorker => MxCommand.PayloadOneofCase.ShutdownWorker,
_ => MxCommand.PayloadOneofCase.None,
};
}
private static RpcException InvalidArgument(string detail)
{
return new RpcException(new Status(StatusCode.InvalidArgument, detail));
}
}