915beec11a
Phase 2 Task 4. Node-side end of the boundary, registered with the receptionist per node rather than as a singleton so central's contact rotation reaches whichever node answers. Inbound commands are re-emitted on the node's EventStream, NOT back onto their DistributedPubSub topic. DPS is mesh-wide and central SendToAll-s to every node's comm actor, so a DPS republish would deliver N copies of every command to every subscriber. The EventStream is node-local by construction. 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. Each inbound type is listed explicitly rather than caught by a ReceiveAny, so an unknown type dead-letters loudly instead of being republished blind onto a stream where every subscriber ignores it. Outbound is ApplyAck only, and it uses Send rather than SendToAll -- the deploy coordinator is one singleton behind central's proxy, so SendToAll would deliver one ack per central node and the coordinator would count this node twice. Notably there is NO Ask across this boundary in Phase 2: every migrated command is fire-and-forget, which is why this actor is far smaller than the sister project's equivalent and needs no sender preservation at all. Sabotage-verified twice: deleting the AlarmCommand handler and flipping Send to SendToAll each redden exactly their own test. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
131 lines
4.9 KiB
C#
131 lines
4.9 KiB
C#
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<DispatchDeployment>(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<DispatchDeployment>(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<RestartDriver>(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<ReconnectDriver>(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<AlarmCommand>(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<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(
|
|
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));
|
|
}
|
|
}
|