6531ec1984
After the fleet splits into one Akka mesh per application Cluster, a ClusterClientReceptionist serves only its own mesh — so the single fleet-wide client's SendToAll reached only the mesh whose receptionist answered, leaving every other cluster silently on its old configuration. Central now holds one ClusterClient per ClusterId and fans SendToAll across all of them. LoadContactsFromDb selects ClusterId and groups the receptionist contacts per cluster (keeping the per-row TryParse guard and the enabled/non-maintenance filter); ContactsLoaded carries ContactsByCluster; HandleContactsLoaded diffs per cluster (rebuild changed, stop+drop vanished, warn-don't-create on empty); RebuildClient builds a per-cluster client under a clusterId-derived actor name. IMeshClusterClientFactory.Create gained a clusterId parameter so the per-cluster clients get distinct, diagnosable names. ApplyAck handling and the DPS branch are unchanged; the deploy path stays payload-free. Tests: focused unit coverage of the grouping + fan-out (one-client-per-cluster, fan-out SendToAll to every cluster client) via the recording factory double, plus the real two-mesh boundary test extended to central + two separate site meshes proving one dispatch reaches both and a client scoped to one cluster never crosses into another. Red-before-green verified: crippling the fan-out to a single client fails the reaches-both-meshes assertion. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
83 lines
3.3 KiB
C#
83 lines
3.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Guards the one non-obvious part of client creation: recreating a client with the same name
|
|
/// inside a single message handler throws, because <c>Context.Stop</c> is asynchronous and the
|
|
/// name stays reserved until termination completes.
|
|
/// </summary>
|
|
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<ActorPath> 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, "C1", 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, "C1", 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, "C1", UnreachableContact).Path.Name)
|
|
.ToList();
|
|
|
|
names.Distinct().Count().ShouldBe(5);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_cluster_id_is_woven_into_the_client_actor_name()
|
|
{
|
|
// Per-cluster clients must be distinguishable in logs and the actor tree, and — more
|
|
// importantly — two clusters' clients created on one system must never collide on a name.
|
|
var factory = new DefaultMeshClusterClientFactory();
|
|
|
|
var a = factory.Create(Sys, "site-a", UnreachableContact);
|
|
var b = factory.Create(Sys, "site-b", UnreachableContact);
|
|
|
|
a.Path.Name.ShouldContain("site-a");
|
|
b.Path.Name.ShouldContain("site-b");
|
|
a.Path.Name.ShouldNotBe(b.Path.Name);
|
|
}
|
|
|
|
// 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.
|
|
}
|