feat(mesh): register comm actors with the receptionist; pin the wire-contract paths
Phase 2 Task 6. Both comm actors are now spawned and receptionist-registered: CentralCommunicationActor from WithOtOpcUaControlPlaneSingletons, and NodeCommunicationActor from WithOtOpcUaRuntimeActors (before DriverHostActor, because it is the host's ackRouter under ClusterClient mode; it has no dependency of its own on the host, since inbound commands travel via the node's EventStream). Both are plain WithActors registrations, NOT singletons -- a driver node's ClusterClient rotates across contact points and must find a live comm actor at whichever node answers. Both register with the receptionist in BOTH transport modes: an idle registration costs nothing, whereas registering only under ClusterClient would mean flipping the flag on a running fleet needs a restart before anything can be reached, turning a config change back into a deployment. The coordinator ref is resolved lazily per message (Func<IActorRef?> + registry.TryGet) so the comm actor never depends on the order in which Akka.Hosting materialises the singleton proxy relative to this block. Adds MeshCommActorPathTests, which boots the real two-node host and Identify- probes both paths. These strings are the wire contract and nothing else asserts they agree: a rename would compile, pass every unit test, deploy, and deliver nothing, because a ClusterClient send to an unregistered path is dropped silently. Sabotage-verified with an actual rename. DELIBERATELY NOT TESTED, with the reason recorded in the file: that the actors are per-node rather than singletons. Two discriminators were tried and both were invalid -- "the path resolves on both nodes" passes under either shape because a ClusterSingletonManager is also created on every node in the role, and "no /singleton child" is null even for the KNOWN singleton /user/config-publish, so Akka.Hosting does not lay them out that way. A sabotage re-registering the actor via WithSingleton left both green, which is what exposed them. Rather than ship an assertion that cannot fail, the property is left to the Task 8 boundary test, where a send only arrives if the target really is registered. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the two mesh comm-actor paths against a <b>really booted</b> host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>/user/central-communication</c> and <c>/user/node-communication</c> are the wire
|
||||
/// contract of the central↔node boundary: central sends to a path string, and the node
|
||||
/// registers under one. Nothing else in the codebase asserts they agree. Renaming either
|
||||
/// actor would compile, pass every unit test, deploy — and deliver nothing, with no error
|
||||
/// on either side, because a ClusterClient send to an unregistered path is simply dropped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Booting the real host is the point. A test that spawned the actors itself would pin the
|
||||
/// constant against itself and prove nothing about the wiring in
|
||||
/// <c>WithOtOpcUaControlPlaneSingletons</c> / <c>WithOtOpcUaRuntimeActors</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class MeshCommActorPathTests
|
||||
{
|
||||
private static async Task AssertActorExistsAsync(ActorSystem system, string path)
|
||||
{
|
||||
var identity = await system.ActorSelection(path)
|
||||
.Ask<ActorIdentity>(new Identify(path), TimeSpan.FromSeconds(10));
|
||||
|
||||
identity.Subject.ShouldNotBeNull(
|
||||
$"{path} is the ClusterClient wire contract for the central↔node boundary; nothing else "
|
||||
+ "pins it, and a mismatch delivers nothing without erroring on either side");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_mesh_comm_actors_exist_at_their_contract_paths()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
|
||||
// Harness nodes carry admin+driver, so BOTH comm actors live on each of them.
|
||||
await AssertActorExistsAsync(harness.NodeASystem, MeshPaths.CentralCommunication);
|
||||
await AssertActorExistsAsync(harness.NodeASystem, MeshPaths.NodeCommunication);
|
||||
await AssertActorExistsAsync(harness.NodeBSystem, MeshPaths.CentralCommunication);
|
||||
await AssertActorExistsAsync(harness.NodeBSystem, MeshPaths.NodeCommunication);
|
||||
}
|
||||
|
||||
// NOT TESTED HERE: that the comm actors are registered per node rather than as cluster
|
||||
// singletons. Two candidate discriminators were tried and BOTH were invalid:
|
||||
//
|
||||
// * "the path resolves on both nodes" — a ClusterSingletonManager is also created on every
|
||||
// node in the role, so this passes under either shape.
|
||||
// * "no /singleton child exists" — measured against the KNOWN singleton /user/config-publish,
|
||||
// that child is null there too, so Akka.Hosting does not lay singletons out this way and
|
||||
// the assertion proves nothing.
|
||||
//
|
||||
// A sabotage that re-registered CentralCommunicationActor via WithSingleton left both green,
|
||||
// which is what exposed them. Rather than ship an assertion I could not make fail, the property
|
||||
// is left to MeshClusterClientBoundaryTests: a ClusterClient send only arrives if the target is
|
||||
// actually registered with the receptionist, which is the thing that matters.
|
||||
}
|
||||
Reference in New Issue
Block a user