From ab5b958d47fe340b1d1007cccc5525cdea6f0d00 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 6 Aug 2026 15:38:49 -0400 Subject: [PATCH] =?UTF-8?q?refactor(mesh):=20step=202=20=E2=80=94=20delete?= =?UTF-8?q?=20the=20node-side=20ClusterClient=20and=20its=20centralClient?= =?UTF-8?q?=20parameter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Communication/NodeCommunicationActor.cs | 66 +++++--------- .../ServiceCollectionExtensions.cs | 48 +++-------- .../EventStreamCommandDeliveryTests.cs | 4 +- .../NodeCommunicationActorTests.cs | 86 ++++--------------- .../ServiceCollectionExtensionsTests.cs | 26 +++--- 5 files changed, 63 insertions(+), 167 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs index 88702dc8..e54da301 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs @@ -1,5 +1,4 @@ using Akka.Actor; -using Akka.Cluster.Tools.Client; using Akka.Event; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; 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; /// -/// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2), registered -/// with the ClusterClientReceptionist at . +/// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2), living at +/// — the path this node's NodeCommandService +/// gRPC ingress hands every decoded command to. /// /// /// -/// Registered per node, NOT as a cluster singleton — central's ClusterClient rotates -/// across contact points and must find a live comm actor at whichever node answers. +/// Registered per node, NOT as a cluster singleton — central dials each node's own +/// command endpoint, so every node needs its own live comm actor. /// /// /// Inbound commands are re-emitted on the node's , not on their -/// DistributedPubSub topic. DPS is mesh-wide, and central SendToAlls to every +/// DistributedPubSub topic. 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 /// 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 @@ -36,31 +36,23 @@ 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(); /// Creates the props for this actor. - /// - /// ClusterClient dialling central's receptionist, or under - /// MeshTransport:Mode = Dps, where acks still go out over DistributedPubSub from - /// DriverHostActor and this actor never sees them — and under - /// Mode = Grpc, where no ClusterClient is created at all. - /// /// /// The gRPC ack transport under MeshTransport:Mode = Grpc, or - /// under Dps and ClusterClient. + /// under Dps, where acks still go out over DistributedPubSub from + /// DriverHostActor and this actor never sees them. /// /// The props. - public static Props Props(IActorRef? centralClient, IApplyAckSender? ackSender = null) => - Akka.Actor.Props.Create(() => new NodeCommunicationActor(centralClient, ackSender)); + public static Props Props(IApplyAckSender? ackSender = null) => + Akka.Actor.Props.Create(() => new NodeCommunicationActor(ackSender)); /// Initializes a new instance of the class. - /// ClusterClient dialling central, or . /// The gRPC ack transport, or . - public NodeCommunicationActor(IActorRef? centralClient, IApplyAckSender? ackSender = null) + public NodeCommunicationActor(IApplyAckSender? ackSender = null) { - _centralClient = centralClient; _ackSender = ackSender; // 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) { - // 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 duplicate central's work for no benefit. + // Fire-and-forget by construction. 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. try { _ = _ackSender.SendAsync(ack); @@ -115,22 +103,12 @@ public sealed class NodeCommunicationActor : ReceiveActor return; } - if (_centralClient is null) - { - _log.Warning( - "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 (ClusterClient mode) or " - + "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)); + _log.Warning( + "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:CentralGrpcEndpoints, and that MeshTransport:Mode is Grpc on a node " + + "whose central lives in another mesh", + ack.DeploymentId); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index dd7c05ff..01b1a23b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -47,7 +47,6 @@ public static class ServiceCollectionExtensions public const string OpcUaPublishActorName = "opcua-publish"; public const string PeerProbeSupervisorName = "peer-probe-supervisor"; public const string ContinuousHistorizationRecorderActorName = "continuous-historization-recorder"; - public const string CentralClusterClientName = "central-cluster-client"; /// /// Registers shared runtime services. Currently binds @@ -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. services.TryAddSingleton(sp => sp.GetRequiredService()); - // ClusterClient→gRPC migration Task 1B.2: the node→central ack transport under - // MeshTransport:Mode = Grpc. Registered unconditionally but resolved only by the Grpc arm of + // The node→central ack transport under MeshTransport:Mode = Grpc (mesh gRPC command plane, + // 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 // read into options), and a singleton nobody resolves is never constructed, so a Dps node // opens no channel. @@ -404,35 +403,15 @@ public static class ServiceCollectionExtensions registry.Register(mux); // 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. - // It has no dependency of its own on the host — inbound commands travel via the node's + // BEFORE DriverHostActor because it is the host's ackRouter under Grpc mode. It has no + // dependency of its own on the host — inbound commands travel via the node's // EventStream — so this ordering costs nothing. var meshOptions = resolver.GetService>().Value; - var useClusterClient = string.Equals( - meshOptions.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase); var useGrpc = string.Equals( meshOptions.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase); - IActorRef? centralClient = null; - if (useClusterClient) - { - // 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. + // The node→central ack transport. Built ONLY under Grpc: under Dps the ack goes out on + // the deployment-acks topic from DriverHostActor and never reaches this actor at all. IApplyAckSender? ackSender = null; if (useGrpc) { @@ -451,7 +430,7 @@ public static class ServiceCollectionExtensions } var nodeComm = system.ActorOf( - NodeCommunicationActor.Props(centralClient, ackSender), + NodeCommunicationActor.Props(ackSender), MeshPaths.NodeCommunicationName); // Registered with the receptionist in ALL modes: under Dps and Grpc nothing dials it, and @@ -567,12 +546,11 @@ public static class ServiceCollectionExtensions : null; var driverHost = system.ActorOf( - // ackRouter: under ClusterClient AND Grpc the node comm actor relays ApplyAcks across - // the boundary (which transport it then uses is its own business — the ClusterClient - // ref or the gRPC sender it was built with); under Dps it stays null and the host - // publishes on the deployment-acks topic exactly as before. Grpc MUST be included - // here: routed to DPS instead, a Grpc node's ack would never reach the sender at all - // and every deployment to it would time out while it applied perfectly. + // ackRouter: under Grpc the node comm actor relays ApplyAcks across the mesh + // boundary through the gRPC sender it was built with; under Dps it stays null and + // the host publishes on the deployment-acks topic exactly as before. Routed to DPS + // instead, a Grpc node's ack would never reach the sender at all 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 // gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor // 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 // host when no store was wired). DriverHostActor.Props(dbFactory, roleInfo.LocalNode, - ackRouter: useClusterClient || useGrpc ? nodeComm : null, + ackRouter: useGrpc ? nodeComm : null, driverFactory: driverFactory, localRoles: roleInfo.LocalRoles, dependencyMux: mux, opcUaPublishActor: publishActor, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/EventStreamCommandDeliveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/EventStreamCommandDeliveryTests.cs index c9c66be1..aaa06b3c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/EventStreamCommandDeliveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/EventStreamCommandDeliveryTests.cs @@ -50,7 +50,7 @@ public class EventStreamCommandDeliveryTests : RuntimeActorTestBase var driverHost = Sys.ActorOf( DriverHostActor.Props(NewInMemoryDbFactory(), new NodeId("127.0.0.1:4053"), ackRouter: ackRouter.Ref), "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 // EventStream subscription. Without this the relay below races the subscription. @@ -141,7 +141,7 @@ akka { ScriptedAlarmHostActor.Props( CreateTestProbe().Ref, CreateTestProbe().Ref, upstream, engine, new NodeId("node-A")), "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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs index 473d2d02..e1bb1f9d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs @@ -1,5 +1,4 @@ using Akka.Actor; -using Akka.Cluster.Tools.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; @@ -24,7 +23,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase { var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); - var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); + var actor = Sys.ActorOf(NodeCommunicationActor.Props()); var dispatch = NewDispatch(); actor.Tell(dispatch); @@ -40,7 +39,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase // subscriber. One publish per inbound command is the whole point. var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); - var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); + var actor = Sys.ActorOf(NodeCommunicationActor.Props()); actor.Tell(NewDispatch()); @@ -53,7 +52,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase { var subscriber = CreateTestProbe(); 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()); actor.Tell(msg); @@ -66,7 +65,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase { var subscriber = CreateTestProbe(); 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()); actor.Tell(msg); @@ -82,7 +81,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase // leg possible without a registration handshake. var subscriber = CreateTestProbe(); 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); actor.Tell(msg); @@ -91,32 +90,11 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase } [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 - // actor's proxy. The coordinator's NodeId-keyed upsert absorbs a duplicate idempotently, - // so the single-target rule is defence in depth and a bound on wasted work, not a - // 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(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)); + // Dps: DriverHostActor publishes the ack on the deployment-acks topic and never routes it + // here, so an ack arriving with no sender wired is a mis-wiring and must say so. + var actor = Sys.ActorOf(NodeCommunicationActor.Props()); var ack = new ApplyAck( new DeploymentId(Guid.NewGuid()), @@ -129,7 +107,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase 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( new DeploymentId(Guid.NewGuid()), @@ -141,13 +119,12 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase [Fact] 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 - // single-count obligation: one post per node per dispatch, because both central endpoints - // route to the SAME coordinator singleton proxy. Where the ack goes among the endpoints is the - // sender's business (GrpcApplyAckSenderTests); what this pins is that the actor hands it over - // once and does not also fan it out itself. + // One post per node per dispatch, because both central endpoints route to the SAME + // coordinator singleton proxy. Where the ack goes among the endpoints is the sender's + // business (GrpcApplyAckSenderTests); what this pins is that the actor hands it over once + // and does not also fan it out itself. var sender = new RecordingAckSender(); - var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null, ackSender: sender)); + var actor = Sys.ActorOf(NodeCommunicationActor.Props(sender)); var ack = NewAck(); actor.Tell(ack); @@ -158,37 +135,6 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase 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(TimeSpan.FromSeconds(5)).Message.ShouldBe(ack); - } - [Fact] public void Inbound_commands_are_unaffected_by_the_presence_of_a_sender() { @@ -197,7 +143,7 @@ public class NodeCommunicationActorTests : RuntimeActorTestBase var subscriber = CreateTestProbe(); Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); var sender = new RecordingAckSender(); - var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null, ackSender: sender)); + var actor = Sys.ActorOf(NodeCommunicationActor.Props(sender)); var dispatch = NewDispatch(); actor.Tell(dispatch); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs index f649d30d..0b8965c7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs @@ -127,23 +127,22 @@ public sealed class ServiceCollectionExtensionsTests /// /// Under MeshTransport:Mode = Grpc an ApplyAck produced by the driver host reaches the - /// registered , and no ClusterClient actor exists - /// (ClusterClient→gRPC migration Task 1B.2). + /// registered . /// /// /// This is the fact that pins the ackRouter gate in WithOtOpcUaRuntimeActors - /// (useClusterClient || useGrpc ? nodeComm : null). Narrowed back to ClusterClient-only - /// that gate still compiles and every unit test still passes, while every ack on a Grpc node - /// goes out on the deployment-acks DPS topic instead — where, in the split-mesh topology - /// Grpc exists to serve, central is not subscribed. The whole transport would be dead and - /// nothing below this level of the stack could tell. + /// (useGrpc ? nodeComm : null). Drop the useGrpc arm and that gate still compiles + /// and every unit test still passes, while every ack on a Grpc node goes out on the + /// deployment-acks DPS topic instead — where, in the split-mesh topology Grpc exists to + /// serve, central is not subscribed. The whole transport would be dead and nothing below this + /// level of the stack could tell. /// /// 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 /// real SendAck path rather than poking the router directly. /// [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(); using var host = BuildRuntimeActorHost(services => @@ -160,7 +159,6 @@ public sealed class ServiceCollectionExtensionsTests await host.StartAsync(); try { - var system = host.Services.GetRequiredService(); var driverHost = host.Services.GetRequiredService>(); driverHost.ActorRef.Tell(new DispatchDeployment( @@ -168,18 +166,14 @@ public sealed class ServiceCollectionExtensionsTests new RevisionHash("rev-that-does-not-exist"), new CorrelationId(Guid.NewGuid()))); - // The ack travelled DriverHostActor -> NodeCommunicationActor -> IApplyAckSender. Under a - // ClusterClient-only gate it would have gone to DPS and this never completes. + // The ack travelled DriverHostActor -> NodeCommunicationActor -> IApplyAckSender. With the + // useGrpc arm dropped from the ackRouter gate it would have gone to DPS instead and this + // never completes. var deadline = DateTime.UtcNow.AddSeconds(15); while (sender.Sent.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(50); sender.Sent.Count.ShouldBe(1); - - // And the Akka cross-mesh association is genuinely gone, not merely unused. - await Should.ThrowAsync(() => system - .ActorSelection($"/user/{ServiceCollectionExtensions.CentralClusterClientName}") - .ResolveOne(TimeSpan.FromMilliseconds(500))); } finally {