Files
mxaccessgw/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs
T
Joseph Doherty a0203503a7 Code-review 2026-05-20 sweep: re-review at 1cd51bb, resolve 72 findings across all 11 modules
Re-reviewed every module/client against the 10-category checklist
(REVIEW-PROCESS.md) at commit 1cd51bb, filed 72 new findings, and
fixed them in three priority waves (3 High, 17 Medium, 52 Low).

Highs
- Server-017: enumerate AcknowledgeAlarm / QueryActiveAlarms in
  GatewayGrpcScopeResolver so non-admin keys can use them; document
  the mapping in docs/Authorization.md; add interceptor tests.
- Client.Java-013: add the five missing bulk-method stubs to the
  CLI FakeSession so the test module compiles on a clean tree.
- Client.Rust-013: fix the clippy::doc_lazy_continuation regression
  in generated tonic code by reformatting the ReadBulkCommand proto
  comment and scoping a #![allow(...)] to the generated submodules.

Mediums (highlights)
- Server: unify GatewaySession state-lock discipline (-015) and
  make DisposeAsync race-safe against in-flight CloseAsync (-016);
  add constraint-enforcement test coverage for the bulk-plan path
  (-021).
- Worker: introduce StaRuntimeShutdownException so RunAlarmPollLoop
  can distinguish graceful shutdown from a real STA-affinity
  violation (-016); have the watchdog skip StaHung while
  CurrentCommandCorrelationId is non-empty so a legitimate slow
  ReadBulk no longer self-faults (-017).
- Tests: add per-method round-trip + cancellation coverage for the
  11 GatewaySession bulk methods (-013); replace the real TCP probe
  in GalaxyHierarchyCacheTests with an IGalaxyRepository fake
  (-016).
- IntegrationTests: drive the StreamEvents writer in the live Write
  test and assert OnWriteComplete (-012); add live tests for
  Unadvise/RemoveItem/Unregister ordering, WriteSecured, and
  abnormal worker exit (-014).
- Worker.Tests: replace MxAccessSession reflection with an internal
  CreateForTesting factory (-016); cover WorkerCancel and
  unexpected-body envelope branches (-017).
- Client.Java: cancel MxEventStream when close() races
  beforeStart() (-014); return a CancellingCompletableFuture that
  actually forwards cancellation through .thenApply chains (-015).
- Client.Python: drop the silent localhost-plaintext downgrade in
  the CLI; require explicit --plaintext (-013).
- Client.Rust: stop bench-read-bulk from polluting success-latency
  histograms with failed-call durations (-015); add coverage for
  the five MalformedReply paths, the bulk-write helpers, the
  Error::Unavailable mapping, and the unary-fault path (-016).
- Contracts: extend docs/Contracts.md with the bulk read/write
  command family (-009).

Lows (highlights)
- Server: cap GalaxyGlobMatcher.RegexCache; align
  WorkerAlarmRpcDispatcher missing-session handling; drop the
  duplicate dashboard @page routes; refresh IAlarmRpcDispatcher
  XML doc.
- Worker: surface SetXmlAlarmQuery COM failures; remove dead
  subscriptionExpression / ExecutingCommand arms; preserve
  factory-supplied runtime sessions; split MxAlarmSnapshot.cs into
  three files.
- Tests: dispose the WebApplication in seven test classes; rebuild
  FakeWorkerProcess.WaitForExitAsync against a real TaskCompletion
  source; switch the heartbeat-expires test to ManualTimeProvider;
  add InvariantCulture to the remaining DateTimeOffset.Parse sites;
  document GalaxyFilterInputSafetyTests in GatewayTesting.md.
- IntegrationTests: comment fixes, RecordingServerStreamWriter
  IDisposable, class-level [Trait], single-source ZB default
  connection string.
- Worker.Tests: replace silent-return gating with LiveMxAccessFact
  so absent env vars SKIP not pass; PascalCase rename of probe
  [Fact]s; deterministic deadline test; new frame-protocol error
  tests; ComputeTransitions diff-coverage; relocate dev-rig probes
  to Probes/.
- Contracts: add round-trip coverage and per-field redaction /
  Galaxy-identifier comments to the protos.
