refactor(mesh): step 2 — delete the node-side ClusterClient and its centralClient parameter

WithOtOpcUaRuntimeActors no longer builds a ClusterClient from
MeshTransport:CentralContactPoints, and NodeCommunicationActor.Props takes only the gRPC
IApplyAckSender. The ackRouter gate narrows from `useClusterClient || useGrpc` to `useGrpc`,
and the CentralClusterClientName constant is gone.

NodeCommunicationActor's no-transport warning now names MeshTransport:CentralGrpcEndpoints
and MeshTransport:Mode only — the old text pointed an operator at a key that no longer
exists.

Ripples in this step: the two ClusterClient-shaped facts in NodeCommunicationActorTests
(Send-not-SendToAll, and sender-wins-over-a-client) are deleted — the first has no subject
left, the second guarded a both-transports-present state that is now unconstructible. The
Runtime ServiceCollectionExtensions fact keeps its ackRouter pin and drops its
"no ClusterClient actor exists" half for the same reason.
This commit is contained in:
Joseph Doherty
2026-08-06 15:38:49 -04:00
parent 0db40a5e7f
commit ab5b958d47
5 changed files with 63 additions and 167 deletions
@@ -1,5 +1,4 @@
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Event; using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
@@ -9,17 +8,18 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Communication; namespace ZB.MOM.WW.OtOpcUa.Runtime.Communication;
/// <summary> /// <summary>
/// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2), registered /// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2), living at
/// with the <c>ClusterClientReceptionist</c> at <see cref="MeshPaths.NodeCommunication"/>. /// <see cref="MeshPaths.NodeCommunication"/> — the path this node's <c>NodeCommandService</c>
/// gRPC ingress hands every decoded command to.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Registered per node, NOT as a cluster singleton</b> — central's ClusterClient rotates /// <b>Registered per node, NOT as a cluster singleton</b> — central dials each node's own
/// across contact points and must find a live comm actor at whichever node answers. /// command endpoint, so every node needs its own live comm actor.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Inbound commands are re-emitted on the node's <see cref="EventStream"/>, not on their /// <b>Inbound commands are re-emitted on the node's <see cref="EventStream"/>, not on their
/// DistributedPubSub topic.</b> DPS is mesh-wide, and central <c>SendToAll</c>s to every /// DistributedPubSub topic.</b> DPS is mesh-wide, and central delivers one copy to every
/// node's comm actor — so a DPS re-publish would deliver N copies of every command to every /// node's comm actor — so a DPS re-publish would deliver N copies of every command to every
/// subscriber, where N is the node count. The EventStream is node-local by construction, /// subscriber, where N is the node count. The EventStream is node-local by construction,
/// which is exactly the needed semantics. It also avoids a registration handshake with /// which is exactly the needed semantics. It also avoids a registration handshake with
@@ -36,31 +36,23 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Communication;
/// </remarks> /// </remarks>
public sealed class NodeCommunicationActor : ReceiveActor public sealed class NodeCommunicationActor : ReceiveActor
{ {
private readonly IActorRef? _centralClient;
private readonly IApplyAckSender? _ackSender; private readonly IApplyAckSender? _ackSender;
private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates the props for this actor.</summary> /// <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 — and under
/// <c>Mode = Grpc</c>, where no ClusterClient is created at all.
/// </param>
/// <param name="ackSender"> /// <param name="ackSender">
/// The gRPC ack transport under <c>MeshTransport:Mode = Grpc</c>, or <see langword="null"/> /// The gRPC ack transport under <c>MeshTransport:Mode = Grpc</c>, or <see langword="null"/>
/// under <c>Dps</c> and <c>ClusterClient</c>. /// under <c>Dps</c>, where acks still go out over DistributedPubSub from
/// <c>DriverHostActor</c> and this actor never sees them.
/// </param> /// </param>
/// <returns>The props.</returns> /// <returns>The props.</returns>
public static Props Props(IActorRef? centralClient, IApplyAckSender? ackSender = null) => public static Props Props(IApplyAckSender? ackSender = null) =>
Akka.Actor.Props.Create(() => new NodeCommunicationActor(centralClient, ackSender)); Akka.Actor.Props.Create(() => new NodeCommunicationActor(ackSender));
/// <summary>Initializes a new instance of the <see cref="NodeCommunicationActor"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="NodeCommunicationActor"/> class.</summary>
/// <param name="centralClient">ClusterClient dialling central, or <see langword="null"/>.</param>
/// <param name="ackSender">The gRPC ack transport, or <see langword="null"/>.</param> /// <param name="ackSender">The gRPC ack transport, or <see langword="null"/>.</param>
public NodeCommunicationActor(IActorRef? centralClient, IApplyAckSender? ackSender = null) public NodeCommunicationActor(IApplyAckSender? ackSender = null)
{ {
_centralClient = centralClient;
_ackSender = ackSender; _ackSender = ackSender;
// Inbound from central. Each type is listed explicitly rather than caught by a ReceiveAny: // Inbound from central. Each type is listed explicitly rather than caught by a ReceiveAny:
@@ -85,14 +77,10 @@ public sealed class NodeCommunicationActor : ReceiveActor
{ {
if (_ackSender is not null) if (_ackSender is not null)
{ {
// Fire-and-forget by construction, exactly as the ClusterClient Tell below is. The sender // Fire-and-forget by construction. The sender never throws and never retries (see
// never throws and never retries (see IApplyAckSender), so there is nothing to await and // IApplyAckSender), so there is nothing to await and nothing to pipe back: awaiting would
// nothing to pipe back: awaiting would stall this mailbox behind a network call to central, // stall this mailbox behind a network call to central, and an Ask would invent a
// and an Ask would invent a request/response the one-way contract does not have. // 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 duplicate central's work for no benefit.
try try
{ {
_ = _ackSender.SendAsync(ack); _ = _ackSender.SendAsync(ack);
@@ -115,22 +103,12 @@ public sealed class NodeCommunicationActor : ReceiveActor
return; return;
} }
if (_centralClient is null) _log.Warning(
{ "No central ack transport — dropping ApplyAck for {DeploymentId}. The ack is not "
_log.Warning( + "buffered and will not be retried, so the deployment will time out at central "
"No central ack transport — dropping ApplyAck for {DeploymentId}. The ack is not " + "naming this node even though it applied successfully. Check "
+ "buffered and will not be retried, so the deployment will time out at central " + "MeshTransport:CentralGrpcEndpoints, and that MeshTransport:Mode is Grpc on a node "
+ "naming this node even though it applied successfully. Check " + "whose central lives in another mesh",
+ "MeshTransport:CentralContactPoints (ClusterClient mode) or " ack.DeploymentId);
+ "MeshTransport:CentralGrpcEndpoints (Grpc mode)",
ack.DeploymentId);
return;
}
// Send, not SendToAll: the deploy coordinator is a single singleton, and the central comm
// actor forwards to its proxy. Its NodeId-keyed upsert absorbs a duplicate idempotently,
// so single-target is defence in depth and a bound on wasted sends — mirroring the
// corrected Grpc-branch rationale above.
_centralClient.Tell(new ClusterClient.Send(MeshPaths.CentralCommunication, ack));
} }
} }
@@ -47,7 +47,6 @@ public static class ServiceCollectionExtensions
public const string OpcUaPublishActorName = "opcua-publish"; public const string OpcUaPublishActorName = "opcua-publish";
public const string PeerProbeSupervisorName = "peer-probe-supervisor"; public const string PeerProbeSupervisorName = "peer-probe-supervisor";
public const string ContinuousHistorizationRecorderActorName = "continuous-historization-recorder"; public const string ContinuousHistorizationRecorderActorName = "continuous-historization-recorder";
public const string CentralClusterClientName = "central-cluster-client";
/// <summary> /// <summary>
/// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/> /// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/>
@@ -101,8 +100,8 @@ public static class ServiceCollectionExtensions
// with no gate at all, and both nodes would write every sample twice with nothing to say so. // with no gate at all, and both nodes would write every sample twice with nothing to say so.
services.TryAddSingleton<IHistorizationRoleView>(sp => sp.GetRequiredService<RedundancyRoleView>()); services.TryAddSingleton<IHistorizationRoleView>(sp => sp.GetRequiredService<RedundancyRoleView>());
// ClusterClient→gRPC migration Task 1B.2: the node→central ack transport under // The node→central ack transport under MeshTransport:Mode = Grpc (mesh gRPC command plane,
// MeshTransport:Mode = Grpc. Registered unconditionally but resolved only by the Grpc arm of // Task 1B.2). Registered unconditionally but resolved only by the Grpc arm of
// WithOtOpcUaRuntimeActors — the mode is not knowable here (this runs before configuration is // WithOtOpcUaRuntimeActors — the mode is not knowable here (this runs before configuration is
// read into options), and a singleton nobody resolves is never constructed, so a Dps node // read into options), and a singleton nobody resolves is never constructed, so a Dps node
// opens no channel. // opens no channel.
@@ -404,35 +403,15 @@ public static class ServiceCollectionExtensions
registry.Register<DependencyMuxActorKey>(mux); registry.Register<DependencyMuxActorKey>(mux);
// Per-cluster mesh Phase 2: the node end of the central↔node command boundary. Spawned // Per-cluster mesh Phase 2: the node end of the central↔node command boundary. Spawned
// BEFORE DriverHostActor because it is the host's ackRouter under ClusterClient mode. // BEFORE DriverHostActor because it is the host's ackRouter under Grpc mode. It has no
// It has no dependency of its own on the host — inbound commands travel via the node's // dependency of its own on the host — inbound commands travel via the node's
// EventStream — so this ordering costs nothing. // EventStream — so this ordering costs nothing.
var meshOptions = resolver.GetService<IOptions<MeshTransportOptions>>().Value; var meshOptions = resolver.GetService<IOptions<MeshTransportOptions>>().Value;
var useClusterClient = string.Equals(
meshOptions.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase);
var useGrpc = string.Equals( var useGrpc = string.Equals(
meshOptions.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase); meshOptions.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase);
IActorRef? centralClient = null; // The node→central ack transport. Built ONLY under Grpc: under Dps the ack goes out on
if (useClusterClient) // the deployment-acks topic from DriverHostActor and never reaches this actor at all.
{
// Contacts come from appsettings, not the database — the deliberate asymmetry:
// central's node set changes as operators add and retire nodes, but central's own
// address is part of the deployment. MeshTransportOptionsValidator has already
// rejected an empty or malformed list at boot, so this cannot silently produce a
// client with no contacts.
var contacts = meshOptions.CentralContactPoints
.Select(cp => ActorPath.Parse($"{cp}/system/receptionist"))
.ToImmutableHashSet();
centralClient = system.ActorOf(
ClusterClient.Props(ClusterClientSettings.Create(system).WithInitialContacts(contacts)),
CentralClusterClientName);
}
// ClusterClient→gRPC migration Task 1B.2. Deliberately EXCLUSIVE with the branch above —
// under Grpc no ClusterClient is created at all, which is half the point of the migration
// (it is the cross-WAN Akka association this program removes). The two are never both
// handed to the actor, and it prefers the sender if they ever were.
IApplyAckSender? ackSender = null; IApplyAckSender? ackSender = null;
if (useGrpc) if (useGrpc)
{ {
@@ -451,7 +430,7 @@ public static class ServiceCollectionExtensions
} }
var nodeComm = system.ActorOf( var nodeComm = system.ActorOf(
NodeCommunicationActor.Props(centralClient, ackSender), NodeCommunicationActor.Props(ackSender),
MeshPaths.NodeCommunicationName); MeshPaths.NodeCommunicationName);
// Registered with the receptionist in ALL modes: under Dps and Grpc nothing dials it, and // Registered with the receptionist in ALL modes: under Dps and Grpc nothing dials it, and
@@ -567,12 +546,11 @@ public static class ServiceCollectionExtensions
: null; : null;
var driverHost = system.ActorOf( var driverHost = system.ActorOf(
// ackRouter: under ClusterClient AND Grpc the node comm actor relays ApplyAcks across // ackRouter: under Grpc the node comm actor relays ApplyAcks across the mesh
// the boundary (which transport it then uses is its own business — the ClusterClient // boundary through the gRPC sender it was built with; under Dps it stays null and
// ref or the gRPC sender it was built with); under Dps it stays null and the host // the host publishes on the deployment-acks topic exactly as before. Routed to DPS
// publishes on the deployment-acks topic exactly as before. Grpc MUST be included // instead, a Grpc node's ack would never reach the sender at all and every
// here: routed to DPS instead, a Grpc node's ack would never reach the sender at all // deployment to it would time out while it applied perfectly.
// and every deployment to it would time out while it applied perfectly.
// dbFactory is nullable (Phase 4): a driver-only node passes null here — ConfigDb is // dbFactory is nullable (Phase 4): a driver-only node passes null here — ConfigDb is
// gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor // gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor
// guards every ConfigDb-touching path on it (UpsertNodeDeploymentState no-ops; central // guards every ConfigDb-touching path on it (UpsertNodeDeploymentState no-ops; central
@@ -580,7 +558,7 @@ public static class ServiceCollectionExtensions
// alarmStateStore (there is no ConfigDb-backed EF fallback; the host skips the alarm // alarmStateStore (there is no ConfigDb-backed EF fallback; the host skips the alarm
// host when no store was wired). // host when no store was wired).
DriverHostActor.Props(dbFactory, roleInfo.LocalNode, DriverHostActor.Props(dbFactory, roleInfo.LocalNode,
ackRouter: useClusterClient || useGrpc ? nodeComm : null, ackRouter: useGrpc ? nodeComm : null,
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles, driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
dependencyMux: mux, dependencyMux: mux,
opcUaPublishActor: publishActor, opcUaPublishActor: publishActor,
@@ -50,7 +50,7 @@ public class EventStreamCommandDeliveryTests : RuntimeActorTestBase
var driverHost = Sys.ActorOf( var driverHost = Sys.ActorOf(
DriverHostActor.Props(NewInMemoryDbFactory(), new NodeId("127.0.0.1:4053"), ackRouter: ackRouter.Ref), DriverHostActor.Props(NewInMemoryDbFactory(), new NodeId("127.0.0.1:4053"), ackRouter: ackRouter.Ref),
"driver-host"); "driver-host");
var comm = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null), "node-communication"); var comm = Sys.ActorOf(NodeCommunicationActor.Props(), "node-communication");
// Warm-up: a direct Tell whose ack proves PreStart has completed, and with it the // Warm-up: a direct Tell whose ack proves PreStart has completed, and with it the
// EventStream subscription. Without this the relay below races the subscription. // EventStream subscription. Without this the relay below races the subscription.
@@ -141,7 +141,7 @@ akka {
ScriptedAlarmHostActor.Props( ScriptedAlarmHostActor.Props(
CreateTestProbe().Ref, CreateTestProbe().Ref, upstream, engine, new NodeId("node-A")), CreateTestProbe().Ref, CreateTestProbe().Ref, upstream, engine, new NodeId("node-A")),
"scripted-alarm-host"); "scripted-alarm-host");
var comm = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null), "node-communication"); var comm = Sys.ActorOf(NodeCommunicationActor.Props(), "node-communication");
var cmd = new AlarmCommand("alarm-unowned", "Acknowledge", "alice", "c", null); var cmd = new AlarmCommand("alarm-unowned", "Acknowledge", "alice", "c", null);
@@ -1,5 +1,4 @@
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
@@ -24,7 +23,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
{ {
var subscriber = CreateTestProbe(); var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var dispatch = NewDispatch(); var dispatch = NewDispatch();
actor.Tell(dispatch); actor.Tell(dispatch);
@@ -40,7 +39,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
// subscriber. One publish per inbound command is the whole point. // subscriber. One publish per inbound command is the whole point.
var subscriber = CreateTestProbe(); var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var actor = Sys.ActorOf(NodeCommunicationActor.Props());
actor.Tell(NewDispatch()); actor.Tell(NewDispatch());
@@ -53,7 +52,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
{ {
var subscriber = CreateTestProbe(); var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(RestartDriver)); Sys.EventStream.Subscribe(subscriber.Ref, typeof(RestartDriver));
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var msg = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid()); var msg = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
actor.Tell(msg); actor.Tell(msg);
@@ -66,7 +65,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
{ {
var subscriber = CreateTestProbe(); var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(ReconnectDriver)); Sys.EventStream.Subscribe(subscriber.Ref, typeof(ReconnectDriver));
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var msg = new ReconnectDriver("C1", "driver-1", "alice", Guid.NewGuid()); var msg = new ReconnectDriver("C1", "driver-1", "alice", Guid.NewGuid());
actor.Tell(msg); actor.Tell(msg);
@@ -82,7 +81,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
// leg possible without a registration handshake. // leg possible without a registration handshake.
var subscriber = CreateTestProbe(); var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(AlarmCommand)); Sys.EventStream.Subscribe(subscriber.Ref, typeof(AlarmCommand));
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); var actor = Sys.ActorOf(NodeCommunicationActor.Props());
var msg = new AlarmCommand("alarm-1", "Acknowledge", "alice", "comment", null); var msg = new AlarmCommand("alarm-1", "Acknowledge", "alice", "comment", null);
actor.Tell(msg); actor.Tell(msg);
@@ -91,32 +90,11 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
} }
[Fact] [Fact]
public void ApplyAck_goes_to_central_as_a_Send_not_a_SendToAll() public void An_ack_with_no_ack_sender_is_dropped_with_a_warning()
{ {
// Send, not SendToAll: the deploy coordinator is one singleton behind the central comm // Dps: DriverHostActor publishes the ack on the deployment-acks topic and never routes it
// actor's proxy. The coordinator's NodeId-keyed upsert absorbs a duplicate idempotently, // here, so an ack arriving with no sender wired is a mis-wiring and must say so.
// so the single-target rule is defence in depth and a bound on wasted work, not a var actor = Sys.ActorOf(NodeCommunicationActor.Props());
// correctness requirement.
var client = CreateTestProbe();
var actor = Sys.ActorOf(NodeCommunicationActor.Props(client.Ref));
var ack = new ApplyAck(
new DeploymentId(Guid.NewGuid()),
new NodeId("host-a:4053"),
ApplyAckOutcome.Applied,
null,
new CorrelationId(Guid.NewGuid()));
actor.Tell(ack);
var sent = client.ExpectMsg<ClusterClient.Send>(TimeSpan.FromSeconds(5));
sent.Path.ShouldBe(MeshPaths.CentralCommunication);
sent.Message.ShouldBe(ack);
}
[Fact]
public void An_ack_with_no_central_client_is_dropped_with_a_warning()
{
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null));
var ack = new ApplyAck( var ack = new ApplyAck(
new DeploymentId(Guid.NewGuid()), new DeploymentId(Guid.NewGuid()),
@@ -129,7 +107,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
TimeSpan.FromSeconds(5), () => actor.Tell(ack)); TimeSpan.FromSeconds(5), () => actor.Tell(ack));
} }
// ---- Mode=Grpc (ClusterClient→gRPC migration Task 1B.2) ------------------------------------- // ---- Mode=Grpc (the cross-mesh ack leg) -----------------------------------------------------
private static ApplyAck NewAck() => new( private static ApplyAck NewAck() => new(
new DeploymentId(Guid.NewGuid()), new DeploymentId(Guid.NewGuid()),
@@ -141,13 +119,12 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
[Fact] [Fact]
public void Under_Grpc_the_ack_goes_to_the_sender_exactly_once() public void Under_Grpc_the_ack_goes_to_the_sender_exactly_once()
{ {
// The gRPC counterpart of the ClusterClient.Send fact above, and it carries the same // One post per node per dispatch, because both central endpoints route to the SAME
// single-count obligation: one post per node per dispatch, because both central endpoints // coordinator singleton proxy. Where the ack goes among the endpoints is the sender's
// route to the SAME coordinator singleton proxy. Where the ack goes among the endpoints is the // business (GrpcApplyAckSenderTests); what this pins is that the actor hands it over once
// sender's business (GrpcApplyAckSenderTests); what this pins is that the actor hands it over // and does not also fan it out itself.
// once and does not also fan it out itself.
var sender = new RecordingAckSender(); var sender = new RecordingAckSender();
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null, ackSender: sender)); var actor = Sys.ActorOf(NodeCommunicationActor.Props(sender));
var ack = NewAck(); var ack = NewAck();
actor.Tell(ack); actor.Tell(ack);
@@ -158,37 +135,6 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
sender.Sent.Count.ShouldBe(1); sender.Sent.Count.ShouldBe(1);
} }
[Fact]
public void A_sender_wins_over_a_cluster_client_and_the_ack_is_not_sent_twice()
{
// Defence in depth. The registration arm builds exactly one transport — under Grpc no
// ClusterClient is created at all — and if both were ever present, posting over both would
// deliver two acks for one apply. The coordinator would absorb the duplicate (NodeId-keyed
// upsert), so this pins tidiness and bounded work, not seal correctness.
var client = CreateTestProbe();
var sender = new RecordingAckSender();
var actor = Sys.ActorOf(NodeCommunicationActor.Props(client.Ref, sender));
actor.Tell(NewAck());
AwaitAssert(() => sender.Sent.Count.ShouldBe(1), TimeSpan.FromSeconds(5));
client.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public void The_ClusterClient_branch_never_touches_the_sender()
{
// Null-safe by construction: under Dps and ClusterClient the sender is not built at all, so the
// ApplyAck path must not depend on one existing.
var client = CreateTestProbe();
var actor = Sys.ActorOf(NodeCommunicationActor.Props(client.Ref));
var ack = NewAck();
actor.Tell(ack);
client.ExpectMsg<ClusterClient.Send>(TimeSpan.FromSeconds(5)).Message.ShouldBe(ack);
}
[Fact] [Fact]
public void Inbound_commands_are_unaffected_by_the_presence_of_a_sender() public void Inbound_commands_are_unaffected_by_the_presence_of_a_sender()
{ {
@@ -197,7 +143,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase
var subscriber = CreateTestProbe(); var subscriber = CreateTestProbe();
Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
var sender = new RecordingAckSender(); var sender = new RecordingAckSender();
var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null, ackSender: sender)); var actor = Sys.ActorOf(NodeCommunicationActor.Props(sender));
var dispatch = NewDispatch(); var dispatch = NewDispatch();
actor.Tell(dispatch); actor.Tell(dispatch);
@@ -127,23 +127,22 @@ public sealed class ServiceCollectionExtensionsTests
/// <summary> /// <summary>
/// Under <c>MeshTransport:Mode = Grpc</c> an ApplyAck produced by the driver host reaches the /// Under <c>MeshTransport:Mode = Grpc</c> an ApplyAck produced by the driver host reaches the
/// registered <see cref="IApplyAckSender"/>, and no ClusterClient actor exists /// registered <see cref="IApplyAckSender"/>.
/// (ClusterClient→gRPC migration Task 1B.2).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This is the fact that pins the <c>ackRouter</c> gate in <c>WithOtOpcUaRuntimeActors</c> /// This is the fact that pins the <c>ackRouter</c> gate in <c>WithOtOpcUaRuntimeActors</c>
/// (<c>useClusterClient || useGrpc ? nodeComm : null</c>). Narrowed back to ClusterClient-only /// (<c>useGrpc ? nodeComm : null</c>). Drop the <c>useGrpc</c> arm and that gate still compiles
/// that gate still compiles and every unit test still passes, while every ack on a Grpc node /// and every unit test still passes, while every ack on a Grpc node goes out on the
/// goes out on the <c>deployment-acks</c> DPS topic instead — where, in the split-mesh topology /// <c>deployment-acks</c> DPS topic instead — where, in the split-mesh topology Grpc exists to
/// Grpc exists to serve, central is not subscribed. The whole transport would be dead and /// serve, central is not subscribed. The whole transport would be dead and nothing below this
/// nothing below this level of the stack could tell. /// level of the stack could tell.
/// ///
/// The dispatch names a deployment that does not exist in the in-memory ConfigDb, so the apply /// The dispatch names a deployment that does not exist in the in-memory ConfigDb, so the apply
/// fails and the host emits a Failed ack — deterministic, synchronous, and it exercises the /// fails and the host emits a Failed ack — deterministic, synchronous, and it exercises the
/// real <c>SendAck</c> path rather than poking the router directly. /// real <c>SendAck</c> path rather than poking the router directly.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task Under_Grpc_a_driver_host_ack_reaches_the_ack_sender_and_no_ClusterClient_exists() public async Task Under_Grpc_a_driver_host_ack_reaches_the_ack_sender()
{ {
var sender = new RecordingAckSender(); var sender = new RecordingAckSender();
using var host = BuildRuntimeActorHost(services => using var host = BuildRuntimeActorHost(services =>
@@ -160,7 +159,6 @@ public sealed class ServiceCollectionExtensionsTests
await host.StartAsync(); await host.StartAsync();
try try
{ {
var system = host.Services.GetRequiredService<ActorSystem>();
var driverHost = host.Services.GetRequiredService<IRequiredActor<DriverHostActorKey>>(); var driverHost = host.Services.GetRequiredService<IRequiredActor<DriverHostActorKey>>();
driverHost.ActorRef.Tell(new DispatchDeployment( driverHost.ActorRef.Tell(new DispatchDeployment(
@@ -168,18 +166,14 @@ public sealed class ServiceCollectionExtensionsTests
new RevisionHash("rev-that-does-not-exist"), new RevisionHash("rev-that-does-not-exist"),
new CorrelationId(Guid.NewGuid()))); new CorrelationId(Guid.NewGuid())));
// The ack travelled DriverHostActor -> NodeCommunicationActor -> IApplyAckSender. Under a // The ack travelled DriverHostActor -> NodeCommunicationActor -> IApplyAckSender. With the
// ClusterClient-only gate it would have gone to DPS and this never completes. // useGrpc arm dropped from the ackRouter gate it would have gone to DPS instead and this
// never completes.
var deadline = DateTime.UtcNow.AddSeconds(15); var deadline = DateTime.UtcNow.AddSeconds(15);
while (sender.Sent.Count == 0 && DateTime.UtcNow < deadline) while (sender.Sent.Count == 0 && DateTime.UtcNow < deadline)
await Task.Delay(50); await Task.Delay(50);
sender.Sent.Count.ShouldBe(1); sender.Sent.Count.ShouldBe(1);
// And the Akka cross-mesh association is genuinely gone, not merely unused.
await Should.ThrowAsync<ActorNotFoundException>(() => system
.ActorSelection($"/user/{ServiceCollectionExtensions.CentralClusterClientName}")
.ResolveOne(TimeSpan.FromMilliseconds(500)));
} }
finally finally
{ {