feat(mesh): gRPC command fan-out behind Mode=Grpc

MeshGrpcCommandDialer places one NodeCommandService.Execute call on one
node: a GrpcChannel cached per endpoint (the GrpcDeploymentArtifactFetcher
shape), the TARGET node's cluster's bearer plus x-otopcua-cluster naming
that cluster, and a per-call deadline linked to the caller's token. A
cluster with no resolvable key is refused locally with Unauthenticated —
there is no arm on which a command leaves central unauthenticated, even
one the far side would have rejected.

CentralCommunicationActor gains the third mode branch. The contact load
now also projects GrpcPort and, from the SAME rows in the SAME pass,
builds the dispatch set — which is what makes 'the gRPC fan reaches
exactly the SendToAll recipient set' structural rather than two queries
that have to keep agreeing. A row with a null GrpcPort is skipped
warn-once (the TelemetryNodeSource precedent). Per-node results are
piped back for LOGGING ONLY: named node, gRPC status, dropped. No retry,
no queue.

Every dial is scheduled on the thread pool rather than called from the
receive. Found by review and pinned red-first: an async method runs
synchronously to its first await, and the dialer's pre-await work is
TryGetPsk, which blocks on the secret store — bounded but seconds, and
repeated after every refresh since the refresh drops the cache. Dialing
inline parked the actor's mailbox, so deployment acks queued behind a
slow node; the regression test tells an ApplyAck while a dial is held
and fails without the hop.

The contact refresh also wires the Phase 1A register: it publishes the
loaded cluster ids as the PSK provider's known-cluster set and drops the
key cache, so a rotated key takes effect within one ContactRefreshSeconds.
Neither happens under Dps or ClusterClient.

Both existing mode branches are behaviourally untouched and every
existing fact passes unmodified. The one edit to shared text: the
Status.Failure warning drops the word 'ClusterClient', since that handler
now serves all three transports.

ControlPlane.Tests 205 -> 230, Host.Tests 92, Cluster.Tests 184, 0 warnings.
This commit is contained in:
Joseph Doherty
2026-08-06 05:58:21 -04:00
parent 14d21496fe
commit 6a86201105
6 changed files with 1428 additions and 11 deletions
@@ -66,6 +66,15 @@ public static class ServiceCollectionExtensions
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
var options = resolver.GetService<IOptions<MeshTransportOptions>>().Value;
// Through IServiceProvider rather than the Akka resolver, deliberately: these two are
// OPTIONAL (Grpc mode only, and admin-role only within that), and GetService's
// null-for-absent contract is the thing being relied on. The Akka resolver's
// GetService<T> makes no such promise, and this same block runs in test harnesses whose
// service collections register neither.
var serviceProvider = resolver.GetService<IServiceProvider>();
var dialer = serviceProvider.GetService<IMeshCommandDialer>();
var pskProvider = serviceProvider.GetService<IMeshPskProvider>();
var actor = system.ActorOf(
CentralCommunicationActor.Props(
dbFactory,
@@ -75,7 +84,9 @@ public static class ServiceCollectionExtensions
// which Akka.Hosting materialises the singleton proxy relative to this block.
() => registry.TryGet<ConfigPublishCoordinatorKey>(out var coordinator)
? coordinator
: null),
: null,
dialer,
pskProvider),
MeshPaths.CentralCommunicationName);
// Registered unconditionally, in both transport modes. Under Dps nothing dials it, and