diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshCommand.cs new file mode 100644 index 00000000..a12ab795 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshCommand.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh; + +/// +/// A central→node command, plus the DistributedPubSub topic it would have been published on. +/// +/// +/// The legacy DPS topic. Carried so the router can publish under +/// MeshTransport:Mode = Dps without a second dispatch table; ignored entirely under +/// ClusterClient mode, where the destination is an actor path rather than a topic. +/// +/// +/// The command itself, never wrapped. Node-side handlers see exactly the type they see today, +/// which is what lets the dark switch touch publishers only. +/// +/// +/// +/// This envelope exists so the admin singletons stop knowing which transport is in force. +/// Before per-cluster mesh Phase 2 each publisher held its own +/// DistributedPubSub.Get(Context.System).Mediator call, so the switch would have had +/// to be threaded through five call sites across two actors, and each would have been free +/// to drift. +/// +/// +/// Central-side only — it never crosses the wire, so it carries no serialization contract +/// and needs no additive-evolution discipline. +/// +/// +public sealed record MeshCommand(string Topic, object Message); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshClusterClientFactory.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshClusterClientFactory.cs new file mode 100644 index 00000000..5ea57434 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshClusterClientFactory.cs @@ -0,0 +1,47 @@ +using System.Collections.Immutable; +using Akka.Actor; +using Akka.Cluster.Tools.Client; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Communication; + +/// +/// Creates the ClusterClient central uses to reach driver nodes. Exists as a seam so +/// CentralCommunicationActor can be tested against a TestProbe without standing up +/// a second cluster. +/// +public interface IMeshClusterClientFactory +{ + /// Creates a client for the given receptionist contact points. + /// The actor system to spawn the client in. + /// Receptionist actor paths, one per reachable node. + /// The new client's . + IActorRef Create(ActorSystem system, ImmutableHashSet contacts); +} + +/// +public sealed class DefaultMeshClusterClientFactory : IMeshClusterClientFactory +{ + /// + /// Per-incarnation generation counter, appended to the actor name. + /// + /// + /// Context.Stop of the previous client is asynchronous — its actor name stays + /// reserved until termination completes — so recreating the same name while handling a single + /// message throws . That is exactly what a contact-set + /// change does: stop the old client, create the replacement, both in one handler. A + /// generation suffix makes every incarnation's name unique by construction. Ported from the + /// sister project, where it was a shipped bug fix rather than foresight. + /// + private long _generation; + + /// + public IActorRef Create(ActorSystem system, ImmutableHashSet contacts) + { + ArgumentNullException.ThrowIfNull(system); + ArgumentNullException.ThrowIfNull(contacts); + + var settings = ClusterClientSettings.Create(system).WithInitialContacts(contacts); + var name = $"mesh-client-{Interlocked.Increment(ref _generation)}"; + return system.ActorOf(ClusterClient.Props(settings), name); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/MeshClusterClientFactoryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/MeshClusterClientFactoryTests.cs new file mode 100644 index 00000000..911142b7 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/MeshClusterClientFactoryTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; +using Akka.Actor; +using Akka.Cluster.Tools.Client; +using Akka.Configuration; +using Akka.TestKit.Xunit2; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication; + +/// +/// Guards the one non-obvious part of client creation: recreating a client with the same name +/// inside a single message handler throws, because Context.Stop is asynchronous and the +/// name stays reserved until termination completes. +/// +public class MeshClusterClientFactoryTests : TestKit +{ + private static readonly Config TestConfig = ConfigurationFactory.ParseString(@" + akka.actor.provider = cluster + akka.remote.dot-netty.tcp.port = 0 + akka.remote.dot-netty.tcp.hostname = 127.0.0.1") + .WithFallback(ClusterClientReceptionist.DefaultConfig()); + + public MeshClusterClientFactoryTests() + : base(TestConfig) + { + } + + // A real ClusterClient pointed at an unreachable contact is safe in TestKit — it simply retries + // the initial-contact rotation and never fails the test. + private static ImmutableHashSet UnreachableContact => ImmutableHashSet.Create( + ActorPath.Parse("akka.tcp://otopcua@127.0.0.1:65501/system/receptionist")); + + [Fact] + public void Recreating_a_client_in_one_pass_does_not_collide_on_the_actor_name() + { + var factory = new DefaultMeshClusterClientFactory(); + + var first = factory.Create(Sys, UnreachableContact); + Sys.Stop(first); + + // Stop is asynchronous: `first`'s name is still reserved right here. Without the generation + // suffix this throws InvalidActorNameException — which is precisely what a contact-set + // change would do in production, where stop-then-recreate happens in one handler. + var second = factory.Create(Sys, UnreachableContact); + + second.Path.Name.ShouldNotBe(first.Path.Name); + } + + [Fact] + public void Every_incarnation_gets_a_distinct_name() + { + var factory = new DefaultMeshClusterClientFactory(); + + var names = Enumerable.Range(0, 5) + .Select(_ => factory.Create(Sys, UnreachableContact).Path.Name) + .ToList(); + + names.Distinct().Count().ShouldBe(5); + } + + // Contact-set propagation is deliberately NOT asserted here: reading initial contacts back + // off a created ClusterClient is not possible, and asserting ClusterClientSettings directly + // would test Akka rather than this factory. It is covered where it can actually fail — the + // real two-ActorSystem boundary test, which only delivers if the contacts reached the client. +}