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 DispatchAsync(object command, ServerCallContext context) { var dispatcher = EnsureReady(); var route = dispatcher.ResolveRoute(command); switch (route.Disposition) { case SiteCommandDispatcher.RouteDisposition.ImmediateReply: return route.Reply!; case SiteCommandDispatcher.RouteDisposition.TellFireAndForget: route.Target!.Tell(command); return route.Reply!; default: try { return await route.Target!.Ask( command, AskTimeout(context), context.CancellationToken).ConfigureAwait(false); } catch (RpcException) { throw; } catch (OperationCanceledException) { // Client cancelled or the deadline elapsed. throw new RpcException(new GrpcStatus( StatusCode.DeadlineExceeded, "Site did not answer within the deadline.")); } catch (Exception ex) { _logger.LogWarning(ex, "Local dispatch of {Command} faulted", command.GetType().Name); throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message)); } } } private SiteCommandDispatcher EnsureReady() { var dispatcher = _dispatcher; if (!_ready || dispatcher is null) { throw new RpcException(new GrpcStatus( StatusCode.Unavailable, "Site command plane not ready.")); } return dispatcher; } private static TimeSpan AskTimeout(ServerCallContext context) { var deadline = context.Deadline; if (deadline == DateTime.MaxValue) { return DefaultAskTimeout; } var remaining = deadline - DateTime.UtcNow; return remaining > TimeSpan.Zero ? remaining : TimeSpan.FromMilliseconds(1); } // Default deferred-leave: return control (so the ack serialises) before the graceful Leave // runs. A yield hands the current continuation back before the leave begins; the leave itself // is slow-async (member marked Leaving, CoordinatedShutdown over seconds), so the ack is long // gone by the time Kestrel actually stops. private static void DeferLeaveUntilAfterReply(Action commitLeave) => _ = Task.Run(async () => { await Task.Yield(); commitLeave(); }); }