diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs new file mode 100644 index 00000000..fe192b2d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs @@ -0,0 +1,94 @@ +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; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh; +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 . +/// +/// +/// +/// 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. +/// +/// +/// Inbound commands are re-emitted on the node's , not on their +/// DistributedPubSub topic. DPS is mesh-wide, and central SendToAlls 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 +/// actors spawned later: ScriptedAlarmHostActor is a child of +/// DriverHostActor and has no registry key, so there is no ref to hand in at wiring +/// time. +/// +/// +/// Outbound is only. There is no Ask across this boundary in +/// Phase 2 — every migrated command is fire-and-forget, and the deploy ack returns as an +/// independent message the coordinator already subscribes for. Nothing here needs sender +/// preservation, which is why this actor is far smaller than the sister project's. +/// +/// +public sealed class NodeCommunicationActor : ReceiveActor +{ + private readonly IActorRef? _centralClient; + 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. + /// + /// The props. + public static Props Props(IActorRef? centralClient) => + Akka.Actor.Props.Create(() => new NodeCommunicationActor(centralClient)); + + /// Initializes a new instance of the class. + /// ClusterClient dialling central, or . + public NodeCommunicationActor(IActorRef? centralClient) + { + _centralClient = centralClient; + + // 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 + // blind onto the node's EventStream, where it would be silently ignored by every subscriber. + Receive(PublishLocally); + Receive(PublishLocally); + Receive(PublishLocally); + Receive(PublishLocally); + + // Outbound to central. + Receive(HandleApplyAck); + } + + private void PublishLocally(object msg) + { + _log.Debug("Mesh: received {MessageType} from central; publishing node-locally", msg.GetType().Name); + Context.System.EventStream.Publish(msg); + } + + private void HandleApplyAck(ApplyAck ack) + { + if (_centralClient is null) + { + _log.Warning( + "No central ClusterClient — 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", + ack.DeploymentId); + return; + } + + // Send, not SendToAll: the deploy coordinator is a single singleton, and the central comm + // actor forwards to its proxy. SendToAll would deliver one ack per central node, and the + // coordinator would count the same node twice. + _centralClient.Tell(new ClusterClient.Send(MeshPaths.CentralCommunication, ack)); + } +} 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 new file mode 100644 index 00000000..7cc06aca --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Communication/NodeCommunicationActorTests.cs @@ -0,0 +1,130 @@ +using Akka.Actor; +using Akka.Cluster.Tools.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Runtime.Communication; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Communication; + +public class NodeCommunicationActorTests : RuntimeActorTestBase +{ + private static DispatchDeployment NewDispatch() => new( + new DeploymentId(Guid.NewGuid()), + new RevisionHash("rev-1"), + new CorrelationId(Guid.NewGuid())); + + [Fact] + public void DispatchDeployment_from_central_lands_on_the_node_local_event_stream() + { + var subscriber = CreateTestProbe(); + Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment)); + var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); + + var dispatch = NewDispatch(); + actor.Tell(dispatch); + + subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(dispatch); + } + + [Fact] + public void A_command_is_published_exactly_once() + { + // The reason inbound commands do NOT go back onto their DPS topic: central SendToAll-s to + // every node's comm actor, so a mesh-wide republish would deliver N copies to every + // 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)); + + actor.Tell(NewDispatch()); + + subscriber.ExpectMsg(TimeSpan.FromSeconds(5)); + subscriber.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + [Fact] + public void RestartDriver_from_central_lands_on_the_event_stream() + { + var subscriber = CreateTestProbe(); + Sys.EventStream.Subscribe(subscriber.Ref, typeof(RestartDriver)); + var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); + + var msg = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid()); + actor.Tell(msg); + + subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg); + } + + [Fact] + public void ReconnectDriver_from_central_lands_on_the_event_stream() + { + var subscriber = CreateTestProbe(); + Sys.EventStream.Subscribe(subscriber.Ref, typeof(ReconnectDriver)); + var actor = Sys.ActorOf(NodeCommunicationActor.Props(centralClient: null)); + + var msg = new ReconnectDriver("C1", "driver-1", "alice", Guid.NewGuid()); + actor.Tell(msg); + + subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg); + } + + [Fact] + public void AlarmCommand_from_central_lands_on_the_event_stream() + { + // ScriptedAlarmHostActor is a CHILD of DriverHostActor with no registry key, so there is no + // ref to hand this actor at wiring time — the EventStream is what makes the alarm-command + // 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 msg = new AlarmCommand("alarm-1", "Acknowledge", "alice", "comment", null); + actor.Tell(msg); + + subscriber.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(msg); + } + + [Fact] + public void ApplyAck_goes_to_central_as_a_Send_not_a_SendToAll() + { + // Send, not SendToAll: the deploy coordinator is one singleton behind the central comm + // actor's proxy. SendToAll would deliver one ack per central node and the coordinator would + // count this node twice. + 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)); + + var ack = new ApplyAck( + new DeploymentId(Guid.NewGuid()), + new NodeId("host-a:4053"), + ApplyAckOutcome.Applied, + null, + new CorrelationId(Guid.NewGuid())); + + EventFilter.Warning(contains: "dropping ApplyAck").ExpectOne( + TimeSpan.FromSeconds(5), () => actor.Tell(ack)); + } +}