Files
lmxopcua/docs/plans/2026-08-06-clusterclient-to-grpc-design.md
T

26 KiB
Raw Blame History

OtOpcUa: migrating cross-mesh ClusterClient traffic to gRPC-only

Date: 2026-08-06. Code-verified inventory of every ClusterClient touchpoint in ~/Desktop/OtOpcUa, then the migration assessment. Reference implementation: ScadaBridge shipped the identical migration 2026-07-22→08-01 (design ~/Desktop/scadaproj/scadabridge_clusterclient_to_grpc.md, plan + live gate under ~/Desktop/ScadaBridge/docs/plans/2026-07-22-clusterclient-to-grpc-*) — live-gated end to end, ClusterClient deleted. OtOpcUa runs on the same cluster boxes, so this migration converges both apps on one WAN boundary posture: gRPC + PSK, no Akka remoting between site and central.

Companion docs: docs/plans/2026-07-21-per-cluster-mesh-design.md (why the boundary is per-cluster meshes), 2026-07-22-mesh-phase2-clusterclient-transport.md (how ClusterClient got here), 2026-07-24-mesh-phase6-partition-and-colocation.md (the mesh split this completes).

Everything below was code-verified 2026-08-06. If a cited line has drifted, re-locate by the quoted identifier, never by line number.


1. What ClusterClient actually carries today

Everything crossing the central↔site mesh boundary over Akka funnels through two actors — CentralCommunicationActor (central) and NodeCommunicationActor (node) — so the migration surface is even better-bounded than ScadaBridge's was.

Receptionist registrations (2):

Role Actor Path File:line Note
admin CentralCommunicationActor /user/central-communication ControlPlane/ServiceCollectionExtensions.cs:85 registered unconditionally in both modes (deliberate)
driver NodeCommunicationActor /user/node-communication Runtime/ServiceCollectionExtensions.cs:416 same

Path constants: Commons/Messages/Mesh/MeshPaths.cs. Receptionist + DPS extensions are force-started via Cluster/Resources/akka.conf:16-24; receptionist role scoping deliberately empty (akka.conf:113-119).

