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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user