using Akka.Actor; 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.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Actors; /// /// The single routing truth for the 28 migrated central→site commands. Given one /// of those command records it decides which local target answers it — /// the Deployment Manager singleton proxy, the artifact/event-log/parked-message /// handlers, or the node-local failover path — preserving EXACTLY the targets and /// null-guard semantics used when this routing /// lived inline. /// /// /// /// Two transports call this one unit: the Akka /// (ClusterClient) and the new SiteCommandGrpcService (gRPC). Centralising the /// table means the two can never drift on where a command goes. Each transport keeps /// its own send mechanics — the actor Forwards (preserving the Ask sender), the /// gRPC service Asks and encodes the reply — but both read the same /// here. /// /// /// The parked-message handler stays node-local on purpose. A parked retry or /// discard must run on the node that holds the replicated store row, so parked /// commands route to the per-node _parkedMessageHandler — never onto the /// singleton proxy (design §7.3). This is the same target the actor used; the /// extraction does not "fix" it. /// /// /// Excluded by design: IntegrationCallRequest — the 29th command, dead at /// both ends. It never enters this dispatcher; the actor keeps its own vestigial handler /// for it (28 of 29 migrate). /// /// public sealed class SiteCommandDispatcher { /// How a resolved command is delivered to its target. public enum RouteDisposition { /// Forward/Ask and route its reply back. Forward, /// /// Tell and answer immediately with /// — the fire-and-forget path (only /// , which the downstream never acks). /// The actor Forwards it and sends nothing back; the gRPC transport Tells and /// returns the synthetic ack so a unary RPC still answers. /// TellFireAndForget, /// /// No target is available (a null-guarded handler is unregistered); answer with the /// synthetic — the "handler not available" reply the actor /// produced inline. /// ImmediateReply } /// A resolved routing decision for one command. /// How the command is delivered. /// The target actor for /; null for an immediate reply. /// The synthetic reply for /; null for a forward. public readonly record struct Route(RouteDisposition Disposition, IActorRef? Target, object? Reply); /// The outcome of preparing a failover: the ack to send now, plus the leave to run after. /// The ack the caller must send back before the node leaves. /// /// When non-null (accepted only), invoking it performs the real graceful /// Cluster.Leave. It is deliberately deferred so the transport can flush the /// first — a caller reaching the very node that is about to leave still /// receives the ack rather than a broken stream. /// public readonly record struct FailoverOutcome(SiteFailoverAck Ack, Action? CommitLeave); private readonly string _siteId; private readonly IActorRef _deploymentManagerProxy; // (role, dryRun) -> leaving node address, or null when there is no standby. // dryRun:true resolves the target WITHOUT leaving; dryRun:false performs the Leave. private readonly Func _resolveFailover; // Registered at runtime via the actor's RegisterLocalHandler flow. Written on the actor // thread, read by both the actor and the gRPC service (a Kestrel thread), so volatile. private volatile IActorRef? _eventLogHandler; private volatile IActorRef? _parkedMessageHandler; private volatile IActorRef? _artifactHandler; /// Creates the dispatcher. /// This site's identifier, stamped into synthetic replies and matched by the failover guard. /// The local Deployment Manager singleton proxy — the target for all lifecycle/OPC UA/query/route commands. /// /// Resolves (and, when dryRun is false, performs) the graceful leave of the oldest /// Up member in a role scope. Injected so tests need no real cluster. /// public SiteCommandDispatcher( string siteId, IActorRef deploymentManagerProxy, Func resolveFailover) { ArgumentNullException.ThrowIfNull(siteId); ArgumentNullException.ThrowIfNull(deploymentManagerProxy); ArgumentNullException.ThrowIfNull(resolveFailover); _siteId = siteId; _deploymentManagerProxy = deploymentManagerProxy; _resolveFailover = resolveFailover; } /// Registers the site event-log query handler (a cluster singleton proxy). /// The event-log handler. public void RegisterEventLogHandler(IActorRef handler) => _eventLogHandler = handler; /// Registers the node-local parked-message handler (the replicated-store owner on this node). /// The parked-message handler. public void RegisterParkedMessageHandler(IActorRef handler) => _parkedMessageHandler = handler; /// Registers the artifact-deployment handler. /// The artifact handler. public void RegisterArtifactHandler(IActorRef handler) => _artifactHandler = handler; /// /// Resolves how one of the 27 non-failover commands is delivered. /// is handled separately via / because its /// leave is deferred; passing it here throws. /// /// The command to route. /// The routing decision. /// The command is not a migrated site command (or is failover). public Route ResolveRoute(object command) { ArgumentNullException.ThrowIfNull(command); return command switch { // ── Deployment + instance lifecycle → Deployment Manager singleton proxy ── RefreshDeploymentCommand => ToProxy(), EnableInstanceCommand => ToProxy(), DisableInstanceCommand => ToProxy(), DeleteInstanceCommand => ToProxy(), DeploymentStateQueryRequest => ToProxy(), // ── Artifact deployment → artifact handler (null-guarded) ── DeployArtifactsCommand c => _artifactHandler is { } h ? Forwarded(h) : Immediate(new ArtifactDeploymentResponse( c.DeploymentId, _siteId, false, "Artifact handler not available", DateTimeOffset.UtcNow)), // ── Interactive OPC UA / MxGateway → Deployment Manager singleton proxy ── // The singleton always lands on the active node, which owns the live sessions. BrowseNodeCommand => ToProxy(), SearchAddressSpaceCommand => ToProxy(), ReadTagValuesCommand => ToProxy(), VerifyEndpointCommand => ToProxy(), TrustServerCertCommand => ToProxy(), ListServerCertsCommand => ToProxy(), RemoveServerCertCommand => ToProxy(), WriteTagRequest => ToProxy(), // ── Remote queries: event log (null-guarded) + debug view (proxy) ── EventLogQueryRequest r => _eventLogHandler is { } h ? Forwarded(h) : Immediate(new EventLogQueryResponse( r.CorrelationId, _siteId, [], null, false, false, "Event log handler not available", DateTimeOffset.UtcNow)), DebugSnapshotRequest => ToProxy(), SubscribeDebugViewRequest => ToProxy(), // Fire-and-forget: the Deployment Manager never acks an unsubscribe, so the gRPC // transport Tells it and returns the synthetic ack; the actor just Forwards. UnsubscribeDebugViewRequest => new Route( RouteDisposition.TellFireAndForget, _deploymentManagerProxy, UnsubscribeDebugViewAck.Instance), // ── Parked store-and-forward actions → node-local parked handler (null-guarded) ── ParkedMessageQueryRequest r => _parkedMessageHandler is { } h ? Forwarded(h) : Immediate(new ParkedMessageQueryResponse( r.CorrelationId, _siteId, [], 0, r.PageNumber, r.PageSize, false, "Parked message handler not available", DateTimeOffset.UtcNow)), ParkedMessageRetryRequest r => _parkedMessageHandler is { } h ? Forwarded(h) : Immediate(new ParkedMessageRetryResponse( r.CorrelationId, false, "Parked message handler not available")), ParkedMessageDiscardRequest r => _parkedMessageHandler is { } h ? Forwarded(h) : Immediate(new ParkedMessageDiscardResponse( r.CorrelationId, false, "Parked message handler not available")), RetryParkedOperation r => _parkedMessageHandler is { } h ? Forwarded(h) : Immediate(new ParkedOperationActionAck( r.CorrelationId, Applied: false, "Parked message handler not available")), DiscardParkedOperation r => _parkedMessageHandler is { } h ? Forwarded(h) : Immediate(new ParkedOperationActionAck( r.CorrelationId, Applied: false, "Parked message handler not available")), // ── Inbound-API Route.To() relays → Deployment Manager singleton proxy ── RouteToCallRequest => ToProxy(), RouteToGetAttributesRequest => ToProxy(), RouteToSetAttributesRequest => ToProxy(), RouteToWaitForAttributeRequest => ToProxy(), TriggerSiteFailover => throw new ArgumentException( "TriggerSiteFailover is handled by PrepareFailover/HandleFailover, not ResolveRoute.", nameof(command)), _ => throw new ArgumentException( $"'{command.GetType().Name}' is not a migrated site command.", nameof(command)) }; Route ToProxy() => Forwarded(_deploymentManagerProxy); } private static Route Forwarded(IActorRef target) => new(RouteDisposition.Forward, target, null); private static Route Immediate(object reply) => new(RouteDisposition.ImmediateReply, null, reply); /// /// The actor's failover path: resolve the standby AND issue the leave in one step (today's /// coupled behaviour over ClusterClient, where Tell merely enqueues the ack so leave /// order is immaterial), then return the ack. /// /// The failover command. /// The ack to send back. public SiteFailoverAck HandleFailover(TriggerSiteFailover msg) => FailoverCore(msg, commitLeaveImmediately: true).Ack; /// /// The gRPC failover path: resolve the standby WITHOUT leaving, build the ack, and hand back a /// deferred . The caller must send the ack before /// invoking the leave — otherwise a caller reaching the leaving node sees a broken stream /// instead of its ack (the ack-before-Leave rule). /// /// The failover command. /// The ack and the deferred leave (leave is null when refused). public FailoverOutcome PrepareFailover(TriggerSiteFailover msg) => FailoverCore(msg, commitLeaveImmediately: false); private FailoverOutcome FailoverCore(TriggerSiteFailover msg, bool commitLeaveImmediately) { ArgumentNullException.ThrowIfNull(msg); // A misrouted command must be refused, not silently acted on — acting would fail over a // site the operator never selected. Checked before resolving so the resolver is untouched. if (!string.Equals(msg.SiteId, _siteId, StringComparison.Ordinal)) { return new FailoverOutcome( new SiteFailoverAck( msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: $"Command addressed to site '{msg.SiteId}' but this node serves '{_siteId}'."), CommitLeave: null); } // Site singletons are scoped to the site-specific role, so failover must target that role. var role = $"site-{_siteId}"; try { if (commitLeaveImmediately) { var target = _resolveFailover(role, false); if (target is null) { return new FailoverOutcome(NoPeerAck(msg), CommitLeave: null); } return new FailoverOutcome( new SiteFailoverAck(msg.CorrelationId, Accepted: true, target, ErrorMessage: null), CommitLeave: null); } var resolved = _resolveFailover(role, true); if (resolved is null) { return new FailoverOutcome(NoPeerAck(msg), CommitLeave: null); } return new FailoverOutcome( new SiteFailoverAck(msg.CorrelationId, Accepted: true, resolved, ErrorMessage: null), CommitLeave: () => _resolveFailover(role, false)); } catch (Exception ex) { // A fault must be reported to the operator, never thrown into supervision (over // ClusterClient) or surfaced as a broken stream (over gRPC). return new FailoverOutcome( new SiteFailoverAck( msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: ex.Message), CommitLeave: null); } } private static SiteFailoverAck NoPeerAck(TriggerSiteFailover msg) => new( msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: "No standby available — failing over a lone node would be an outage."); }