Client creation (2 sites, asymmetric discovery — documented Cluster/MeshTransportOptions.cs:16-21):

  • Central: one ClusterClient per application Cluster, contacts loaded from the database (ClusterNode rows WHERE Enabled AND NOT MaintenanceMode, grouped by ClusterId, akka.tcp://…/system/receptionist built from Host+AkkaPort) — CentralCommunicationActor.cs:207-245, refreshed every MeshTransport:ContactRefreshSeconds (default 60). Factory seam IMeshClusterClientFactory / DefaultMeshClusterClientFactory (ControlPlane/Communication/IMeshClusterClientFactory.cs).
  • Node: one central-cluster-client, contacts from static config MeshTransport:CentralContactPoints[] — Runtime/ServiceCollectionExtensions.cs:394-407, created only under MeshTransport:Mode=ClusterClient.

Central → node (4 message types), all ClusterClient.SendToAll("/user/node-communication", …) fanned across every per-cluster client (CentralCommunicationActor.cs:167-168; SendToAll not Send is load-bearing — Send would deploy to one node only). Node side re-emits on the local EventStream (NodeCommunicationActor.cs:70-74); subscribers (DriverHostActor, ScriptedAlarmHostActor) are transport-blind.

Message Purpose Sender Backstop layered on top
DispatchDeployment "revision exists — fetch + apply" (payload-free: id + RevisionHash + correlation; artifact bytes ride gRPC config-serve) ConfigPublishCoordinator (admin singleton), :198 2-min apply deadline (DefaultApplyDeadline, :60, handling :283-300) + boot replay of orphan Applying rows in DriverHostActor.Bootstrap()
RestartDriver AdminUI restart of one driver instance AdminOperationsActor.cs:431 none — local RestartDriverResult answers the UI before/independent of node delivery
ReconnectDriver AdminUI reconnect AdminOperationsActor.cs:461 none
AlarmCommand operator Ack/Shelve/Unshelve into the scripted-alarm engine AdminOperationsActor.cs:122,169 none

Node → central (1 message type), ClusterClient.Send("/user/central-communication", …) (Send not SendToAll is load-bearing — SendToAll would double-count the node at the coordinator, NodeCommunicationActor.cs:89-92):

Message Purpose Path
ApplyAck (DeploymentId, NodeId, Outcome, FailureReason, CorrelationId) per-node deploy acknowledgement DriverHostActor.SendAck (:2488-2507) → _ackRouter = NodeCommunicationActor → central HandleApplyAck (:171-187) → Forward to the ConfigPublishCoordinator singleton proxy → PersistNodeAck (:247-260)

The invariant that sizes this whole migration: there is NO Ask across this boundary. Every message is a one-way Tell; no sender preservation, no reply futures, no Ask timeouts (NodeCommunicationActor.cs:30-35 states it; code confirms it). The per-cluster mesh design doc's line about "sender preservation is the whole Ask idiom" describes ScadaBridge, not this codebase — do not size off it.

Transport guarantees today: none. akka.cluster.client.buffer-size = 0 (no buffering, no replay), reconnect-timeout = off (akka.conf:106,111). No client for a cluster ⇒ the command is logged and dropped (CentralCommunicationActor.cs:146-158); no central client ⇒ the ack is dropped (NodeCommunicationActor.cs:78-87). Serialization is Akka's default Newtonsoft JSON — no serialization-bindings exist for any mesh message — under a 128 KB maximum-frame-size with drop-don't-teardown semantics (akka.conf:35-47). There is no proto contract and no serializer discipline to inherit; the wire surface must be authored fresh.

Not ClusterClient (stays untouched): gRPC config-serve (Phase 3), gRPC telemetry dial (Phase 5), LocalDb sync gRPC, Akka secrets replication (pair-local), the pair-local DPS channels (alarm-commands' OPC-UA-originated leg published by OtOpcUaServerHostedService, redundancy-state, fleet-status), and the node-local EventStream delivery leg.


2. Why gRPC-only is a natural completion, not a rewrite

  1. ClusterClient is the last Akka hop crossing the WAN. Config artifacts (Phase 3), telemetry (Phase 5) and LocalDb sync already ride gRPC across the boundary. After this migration, Akka remoting is pair-internal only — the exact end state ScadaBridge reached.
  2. The surface is tiny. 5 one-way message types, 2 choke-point actors, no Ask/reply semantics, no ordering or delivery guarantees to preserve beyond "fan to all driver nodes" and "one ack per node." ScadaBridge migrated 35 message types with Ask semantics in ~5 weeks; this is a fraction of that.
  3. The playbook is proven on the same clusters. ScadaBridge's design, PSK pattern, coexistence flags, deletion sequencing and live-gate format all shipped and survived their gates. OtOpcUa co-deploys on the same boxes; converging both apps on one boundary posture (gRPC + per-relationship PSK, no cross-WAN Akka) halves the firewall/audit story.
  4. Auth is a real gap, not hygiene. Cross-mesh commands (RestartDriver, AlarmCommand, DispatchDeployment) ride unauthenticated Akka remoting today — anyone who can reach a node's :4053 and speak the protocol can reach /user/node-communication. The gRPC path inherits the family's fail-closed bearer/PSK discipline that already gates the other three channels.
  5. Silent drop becomes visible failure. buffer-size = 0 means an undeliverable command vanishes with one central-side log line. A unary gRPC call fails per node, with a status code — the deploy path's 2-min deadline stops being the only witness.

3. What the migration takes

3.1 Topology — both directions already have a listener to hang off

This is the OtOpcUa-shaped economy (ScadaBridge had to build central's listener from scratch; OtOpcUa does not):

  • NodeCommandService (central→node commands) is mapped on the existing Telemetry listener (Telemetry:GrpcListenPort, h2c, rig :4056) that every driver node already hosts. On a split-topology node, SplitTopologyTransportValidator already forces Telemetry:Mode=Grpc, whose validator forces GrpcListenPort > 0 — the listener is guaranteed up.
  • CentralCommandService (node→central acks) is mapped on the existing ConfigServe listener (ConfigServe:GrpcListenPort, rig :4055) that admin nodes host. A new validator rule ties MeshTransport:Mode=Grpc + admin role to ConfigServe:GrpcListenPort > 0.

Rejected: a dedicated mesh-command port. It would need a new ClusterNode column + EF migration + the seed-backfill UPDATE dance (docker-dev/seed/seed-clusters.sql:126-128 exists precisely because Phase 5's column landed NULL on persisted volumes) + compose churn on six services — for no isolation gain, because the interceptors path-scope shared listeners anyway (three services already coexist in one AddGrpc pipeline).

Rejected: a node-held long-lived command stream (node subscribes, central pushes). It re-implements the connection/reconnect management the receptionist gave for free, inverts the existing dial direction convention (central already dials nodes for telemetry), and buys nothing for 4 command types. Same reasoning ScadaBridge recorded for the mirror-image option.

Either-pair-node delivery is already safe. Acks land on whichever central answers; the receiving CentralCommandService Tells CentralCommunicationActor, which Forwards to the ConfigPublishCoordinator singleton proxy — same intra-pair routing that makes today's receptionist arrival-node-agnostic. Commands land on the specific node dialed, which is the required semantic (each node must apply its own deployment).

3.2 Contract

One new proto: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto, package mesh_transport.v1, csharp_namespace ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1 (deliberately NOT MeshCommand — that C# name is taken by the existing envelope record Commons.Messages.Mesh.MeshCommand).

service NodeCommandService {
  // Central → node. One-way semantics: the ack means "received and re-emitted on the
  // node's EventStream", nothing more. No retry is layered on a failure.
  rpc Execute (CommandEnvelope) returns (ExecuteAck);
}
service CentralCommandService {
  // Node → central. One-way semantics: the ack means "handed to the coordinator proxy".
  rpc ReportApply (ApplyAckMessage) returns (ReportAck);
}
message CommandEnvelope {
  string correlation_id = 1;
  oneof command {
    DispatchDeploymentCommand dispatch_deployment = 2;
    RestartDriverCommand      restart_driver      = 3;
    ReconnectDriverCommand    reconnect_driver    = 4;
    AlarmCommandMessage       alarm_command       = 5;
  }
}

Codegen is build-time Grpc.Tools exactly like the two existing protos (Commons.csproj:28-31 <Protobuf … GrpcServices="Both"/>; generated code is NOT checked in — OtOpcUa does not have ScadaBridge's linux_arm64 protoc segfault; the rig image build is the verification). Add a MeshTransportProtoContract contract-lock helper + tests mirroring TelemetryProtoContract.cs / TelemetryProtoContractTests.cs so a new oneof case cannot ship unhandled. Mapper: MeshTransportDtoMapper in Commons with round-trip golden tests for all five messages (the ScadaBridge T1A.1/T1B.1 lesson: nullable members, init-only props and enum-zero placement are where mappers silently drop data — reserve proto enum 0 as UNSPECIFIED, never a meaningful value).

3.3 Semantics to preserve (all transport-agnostic, all carry over)

  • Fire-and-forget stays fire-and-forget. No retries, no queues, no new delivery guarantees. A failed Execute is logged per node with its status code and dropped — an honest upgrade over the silent drop, not a semantic change. The deploy path's backstops (2-min deadline, orphan-Applying boot replay) remain the only recovery mechanisms.
  • SendToAll fan ⇒ dial every enabled, non-maintenance ClusterNode. The recipient set is already DB-derived (DiscoverDriverNodes derives the expected-ack set from the same rows) — the gRPC dispatcher uses the same query shape, so dispatch set == expected-ack set by construction.
  • Send singleton-counting ⇒ one ReportApply per node per dispatch. Unary gives this naturally; the node tries central endpoints in order and stops at the first success.
  • Node-side delivery stays the EventStream. The gRPC service Tells NodeCommunicationActor the decoded message; the actor's existing re-emit (:70-74) is untouched, so EventStreamCommandDeliveryTests and every subscriber remain valid.
  • Central-side ack handling stays HandleApplyAck. The gRPC service Tells CentralCommunicationActor; the Forward-to-proxy and PersistNodeAck paths are untouched.
  • Fused nodes work unchanged. central-1/-2 are admin,driver,cluster-MAIN: they serve both services, dial their own pair with the MAIN key, and ack to themselves. Nothing special.

3.4 Work you must do anyway: auth — per-cluster PSK from the Secrets store (decided 2026-08-06)

The command plane is unauthenticated today. The gRPC migration adopts ScadaBridge's live-gate-proven shape, adapted to OtOpcUa's conventions:

  • Key scope: one PSK per application Cluster — OT-MESH-PSK-<clusterId> (e.g. OT-MESH-PSK-MAIN, OT-MESH-PSK-SITE-A), covering both directions of that cluster's traffic. Steelmanned alternative — one fleet-wide shared key (what the other three OtOpcUa channels do): simpler seeding, but a compromised site node could then issue RestartDriver/AlarmCommand to every other site and forge acks fleet-wide. ScadaBridge rejected the shared key on exactly this blast-radius argument; the command plane is where it bites hardest, so we do too. (The three existing shared-bearer channels are read/telemetry planes; retrofitting them is out of scope.)
  • Node side (one key): new option MeshTransport:GrpcPsk, production value ${secret:OT-MESH-PSK-<clusterId>}, resolved by the existing pre-host SecretReferenceExpander (Host/Program.cs:93-112) — zero new resolution code. Works on driver-only nodes (node-local SQLite secrets store + env KEK; no ConfigDb involved).
  • Central dial + serve sides (many keys, cluster set is DB-dynamic): new IMeshPskProvider (ControlPlane) over the runtime ISecretResolver, resolving OT-MESH-PSK-{clusterId} at channel-build/verify time, cached per cluster, invalidated on contact refresh, fail-closed (unresolvable ⇒ deny/skip, never proceed unauthenticated). A MeshTransport:ClusterPsks:{clusterId} config map is checked before the Secrets store — the rig/test override, mirroring ScadaBridge's SitePsks__site-* env pattern; production leaves it empty.
  • Node-side server interceptor MeshCommandAuthInterceptor, cloned from TelemetryStreamAuthInterceptor: path prefix /mesh_transport.v1.NodeCommandService/, authorization: Bearer extraction, CryptographicOperations.FixedTimeEquals over UTF-8, fail-closed when GrpcPsk unset, reject PermissionDenied (the 2-of-3 majority convention; ConfigServeAuthInterceptor's Unauthenticated is the outlier — don't copy it). Exactly ONE public constructor — Grpc.AspNetCore silently stops invoking an interceptor with two (documented TelemetryStreamAuthInterceptor.cs:38-42, pinned by HasExactlyOnePublicConstructor; ScadaBridge lost a live-gate day to this).
  • Central-side server interceptor CentralCommandAuthInterceptor: gates /mesh_transport.v1.CentralCommandService/; callers send x-otopcua-cluster: <clusterId> so central picks which key to verify; missing/unknown cluster ⇒ PermissionDenied (fail-closed). The header is a central-side routing hint only — per-cluster isolation is proven by wrong-key rejection, not by the header; do not add a header requirement to the node-side interceptor (ScadaBridge's gate recorded that as theatre).
  • Client attach: raw Metadata authorization: Bearer <psk> per call, the exact pattern of GrpcDeploymentArtifactFetcher.cs:122 / TelemetryStreamClient.cs:134.
  • Boot validation: MeshTransportOptionsValidator extended per the house rules (roles from IConfiguration via RoleParser.ResolveEffectiveRoles — never IClusterRoleInfo; require keys only where the mode makes them load-bearing; error messages name the exact key and env form, never echo the value). Under Mode=Grpc: driver role ⇒ GrpcPsk non-empty + CentralGrpcEndpoints non-empty and http(s)://-prefixed.
  • Transport honesty: bearer PSK over plaintext h2c is readable/replayable on-path — same accepted posture (network isolation) as the three existing channels, and strictly better than today's unauthenticated remoting. TLS on the internal listeners is follow-on hardening; the PSK design doesn't change if it lands.
  • Seeding/rotation: the secret CLI's Reference audit & seed flow per store; rotation = set new value both sides, restart the pair (pairs restart together by discipline). Rig uses committed dev literals per its zero-operator-step convention: dev-mesh-psk-MAIN, dev-mesh-psk-SITE-A, dev-mesh-psk-SITE-B.

3.5 Endpoint discovery — both patterns already exist

  • Central → node: ClusterNode.Host + GrpcPort ⇒ http://{host}:{port} — the exact TelemetryNodeSource pattern, same Enabled && !MaintenanceMode filter, refreshed on the existing MeshTransport:ContactRefreshSeconds timer that today refreshes Akka contacts. No new column, no migration, no seed change.
  • Node → central: new MeshTransport:CentralGrpcEndpoints[] (e.g. ["http://central-1:4055", "http://central-2:4055"]), tried in order per ack — the GrpcDeploymentArtifactFetcher endpoint-failover loop shape, channels cached per endpoint. It replaces CentralContactPoints (deleted in Phase 3) rather than reusing ConfigSource:CentralFetchEndpoints, because a Direct-mode fused node legitimately has no ConfigSource section but still acks deployments.
  • Deadlines are net-new (no Ask timeouts existed): MeshTransport:DispatchTimeoutSeconds (default 10) on Execute, MeshTransport:AckTimeoutSeconds (default 10) on ReportApply, enforced client-side via linked CTS like the fetcher. Never retry DeadlineExceeded — with one-way semantics a late-delivered command is worse than a dropped one only if we retry it into a duplicate.

3.6 The mode switch — third value in, one value out

MeshTransport:Mode gains Grpc. End state: Dps (compiled default; in-mesh/single-mesh topologies where admin and driver share one gossip ring) | Grpc (split topologies). ClusterClient is deleted. The DPS branch is deliberately retained: the "Phase 6 deletes the DistributedPubSub branch" comments (MeshTransportOptions.cs:11-13, ConfigPublishCoordinator.cs:70-73, AdminOperationsActor.cs:90-92) describe a deletion that never happened and contradict docs/Configuration.md:150-156, which records the deliberate reversal — the comments get corrected in Phase 0. SplitTopologyTransportValidator accepts {ClusterClient, Grpc} during the migration (Phase 0 relaxation — without it, flipping any rig node to Grpc fails boot) and requires Grpc from Phase 3.

The coexistence granularity falls out of where the switch is read: central's mode governs the command direction, each node's mode governs its ack direction — so a single site pair can be flipped to gRPC acks while everything else stays on ClusterClient, and vice versa. (Better than ScadaBridge's central-wide SiteTransport, whose per-site coexistence gate had to be recorded as unachievable.)

3.7 Deletions at the end

DefaultMeshClusterClientFactory + IMeshClusterClientFactory, both receptionist registrations, node client creation (Runtime/ServiceCollectionExtensions.cs:394-407) and central RebuildClient/per-cluster client cache (keep LoadContactsFromDb — it now feeds the gRPC dialer's node list), akka.conf's ClusterClientReceptionistExtensionProvider line + the whole akka.cluster.client block (keep the DPS extension — pair-local channels live), MeshTransport:CentralContactPoints + its validator rules + rig env, the ClusterClient mode value, and the ClusterClient-shaped tests (MeshClusterClientBoundaryTests is replaced by a gRPC-boundary equivalent — the "no Akka association crosses the boundary" assertion is the point of the whole program and must survive as a test). Akka.Cluster.Tools package refs stay (ClusterSingleton + DPS).

3.8 Deliberately out of scope

  • FleetStatusBroadcaster is silently central-pair-blind post-Phase-6 (derives "fleet" from central's own gossip ring). Real, pre-existing, transport-independent — filed as its own issue in Phase 0, not folded in.
  • Cross-mesh redundancy-state / fleet-status visibility — deferred by Phase 5, stays deferred; this migration adds no new cross-mesh channels.
  • alarm-commands' pair-local DPS leg (OPC UA Part 9 method calls published node-locally by OtOpcUaServerHostedService:158-159) — untouched; it never crosses meshes.
  • Delivery-guarantee upgrades (queuing, retry, dedup) — explicitly not this program.
  • Retrofitting per-cluster PSKs onto the three existing shared-bearer channels — follow-on.

4. Code-verified findings this design is sized on (plan-vs-code corrections)

Baked in at authoring time rather than discovered mid-implementation (the ScadaBridge §7 lesson, run as a three-track inventory before writing this doc):

  1. No Ask exists across the boundary — the per-cluster mesh design doc's sender-preservation paragraph describes ScadaBridge. OtOpcUa's surface is 4+1 one-way Tells. (Also: its line listing "queries, subscribe handshakes" over ClusterClient — none exist.)
  2. "Phase 6 deletes the DPS branch" never happened and the compiled default is still Dps; three source comments assert the opposite of docs/Configuration.md:150-156. This design keeps DPS for in-mesh topologies and deletes only ClusterClient; the comments get fixed.
  3. There is no ICommandTransport seam. The seam is the MeshCommand envelope + the nullable meshRouter/centralClient/ackRouter ctor params. The gRPC branch lands inside the two choke-point actors (a third mode branch), NOT as a new polymorphic interface — the same "no interface extraction" economy ScadaBridge's §7.1 landed on.
  4. ConfigPublishCoordinator subscribes to the deployment-acks DPS topic unconditionally (:132) in both modes — it is the Dps-mode ack ingress and must survive; do not "clean it up" while deleting ClusterClient.
  5. FleetStatusBroadcaster was left behind by Phase 6 — see §3.8; issue, not scope.
  6. Interceptor multi-constructor trap and enum-zero mapping traps are pre-known from ScadaBridge's live gate and OtOpcUa's own TelemetryStreamAuthInterceptor.cs:38-42 — both are encoded as tests in the plan, not left to be rediscovered.

5. The other knob: keep ClusterClient (the do-nothing steelman)

ClusterClient works today: Phase 7 ran 6/6 failover drills through it, SendToAll fan-out and DB-driven contacts are debugged, and the 5-message surface is small enough that "it's a lot of transport" is not an argument. Keeping it costs nothing now.

What it leaves on the table: (1) the command plane stays unauthenticated across the WAN — the one boundary path with no auth story, in the app whose sister already closed the identical gap; (2) two transport postures on one set of boxes — every firewall rule, packet capture and incident runbook must reason about Akka remoting and gRPC crossing the same boundary; (3) buffer-size=0 silent drops stay silent; (4) Akka.NET upstream has signalled the ClusterClient/receptionist pair as legacy — ScadaBridge's design recorded the same deprecation-direction argument and it has only aged one way.

Verdict: the auth gap alone justifies Phase 0-style hardening, and once auth + contract exist, the remaining migration is small. Do the whole thing.


6. Options

  • A — full migration (recommended): contract + PSK + both directions behind MeshTransport:Mode=Grpc, coexistence-flagged, then delete ClusterClient. ~2 weeks of focused work given the 5-message surface and the existing listeners/patterns.
  • B — auth-only: bolt a PSK story onto ClusterClient (there isn't a natural one — Akka remoting auth means mTLS on the transport, a different and larger program) or accept the gap. Rejected: the "auth" half of the work has no cheap ClusterClient-shaped form.
  • C — do nothing: see §5.

Recommendation: A.


7. Phasing and effort

Phase Content Est. Parallel with
0 Contract (proto + mappers + goldens), auth interceptors + IMeshPskProvider, options/validators (+Grpc value accepted, SplitTopology relaxation), rig PSK seeding, stale-comment fixes, FleetStatus issue filed 2–3 d 1A/1B prep
1A Central→node command plane behind central's Mode=Grpc (worktree A) 3–4 d 1B
1B Node→central ack plane behind node's Mode=Grpc (worktree B) 2–3 d 1A
2 Full-rig flip + soak + kill drills (live gate) 1–2 d —
3 Deletion + validator tightening + docs sweep 1–2 d —
4 Terminal live gate on the post-deletion build 1 d —

Critical path ≈ 0 → 1A → 2 → 3 → 4: ~1.5–2.5 weeks, one-third of ScadaBridge's, which is what a 5-message no-Ask surface with pre-built listeners should cost.

Risks, honestly: the mapper/enum-zero class of bug (mitigated by goldens + contract-lock tests, but ScadaBridge still found five of these in implementation); the Kestrel explicit-endpoints trap does NOT apply (no new listeners — but any future "just add a port" deviation re-imports it, see Program.cs:564-583 before touching anything there); PSK cache invalidation on cluster add/remove is the one genuinely new stateful component (IMeshPskProvider) — its fail-closed behavior needs adversarial tests, not just happy-path.

Implementation plan: 2026-08-06-clusterclient-to-grpc.md. Live gate: 2026-08-06-clusterclient-to-grpc-live-gate.md. Central tracking: 2026-08-06-clusterclient-to-grpc-tracking.md.