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.");
}