fix(dotnet-client): no ack retry, caller-owned channels, safe session dispose, typed diagnostics, AttachSession

This commit is contained in:
Joseph Doherty
2026-10-22 06:09:25 -04:00
parent 488649ce99
commit 6f0b408833
6 changed files with 495 additions and 27 deletions
@@ -14,7 +14,8 @@ namespace ZB.MOM.WW.MxGateway.Client;
/// </summary>
public sealed class MxGatewayClient : IAsyncDisposable
{
private readonly GrpcChannel _channel;
private readonly GrpcChannel? _channel;
private readonly bool _ownsChannel;
private readonly IMxGatewayClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
@@ -36,14 +37,16 @@ public sealed class MxGatewayClient : IAsyncDisposable
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<MxGatewayClient>());
_channel = null!;
_channel = null;
}
private MxGatewayClient(
GrpcChannel channel,
IMxGatewayClientTransport transport)
IMxGatewayClientTransport transport,
bool ownsChannel)
{
_channel = channel;
_ownsChannel = ownsChannel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
@@ -88,7 +91,58 @@ public sealed class MxGatewayClient : IAsyncDisposable
channel,
new GrpcMxGatewayClientTransport(
options,
new MxAccessGateway.MxAccessGatewayClient(channel)));
new MxAccessGateway.MxAccessGatewayClient(channel)),
ownsChannel: true);
}
/// <summary>
/// Creates a gateway client over a caller-owned <see cref="GrpcChannel"/>. The client
/// does not dispose the channel; the caller controls its lifetime and any TLS/handler
/// configuration. <see cref="MxGatewayClientOptions.Endpoint"/> is still validated but
/// the channel's own address is used for transport.
/// </summary>
/// <param name="channel">An existing channel, shared or pre-configured by the caller.</param>
/// <param name="options">Client configuration options (API key, timeouts, retry).</param>
/// <returns>A new gateway client instance that does not own the channel.</returns>
public static MxGatewayClient Create(
GrpcChannel channel,
MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(options);
options.Validate();
return new MxGatewayClient(
channel,
new GrpcMxGatewayClientTransport(
options,
new MxAccessGateway.MxAccessGatewayClient(channel)),
ownsChannel: false);
}
/// <summary>
/// Binds a session object to an existing gateway session id without calling
/// OpenSession, so a reconnecting process can resume
/// <see cref="MxGatewaySession.StreamEventsAsync"/> with <c>afterWorkerSequence</c>.
/// The gateway only allows the API key that opened the session to stream its events
/// or invoke commands on it; any other key is rejected with PermissionDenied. The
/// returned session's <see cref="MxGatewaySession.OpenSessionReply"/> carries only the
/// session id.
/// </summary>
/// <param name="sessionId">The id of a session previously opened with this client's API key.</param>
/// <returns>A session bound to <paramref name="sessionId"/>.</returns>
public MxGatewaySession AttachSession(string sessionId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ThrowIfDisposed();
return new MxGatewaySession(
this,
new OpenSessionReply
{
SessionId = sessionId,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
}
/// <summary>
@@ -199,11 +253,13 @@ public sealed class MxGatewayClient : IAsyncDisposable
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
// Deliberately not wrapped in the retry pipeline: an acknowledge is a
// non-idempotent operator write that re-sends the operator password and
// can double-ack in the Historian.
try
{
return await ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken).ConfigureAwait(false);
return await _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(cancellationToken))
.ConfigureAwait(false);
}
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
{
@@ -266,7 +322,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
}
_disposed = true;
_channel?.Dispose();
if (_ownsChannel)
{
_channel?.Dispose();
}
return ValueTask.CompletedTask;
}