- Client.Dotnet: introduce clients/dotnet/Directory.Build.props so
  TreatWarningsAsErrors / analysers apply; document
  DiscoverHierarchyOptions and IMxGatewayCliClient; require typed
  bulk-read handles in CLI; surface AcknowledgeAlarm transport
  faults through Translate().
- Client.Go: kill dead code in alarms_test / fakeGalaxyServer /
  runWriteBulkVariant; document the six new subcommands in
  writeUsage; drain galaxy-watch events on limit; switch io.EOF
  comparisons to errors.Is.
- Client.Java: shared shutdown helpers + new shutdownTimeout
  option; regex-based credential redaction; Long.toUnsignedString
  for uint64 sequence; doc fixes.
- Client.Python: combine duplicate imports; add coverage for
  _percentile / bench-read-bulk / MAX_AGGREGATE_EVENTS /
  _api_key_from_env; populate pyproject metadata and ship py.typed.
- Client.Rust: expose next_correlation_id() so CLI ping/close
  stop hard-coding correlation IDs; resync RustClientDesign.md
  with the current Session / Error surface and CLI subcommand set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:46:47 -04:00

460 lines
18 KiB
C#

using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
namespace MxGateway.Client;
/// <summary>
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
/// All RPCs are read-only metadata calls that share the gateway's API-key auth
/// interceptor and require the <c>metadata:read</c> scope server-side.
/// </summary>
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private const int DiscoverHierarchyPageSize = 5000;
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private int _disposed;
/// <summary>
/// Initializes a Galaxy Repository client with custom transport and options.
/// </summary>
/// <param name="options">Client options.</param>
/// <param name="transport">The underlying gRPC transport.</param>
internal GalaxyRepositoryClient(
MxGatewayClientOptions options,
IGalaxyRepositoryClientTransport transport)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
Options = options;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
_channel = null;
}
private GalaxyRepositoryClient(
GrpcChannel channel,
IGalaxyRepositoryClientTransport transport)
{
_channel = channel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
Options.Retry,
Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
}
/// <summary>
/// Client options used to configure timeouts, authentication, and retry policy.
/// </summary>
public MxGatewayClientOptions Options { get; }
/// <summary>
/// The underlying generated gRPC client for advanced operations.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient RawClient =>
_transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
/// <summary>
/// Creates a Galaxy Repository client with the given options, establishing a new gRPC channel.
/// </summary>
/// <param name="options">Client options.</param>
/// <returns>A new client instance.</returns>
public static GalaxyRepositoryClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
HttpMessageHandler handler = CreateHttpHandler(options);
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new GalaxyRepositoryClient(
channel,
new GrpcGalaxyRepositoryClientTransport(
options,
new GalaxyRepository.GalaxyRepositoryClient(channel)));
}
/// <summary>
/// Probes the Galaxy Repository database connection. Returns true when the
/// gateway can reach the configured ZB SQL Server.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if connection is successful, false otherwise.</returns>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
TestConnectionReply reply = await TestConnectionRawAsync(
new TestConnectionRequest(),
cancellationToken)
.ConfigureAwait(false);
return reply.Ok;
}
/// <summary>
/// Probes the Galaxy Repository database connection without result wrapping.
/// </summary>
/// <param name="request">The test connection request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<TestConnectionReply> TestConnectionRawAsync(
TestConnectionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.TestConnectionAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Returns the timestamp of the most recent Galaxy deployment, or
/// <see langword="null"/> when no deployment has been recorded.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deployment timestamp, or null if not recorded.</returns>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default)
{
GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync(
new GetLastDeployTimeRequest(),
cancellationToken)
.ConfigureAwait(false);
if (!reply.Present || reply.TimeOfLastDeploy is null)
{
return null;
}
return reply.TimeOfLastDeploy.ToDateTime();
}
/// <summary>
/// Returns the most recent Galaxy deployment timestamp without result wrapping.
/// </summary>
/// <param name="request">The last deploy-time request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.GetLastDeployTimeAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Enumerates the deployed Galaxy object hierarchy. Each <see cref="GalaxyObject"/>
/// includes its dynamic attributes so callers can determine which tag references
/// they may subscribe to via the MxAccessGateway service.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Enumerates the deployed Galaxy object hierarchy with caller-supplied
/// server-side filters. Each returned <see cref="GalaxyObject"/> may include
/// its dynamic attributes (controlled by <see cref="DiscoverHierarchyOptions.IncludeAttributes"/>),
/// so callers can determine which tag references they may subscribe to via
/// the MxAccessGateway service. The client transparently follows the
/// gateway's pagination cursor until the hierarchy is fully drained.
/// </summary>
/// <param name="options">Server-side filter and shape options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The filtered collection of Galaxy objects.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
DiscoverHierarchyOptions options,
CancellationToken cancellationToken = default)
{
List<GalaxyObject> objects = [];
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
string pageToken = string.Empty;
do
{
DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options);
request.PageSize = DiscoverHierarchyPageSize;
request.PageToken = pageToken;
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
request,
cancellationToken)
.ConfigureAwait(false);
objects.AddRange(reply.Objects);
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken)
&& !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return objects;
}
private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
DiscoverHierarchyRequest request = new()
{
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
if (options.RootGobjectId.HasValue)
{
request.RootGobjectId = options.RootGobjectId.Value;
}
else if (!string.IsNullOrWhiteSpace(options.RootTagName))
{
request.RootTagName = options.RootTagName;
}
else if (!string.IsNullOrWhiteSpace(options.RootContainedPath))
{
request.RootContainedPath = options.RootContainedPath;
}
if (options.MaxDepth.HasValue)
{
request.MaxDepth = options.MaxDepth.Value;
}
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
/// <summary>
/// Enumerates the Galaxy object hierarchy without result wrapping.
/// </summary>
/// <param name="request">The discover-hierarchy request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.DiscoverHierarchyAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
/// per new <c>time_of_last_deploy</c>. Pass <paramref name="lastSeenDeployTime"/> to
/// suppress the bootstrap when the caller already holds the current deploy time.
/// </summary>
/// <remarks>
/// Streaming RPCs are not wrapped by the unary safe-read retry pipeline. If the
/// stream is interrupted the caller must reopen it; the server does not guarantee
/// at-least-once delivery beyond the per-subscriber buffer (gaps in
/// <see cref="DeployEvent.Sequence"/> indicate dropped events).
/// </remarks>
/// <param name="lastSeenDeployTime">Optional timestamp to suppress the bootstrap event.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of deploy events.</returns>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
DateTimeOffset? lastSeenDeployTime = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
WatchDeployEventsRequest request = new();
if (lastSeenDeployTime is { } seen)
{
request.LastSeenDeployTime = Timestamp.FromDateTimeOffset(seen);
}
return WatchDeployEventsRawAsync(request, cancellationToken);
}
/// <summary>
/// Subscribes to Galaxy deploy events without result wrapping.
/// </summary>
/// <param name="request">The watch-deploy-events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of raw deploy events.</returns>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return WatchDeployEventsCoreAsync(request, cancellationToken);
}
private async IAsyncEnumerable<DeployEvent> WatchDeployEventsCoreAsync(
WatchDeployEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (DeployEvent deployEvent in _transport
.WatchDeployEventsAsync(request, CreateStreamCallOptions(cancellationToken))
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return deployEvent;
}
}
/// <summary>
/// Closes the gRPC channel and releases resources.
/// </summary>
public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return ValueTask.CompletedTask;
}
_channel?.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>
/// Creates gRPC call options with the client's default timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The call options.</returns>
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
}
/// <summary>
/// Creates gRPC call options for streaming RPCs with the stream timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The stream call options.</returns>
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
}
/// <summary>
/// Creates gRPC call options with the specified timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="timeout">Optional timeout duration.</param>
/// <returns>The call options.</returns>
internal CallOptions CreateCallOptions(
CancellationToken cancellationToken,
TimeSpan? timeout)
{
Metadata headers = new()
{
{ "authorization", $"Bearer {Options.ApiKey}" },
};
return new CallOptions(
headers,
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
cancellationToken);
}
private async Task<T> ExecuteSafeUnaryAsync<T>(
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
timeout.Token)
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
ConnectTimeout = options.ConnectTimeout,
};
if (options.UseTls)
{
handler.SslOptions = new SslClientAuthenticationOptions();
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
{
handler.SslOptions.TargetHost = options.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if (certificate is null)
{
return false;
}
using X509Chain customChain = new();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
X509Certificate2 certificateToValidate = certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
return customChain.Build(certificateToValidate);
};
}
}
return handler;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
}
}