using Akka.Actor;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
///
/// The gRPC central→site command transport: encodes each command with
/// , dials the site's SiteCommandService through the sticky
/// (PSK + x-scadabridge-site already on the channel),
/// and routes the decoded reply back to the waiting Ask (or the debug-bridge actor).
///
///
///
/// Per-call deadlines match today's Ask timeouts exactly — see .
/// Behaviour is otherwise unchanged from the Akka path: an unknown/unconfigured site is warned and
/// dropped (the caller's Ask times out), and a transport fault surfaces to the caller as a
/// , which the S&F/audit layers already treat as transient.
///
///
/// Cross-node retry is the channel provider's job and happens only on
/// Unavailable — never on DeadlineExceeded (a write/deploy/failover may have run).
///
///
public sealed class GrpcSiteTransport : ISiteCommandTransport
{
private readonly SitePairChannelProvider _channels;
private readonly CommunicationOptions _options;
private readonly ILogger _logger;
// Sites this transport has pushed into the channel provider — used to diff removals per refresh,
// the gRPC analogue of the Akka transport's _siteClients key set. Touched only on the actor thread.
private readonly HashSet _knownSites = new(StringComparer.Ordinal);
/// Creates the gRPC transport and ensures the provider's failback loop is running.
/// The shared per-site channel-pair provider.
/// Communication options supplying the per-command deadlines.
/// Logger for drop/fault diagnostics.
public GrpcSiteTransport(
SitePairChannelProvider channels,
CommunicationOptions options,
ILogger logger)
{
_channels = channels ?? throw new ArgumentNullException(nameof(channels));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_channels.EnsureFailbackLoop();
}
///
public void Send(SiteEnvelope envelope, IActorRef replyTo)
{
ArgumentNullException.ThrowIfNull(envelope);
// UnsubscribeDebugView is the one fire-and-forget command: the site never acks it over Akka
// (the debug bridge Tells and expects nothing), so we run the RPC but do not deliver its
// synthetic ack — mirroring today's no-reply behaviour and avoiding a dead-lettered ack.
var fireAndForget = envelope.Message is UnsubscribeDebugViewRequest;
_ = RunAsync(envelope, replyTo, fireAndForget);
}
private async Task RunAsync(SiteEnvelope envelope, IActorRef replyTo, bool fireAndForget)
{
try
{
var reply = await SendCoreAsync(envelope, CancellationToken.None).ConfigureAwait(false);
if (!fireAndForget && !replyTo.IsNobody())
{
replyTo.Tell(reply, ActorRefs.NoSender);
}
}
catch (SiteChannelUnavailableException)
{
// Parity with the Akka "no ClusterClient for site" path: warn and drop, so the caller's
// Ask times out. Central never buffers.
_logger.LogWarning(
"No gRPC channel for site {SiteId}; dropping {Message} (caller's Ask will time out)",
envelope.SiteId, envelope.Message.GetType().Name);
}
catch (Exception ex)
{
if (!fireAndForget && !replyTo.IsNobody())
{
// A timeout or non-OK status faults the caller's Ask exactly as an Akka Ask timeout
// did — the S&F/audit layers already treat that as transient.
replyTo.Tell(new Status.Failure(ex), ActorRefs.NoSender);
}
else
{
_logger.LogWarning(ex,
"Fire-and-forget {Message} to site {SiteId} faulted",
envelope.Message.GetType().Name, envelope.SiteId);
}
}
}
private Task