ab5b958d47
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.
178 lines
6.8 KiB
C#
178 lines
6.8 KiB
C#
using Akka.Actor;
|
|
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());
|
|
|
|
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());
|
|
|
|
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());
|
|
|
|
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());
|
|
|
|
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());
|
|
|
|
var msg = new AlarmCommand("alarm-1", "Acknowledge", "alice", "comment", null);
|
|
actor.Tell(msg);
|
|
|
|
subscriber.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(5)).ShouldBe(msg);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_ack_with_no_ack_sender_is_dropped_with_a_warning()
|
|
{
|
|
// 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()),
|
|
new NodeId("host-a:4053"),
|
|
ApplyAckOutcome.Applied,
|
|
null,
|
|
new CorrelationId(Guid.NewGuid()));
|
|
|
|
EventFilter.Warning(contains: "dropping ApplyAck").ExpectOne(
|
|
TimeSpan.FromSeconds(5), () => actor.Tell(ack));
|
|
}
|
|
|
|
// ---- Mode=Grpc (the cross-mesh ack leg) -----------------------------------------------------
|
|
|
|
private static ApplyAck NewAck() => new(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("host-a:4053"),
|
|
ApplyAckOutcome.Applied,
|
|
null,
|
|
new CorrelationId(Guid.NewGuid()));
|
|
|
|
[Fact]
|
|
public void Under_Grpc_the_ack_goes_to_the_sender_exactly_once()
|
|
{
|
|
// 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(sender));
|
|
|
|
var ack = NewAck();
|
|
actor.Tell(ack);
|
|
|
|
AwaitAssert(() => sender.Sent.Count.ShouldBe(1), TimeSpan.FromSeconds(5));
|
|
sender.Sent[0].ShouldBe(ack);
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
sender.Sent.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Inbound_commands_are_unaffected_by_the_presence_of_a_sender()
|
|
{
|
|
// The sender is outbound-only. A Grpc node still re-emits central's commands on its own
|
|
// EventStream exactly as every other mode does.
|
|
var subscriber = CreateTestProbe();
|
|
Sys.EventStream.Subscribe(subscriber.Ref, typeof(DispatchDeployment));
|
|
var sender = new RecordingAckSender();
|
|
var actor = Sys.ActorOf(NodeCommunicationActor.Props(sender));
|
|
|
|
var dispatch = NewDispatch();
|
|
actor.Tell(dispatch);
|
|
|
|
subscriber.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(5)).ShouldBe(dispatch);
|
|
sender.Sent.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>An <see cref="IApplyAckSender"/> double recording what the actor handed it.</summary>
|
|
private sealed class RecordingAckSender : IApplyAckSender
|
|
{
|
|
private readonly Lock _gate = new();
|
|
private readonly List<ApplyAck> _sent = [];
|
|
|
|
/// <summary>Gets a snapshot of the acks handed to this sender.</summary>
|
|
public IReadOnlyList<ApplyAck> Sent
|
|
{
|
|
get { lock (_gate) return [.. _sent]; }
|
|
}
|
|
|
|
/// <summary>Records <paramref name="ack"/>.</summary>
|
|
/// <param name="ack">The ack the actor handed over.</param>
|
|
/// <param name="cancellationToken">Unused.</param>
|
|
/// <returns>A completed task — the real sender never throws either.</returns>
|
|
public Task SendAsync(ApplyAck ack, CancellationToken cancellationToken = default)
|
|
{
|
|
lock (_gate) _sent.Add(ack);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|