feat(comm): extract SiteCommandDispatcher; site serves commands over gRPC too (T1B.2)

Refactor SiteCommunicationActor's central→site routing table into one
SiteCommandDispatcher — the single routing truth for the 28 migrated commands
(IntegrationCallRequest, the dead 29th, stays on the actor and out of the
dispatcher). The Akka actor and the new SiteCommandGrpcService both route through
one dispatcher instance so the two transports can never drift on where a command
goes. Server-side only: nothing central flips to gRPC yet (that is T1B.3);
ClusterClient remains the live path.

Decisions worth recording:

- Targets preserved byte-for-byte. Lifecycle/OPC UA/query/route → the Deployment
  Manager singleton proxy; DeployArtifacts/EventLog/parked → their null-guarded
  handlers with the exact same "handler not available" replies; the parked
  handler stays NODE-LOCAL (per-node replicated-store owner), never the singleton
  proxy — pinned by a dispatcher test that asserts the target is the parked probe
  and NOT the dm proxy.

- Sender preservation intact. The actor's command handlers became thin
  DispatchCommand delegations that still Forward (central Ask → reply routes
  straight back); the existing SiteCommunicationActorTests pass unchanged, which
  is the regression guard for that plumbing. UnsubscribeDebugView keeps its
  fire-and-forget shape: the actor Forwards, the gRPC service Tells + returns the
  synthetic UnsubscribeDebugViewAck so a unary RPC still answers.

- Ack-before-Leave on failover. The dispatcher's PrepareFailover resolves the
  standby with a DRY-RUN (no leave) to build the ack, and hands back a deferred
  CommitLeave; the gRPC service returns the ack, then schedules the real
  Cluster.Leave — so a caller reaching the very node about to leave still gets its
  ack instead of a broken stream. The actor path keeps today's coupled
  resolve-and-leave (over ClusterClient the ack Tell only enqueues, so order is
  immaterial). Proven at both levels: a dispatcher test asserts the ack is built
  before CommitLeave runs, and a TestServer test asserts the recorded seam order
  is resolve-then-leave.

- ControlPlaneAuthInterceptor gates SiteCommandService by EXTENDING
  DefaultGatedPrefixes (descriptor-derived), not by adding a constructor — the
  one-public-ctor invariant and its test stay green.

Tests: SiteCommandDispatcherTests (28-command routing incl. parked node-locality
and both failover paths) and SiteCommandGrpcService TestServer tests (auth,
readiness→Unavailable, one command per oneof group, failover ordering). Full
solution build 0/0; Communication.Tests 574 and Host.Tests 377 green. No active
<Protobuf> item.
This commit is contained in:
Joseph Doherty
2026-07-22 19:10:44 -04:00
parent 59b13d317b
commit 518c699b90
9 changed files with 1315 additions and 243 deletions
@@ -863,7 +863,20 @@ akka {{
_nodeOptions.SiteId);
}
// Create SiteCommunicationActor for receiving messages from central
// The ONE routing table for central→site commands, shared by the Akka
// SiteCommunicationActor (below) and the gRPC SiteCommandGrpcService (SetReady at the end
// of this method). Its failover seam resolves (dryRun) or performs the graceful Leave via
// the shared ClusterFailoverCoordinator so the central/site paths cannot drift.
var siteCommandDispatcher = new SiteCommandDispatcher(
_nodeOptions.SiteId!,
dmProxy,
(role, dryRun) => ZB.MOM.WW.ScadaBridge.Communication.ClusterState.ClusterFailoverCoordinator
.FailOverOldest(_actorSystem!, role, dryRun)?.ToString());
// Create SiteCommunicationActor for receiving messages from central. It routes commands
// through the shared dispatcher; RegisterLocalHandler (below) registers the handlers INTO
// that dispatcher, so the gRPC command service sees the same registrations. The site→central
// transport is selected above (default Akka); both are passed in.
var siteCommActor = _actorSystem.ActorOf(
Props.Create(() => new SiteCommunicationActor(
_nodeOptions.SiteId!,
@@ -871,7 +884,8 @@ akka {{
dmProxy,
activeNodeCheck,
null,
centralTransport)),
centralTransport,
siteCommandDispatcher)),
"site-communication");
// Register local handlers with SiteCommunicationActor
@@ -1141,5 +1155,12 @@ akka {{
grpcServer?.SetOperationTrackingStore(siteTrackingStore);
}
grpcServer?.SetReady(_actorSystem!);
// Site command plane: hand the shared dispatcher to the gRPC command service and flip it
// ready at the same point as the streaming server — the actor graph exists and the local
// handlers have been registered into the dispatcher, so it can route commands now.
var commandService = _serviceProvider
.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCommandGrpcService>();
commandService?.SetReady(siteCommandDispatcher);
}
}