using Akka.Actor;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
using GrpcStatus = Grpc.Core.Status;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
///
/// gRPC front door for the central→site command plane: decodes a proto command (via
/// ), routes it through the ONE
/// the Akka SiteCommunicationActor also uses, and
/// encodes the reply. One routing truth, two transports.
///
///
///
/// Mapped in the site branch next to SiteStreamGrpcServer, behind the same
/// ControlPlaneAuthInterceptor PSK gate and the same readiness convention: calls are
/// rejected with until is called
/// once the site actor system is up (mirrors SiteStreamGrpcServer.SetReady).
///
///
/// Coexistence — server-side only. This makes the site ALSO listen on gRPC for commands;
/// nothing central flips to gRPC here (that is T1B.3). Central still dials sites via ClusterClient.
///
///
/// Fire-and-forget. The Akka path never acks UnsubscribeDebugView, but a unary RPC
/// must answer, so the dispatcher marks it
/// and this service Tells the target then returns the synthetic ack.
///
///
/// Ack-before-Leave.TriggerFailover resolves the standby WITHOUT leaving
/// (), returns the ack, and only THEN schedules
/// the real Cluster.Leave — so a caller reaching the very node that is about to leave still
/// receives its ack instead of a broken stream.
///
///
public sealed class SiteCommandGrpcService : SiteCommandService.SiteCommandServiceBase
{
// A local Ask has no client deadline of its own; fall back to a generous ceiling when the
// caller set none (a deadline-less client is a test or an internal caller). When the caller
// DID set a gRPC deadline, honour the remaining time instead.
private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromMinutes(2);
private readonly ILogger _logger;
private readonly Action _leaveScheduler;
// Set once by SetReady after the site actor system and dispatcher exist. Read on Kestrel
// threads, written on the host bring-up thread — volatile.
private volatile SiteCommandDispatcher? _dispatcher;
private volatile bool _ready;
/// DI constructor.
/// Logger for denial/dispatch diagnostics.
public SiteCommandGrpcService(ILogger logger)
: this(logger, DeferLeaveUntilAfterReply)
{
}
///
/// Test constructor letting a test observe when the deferred leave runs. Internal so DI sees a
/// single public constructor.
///
/// Logger.
/// Runs the deferred Cluster.Leave after the ack is returned.
internal SiteCommandGrpcService(ILogger logger, Action leaveScheduler)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(leaveScheduler);
_logger = logger;
_leaveScheduler = leaveScheduler;
}
///
/// Marks the service ready and injects the shared routing table. Called once the site actor
/// system, the Deployment Manager singleton, and the local handlers are up — the same point
/// SiteStreamGrpcServer.SetReady is called.
///
/// The shared dispatcher (the same instance the actor routes through).
public void SetReady(SiteCommandDispatcher dispatcher)
{
ArgumentNullException.ThrowIfNull(dispatcher);
_dispatcher = dispatcher;
_ready = true;
}
/// Whether the service is accepting commands. Exposed for tests.
internal bool IsReady => _ready;
///
public override async Task ExecuteLifecycle(LifecycleRequest request, ServerCallContext context)
{
var command = SiteCommandDtoMapper.FromLifecycleRequest(request);
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
return SiteCommandDtoMapper.ToLifecycleReply(reply);
}
///
public override async Task ExecuteOpcUa(OpcUaRequest request, ServerCallContext context)
{
var command = SiteCommandDtoMapper.FromOpcUaRequest(request);
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
return SiteCommandDtoMapper.ToOpcUaReply(reply);
}
///
public override async Task ExecuteQuery(QueryRequest request, ServerCallContext context)
{
var command = SiteCommandDtoMapper.FromQueryRequest(request);
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
return SiteCommandDtoMapper.ToQueryReply(reply);
}
///
public override async Task ExecuteParked(ParkedRequest request, ServerCallContext context)
{
var command = SiteCommandDtoMapper.FromParkedRequest(request);
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
return SiteCommandDtoMapper.ToParkedReply(reply);
}
///
public override async Task ExecuteRoute(RouteRequest request, ServerCallContext context)
{
var command = SiteCommandDtoMapper.FromRouteRequest(request);
var reply = await DispatchAsync(command, context).ConfigureAwait(false);
return SiteCommandDtoMapper.ToRouteReply(reply);
}
///
public override Task TriggerFailover(TriggerSiteFailoverDto request, ServerCallContext context)
{
var dispatcher = EnsureReady();
var msg = SiteCommandDtoMapper.FromProto(request);
// Resolve the standby and build the ack WITHOUT leaving. The real leave is deferred so the
// ack is on the wire first (ack-before-Leave).
var outcome = dispatcher.PrepareFailover(msg);
var dto = SiteCommandDtoMapper.ToProto(outcome.Ack);
if (outcome.CommitLeave is not null)
{
_leaveScheduler(outcome.CommitLeave);
}
return Task.FromResult(dto);
}
///
/// Routes a decoded command through the shared dispatcher and returns the reply record.
/// Forward → local Ask; fire-and-forget → local Tell + synthetic ack; no target →
/// the dispatcher's synthetic "handler not available" reply.
///
private async Task