feat(mesh): gRPC ApplyAck path behind Mode=Grpc

Task 1B.2 — the node→central half of the ClusterClient→gRPC migration.

GrpcApplyAckSender walks MeshTransport:CentralGrpcEndpoints in order and
STOPS AT THE FIRST SUCCESS. That single `return` is the whole single-count
guarantee: the transport it replaces was ClusterClient.Send, deliberately not
SendToAll, because both central nodes forward to the SAME
ConfigPublishCoordinator singleton proxy — an ack posted to both would have the
coordinator count one node twice and could seal a deployment on a doubled count.
The list is an availability list, not a fan-out list.

No retry beyond that one ordered pass, no queue: the receptionist buffered
nothing and retried nothing, and this is like-for-like, not an upgrade to
delivery guarantees. All endpoints failing is still a DROP — but a loud one,
naming the node, deployment, correlation id and the status each endpoint
answered with, which is what separates "central is down" from "central rejected
this node's key". The bearer never reaches a log; only endpoints and status
codes do. Per-endpoint GrpcChannel cache and the endpoint loop clone
GrpcDeploymentArtifactFetcher; the cluster id in x-otopcua-cluster comes from
this node's own cluster-<id> role via RoleParser, off the same bound
Cluster:Roles ClusterRoleInfo reads.

SendAsync never throws: the caller discards the task, so an escaping exception
would become an unobserved fault — a drop with no log line, the exact failure
mode this migration removes.

NodeCommunicationActor gains an OPTIONAL ackSender; every existing test compiles
and passes unedited. It is checked before the ClusterClient so the two can never
both fire. The registration arm creates no ClusterClient under Grpc.

NOTE — one wiring change beyond the actor: DriverHostActor's ackRouter is now
`useClusterClient || useGrpc ? nodeComm : null`. Left at ClusterClient-only, a
Grpc node's ack would have gone out on the DPS deployment-acks topic and never
reached the sender at all.
This commit is contained in:
Joseph Doherty
2026-08-06 05:48:29 -04:00
parent e1db90f9c9
commit 308c6aaa0f
6 changed files with 775 additions and 13 deletions
@@ -37,23 +37,31 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Communication;
public sealed class NodeCommunicationActor : ReceiveActor
{
private readonly IActorRef? _centralClient;
private readonly IApplyAckSender? _ackSender;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates the props for this actor.</summary>
/// <param name="centralClient">
/// ClusterClient dialling central's receptionist, or <see langword="null"/> under
/// <c>MeshTransport:Mode = Dps</c>, where acks still go out over DistributedPubSub from
/// <c>DriverHostActor</c> and this actor never sees them.
/// <c>DriverHostActor</c> and this actor never sees them — and under
/// <c>Mode = Grpc</c>, where no ClusterClient is created at all.
/// </param>
/// <param name="ackSender">
/// The gRPC ack transport under <c>MeshTransport:Mode = Grpc</c>, or <see langword="null"/>
/// under <c>Dps</c> and <c>ClusterClient</c>.
/// </param>
/// <returns>The props.</returns>
public static Props Props(IActorRef? centralClient) =>
Akka.Actor.Props.Create(() => new NodeCommunicationActor(centralClient));
public static Props Props(IActorRef? centralClient, IApplyAckSender? ackSender = null) =>
Akka.Actor.Props.Create(() => new NodeCommunicationActor(centralClient, ackSender));
/// <summary>Initializes a new instance of the <see cref="NodeCommunicationActor"/> class.</summary>
/// <param name="centralClient">ClusterClient dialling central, or <see langword="null"/>.</param>
public NodeCommunicationActor(IActorRef? centralClient)
/// <param name="ackSender">The gRPC ack transport, or <see langword="null"/>.</param>
public NodeCommunicationActor(IActorRef? centralClient, IApplyAckSender? ackSender = null)
{
_centralClient = centralClient;
_ackSender = ackSender;
// Inbound from central. Each type is listed explicitly rather than caught by a ReceiveAny:
// an unknown type must dead-letter loudly during development rather than be republished
@@ -75,13 +83,28 @@ public sealed class NodeCommunicationActor : ReceiveActor
private void HandleApplyAck(ApplyAck ack)
{
if (_ackSender is not null)
{
// Fire-and-forget by construction, exactly as the ClusterClient Tell below is. The sender
// never throws and never retries (see IApplyAckSender), so there is nothing to await and
// nothing to pipe back: awaiting would stall this mailbox behind a network call to central,
// and an Ask would invent a request/response the one-way contract does not have.
//
// Checked BEFORE the ClusterClient. The registration arm builds exactly one transport, so
// both are never set — but were they, posting over both would deliver two acks for one
// apply and the coordinator would count this node twice.
_ = _ackSender.SendAsync(ack);
return;
}
if (_centralClient is null)
{
_log.Warning(
"No central ClusterClient — dropping ApplyAck for {DeploymentId}. The ack is not "
"No central ack transport — dropping ApplyAck for {DeploymentId}. The ack is not "
+ "buffered and will not be retried, so the deployment will time out at central "
+ "naming this node even though it applied successfully. Check "
+ "MeshTransport:CentralContactPoints",
+ "MeshTransport:CentralContactPoints (ClusterClient mode) or "
+ "MeshTransport:CentralGrpcEndpoints (Grpc mode)",
ack.DeploymentId);
return;
}