using System.Collections.Concurrent;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Enums;
using Google.Protobuf.WellKnownTypes;
namespace ScadaLink.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; }
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.
///
public SiteStreamGrpcClient(string endpoint, ILogger logger, CommunicationOptions options)
{
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
}
});
_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.
///
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.
///
internal static SiteStreamGrpcClient CreateForTesting() => new();
///
/// Registers a CancellationTokenSource for a correlation ID. Test-only.
///
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 and disposed so it cannot leak. Internal for testability.
///
internal void RegisterSubscription(string correlationId, CancellationTokenSource cts)
{
if (_subscriptions.TryGetValue(correlationId, out var prior) && !ReferenceEquals(prior, cts))
{
prior.Cancel();
prior.Dispose();
}
_subscriptions[correlationId] = cts;
}
///
/// 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.
///
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, and
/// lets the caller handle reconnection.
///
public virtual async Task SubscribeAsync(
string correlationId,
string instanceUniqueName,
Action