using System.Collections.Concurrent;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using Google.Protobuf.WellKnownTypes;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
///
/// Per-site gRPC client that manages streaming subscriptions to a site's
/// SiteStreamGrpcServer. The central-side DebugStreamBridgeActor uses this
/// to open server-streaming calls for individual instances.
///
public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
{
private readonly GrpcChannel? _channel;
private readonly SiteStreamService.SiteStreamServiceClient? _client;
private readonly ILogger? _logger;
private readonly ConcurrentDictionary _subscriptions = new();
///
/// The gRPC endpoint (site node address) this client is bound to. The
/// compares this against the requested
/// endpoint so a NodeA→NodeB failover flip (or a site address edit) is honoured
/// rather than served stale from cache.
///
public virtual string Endpoint { get; } = string.Empty;
///
/// The HTTP/2 keepalive ping delay actually applied to this client's channel.
/// Exposed for tests verifying that is honoured.
///
internal TimeSpan KeepAlivePingDelay { get; }
///
/// The HTTP/2 keepalive ping timeout actually applied to this client's channel.
/// Exposed for tests verifying that is honoured.
///
internal TimeSpan KeepAlivePingTimeout { get; }
///
/// Creates a client with default communication options.
///
/// The gRPC endpoint address for the site.
/// Logger for diagnostics and errors.
public SiteStreamGrpcClient(string endpoint, ILogger logger)
: this(endpoint, logger, new CommunicationOptions())
{
}
///
/// Creates a client whose HTTP/2 keepalive is taken from
/// rather than hard-coded, satisfying the design doc's "gRPC Connection Keepalive"
/// section which states these values are configurable.
///
/// The gRPC endpoint address for the site.
/// Logger for diagnostics and errors.
/// Communication options including keepalive settings.
public SiteStreamGrpcClient(string endpoint, ILogger logger, CommunicationOptions options)
: this(endpoint, logger, options, pskProvider: null, siteIdentifier: null)
{
}
///
/// Creates a client that authenticates every call with the site's preshared key.
/// This is the production shape: SiteStreamService is gated by
/// ControlPlaneAuthInterceptor on the site node, so a client without credentials
/// gets on every call.
///
/// The gRPC endpoint address for the site.
/// Logger for diagnostics and errors.
/// Communication options including keepalive settings.
/// Resolves the site's preshared key; null leaves the channel unauthenticated.
/// Site this channel talks to; null leaves the channel unauthenticated.
public SiteStreamGrpcClient(
string endpoint,
ILogger logger,
CommunicationOptions options,
ISitePskProvider? pskProvider,
string? siteIdentifier)
{
Endpoint = endpoint;
KeepAlivePingDelay = options.GrpcKeepAlivePingDelay;
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout;
_channel = GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
KeepAlivePingDelay = options.GrpcKeepAlivePingDelay,
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout,
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always
}
}.WithSiteCredentials(pskProvider, siteIdentifier));
_client = new SiteStreamService.SiteStreamServiceClient(_channel);
_logger = logger;
}
///
/// Protected constructor for unit testing without a real gRPC channel.
/// Allows subclassing for mock implementations.
///
protected SiteStreamGrpcClient()
{
}
///
/// Protected constructor for unit testing — records the endpoint without
/// opening a real gRPC channel, so endpoint-aware factory behaviour can be
/// exercised by test doubles.
///
/// The gRPC endpoint address for the site.
protected SiteStreamGrpcClient(string endpoint)
{
Endpoint = endpoint;
}
///
/// Creates a test-only instance that has no gRPC channel. Used to test
/// Unsubscribe and Dispose behavior without needing a real endpoint.
///
/// A with no channel or client, for testing only.
internal static SiteStreamGrpcClient CreateForTesting() => new();
///
/// Registers a CancellationTokenSource for a correlation ID. Test-only.
///
/// Unique identifier for the subscription.
/// CancellationTokenSource for managing the subscription lifecycle.
internal void AddSubscriptionForTesting(string correlationId, CancellationTokenSource cts)
{
_subscriptions[correlationId] = cts;
}
///
/// Registers a subscription's CancellationTokenSource for a correlation ID.
/// If an entry already exists for that correlation ID (a reconnect race where two
/// calls briefly share an ID), the prior CTS is
/// cancelled only — never disposed from here. Internal for testability.
///
/// Cancel-only is the contract (arch-review 2026-08-19 P3-1, the client half of the
/// row-47 class): the prior handler's is still running and
/// still reads cts.Token (opening the call, ReadAllAsync, the
/// IsCancellationRequested checks), so disposing it from the REPLACING path throws
/// inside the prior stream — and, worse, out of the
/// prior.Cancel() call itself into the NEW SubscribeAsync, where it reads as a
/// fault of the current generation and burns a reconnect. Disposal belongs to the OWNING
/// path: / dispose the CTS they
/// created once their stream loop has fully unwound.
///
///
/// Unique identifier for the subscription.
/// CancellationTokenSource for managing the subscription lifecycle.
internal void RegisterSubscription(string correlationId, CancellationTokenSource cts)
{
if (_subscriptions.TryGetValue(correlationId, out var prior) && !ReferenceEquals(prior, cts))
{
CancelQuietly(prior);
}
_subscriptions[correlationId] = cts;
}
///
/// Cancels a subscription token source, swallowing .
/// A CTS whose owning stream loop has already unwound and disposed it is simply a
/// subscription that no longer needs cancelling; the caller must never see that race as a
/// failure (arch-review 2026-08-19 P3-1).
///
/// The token source to cancel.
private static void CancelQuietly(CancellationTokenSource cts)
{
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed by its owning stream loop — nothing left to cancel.
}
}
///
/// Removes the subscription entry for a correlation ID only if the stored CTS is
/// exactly the one supplied. A racing replacement stream may already own the slot,
/// in which case this is a no-op. Internal for testability.
///
/// Unique identifier for the subscription.
/// CancellationTokenSource to match before removing.
internal void RemoveSubscription(string correlationId, CancellationTokenSource cts)
{
_subscriptions.TryRemove(new KeyValuePair(correlationId, cts));
}
///
/// Opens a server-streaming subscription for a specific instance.
/// This is a long-running async method; the caller launches it as a background task.
/// The callback delivers domain events,
/// lets the caller handle reconnection after a fault, and
/// reports a graceful end of stream.
///
/// Unique identifier for this subscription.
/// Unique name of the instance to subscribe to.
/// Callback invoked for each domain event received from the stream.
/// Callback invoked when the subscription encounters an error.
///
/// Callback invoked when the server ended the RPC with OK — the site's max stream
/// lifetime elapsing or a graceful site shutdown. Mutually exclusive with
/// ; see .
///
/// Cancellation token to stop the subscription.
/// A task that represents the asynchronous operation.
public virtual async Task SubscribeAsync(
string correlationId,
string instanceUniqueName,
Action