using System.Net; using System.Net.Sockets; using Akka.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// Review finding on R3-04 (Important, 2026-08-20): _roleLeaders must stay /// , NOT like /// _membersByRole. Akka raises a SEPARATE RoleLeaderChanged event per exact gossiped /// role string — "driver" and "Driver" are two entirely distinct roles to Akka's own role-leader /// computation, each with its own (possibly different) leader member. An ignore-case dictionary /// would collapse both onto ONE key, and whichever role-leader-changed event landed LAST would /// silently overwrite the other — RoleLeader("driver") could then return the "Driver" /// role's leader instead of (or in addition to losing) the "driver" role's own. /// /// /// _membersByRole stays deliberately — it /// answers "give me every member carrying (a spelling of) this role", where merging casings is /// the whole point (archreview 01/C-9, rolling-upgrade peer defence in depth). _roleLeaders /// answers a per-EXACT-role-string question Akka itself keeps separate, so merging casings there /// loses information instead. /// public sealed class ClusterRoleInfoRoleLeaderCaseTests { private static int FreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private const string SystemName = "clusterroleinfo-leader-case-test"; private static async Task<(IHost Host, IClusterRoleInfo RoleInfo)> StartNodeAsync( int selfPort, int? peerPort, string role) { var self = $"akka.tcp://{SystemName}@127.0.0.1:{selfPort}"; var seeds = peerPort is null ? new[] { self } : new[] { self, $"akka.tcp://{SystemName}@127.0.0.1:{peerPort}" }; var options = new AkkaClusterOptions { SystemName = SystemName, Hostname = "127.0.0.1", PublicHostname = "127.0.0.1", Port = selfPort, // Deliberately ONE role each, spelled differently between the two nodes — that mismatch // IS the scenario under test, not an oversight. Roles = new[] { role }, SeedNodes = seeds, }; var builder = Host.CreateDefaultBuilder(); builder.ConfigureServices(services => { services.AddSingleton>(Options.Create(options)); services.AddAkka(SystemName, (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp)); services.AddSingleton(); }); var host = builder.Build(); await host.StartAsync(); return (host, host.Services.GetRequiredService()); } /// /// Two single-member "roles" — "driver" on node A, "Driver" on node B, both Up in the SAME /// cluster. Each is the sole (and therefore leader) member of its own exact-string role. If /// _roleLeaders were case-insensitive, the two RoleLeaderChanged events would collide /// onto one dictionary slot and one of the two role-leader facts would be lost or wrong. /// [Fact] public async Task RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries() { var portA = FreePort(); var portB = FreePort(); var (hostA, roleInfoA) = await StartNodeAsync(portA, peerPort: null, role: "driver"); try { var (hostB, _) = await StartNodeAsync(portB, peerPort: portA, role: "Driver"); try { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); NodeId? driverLeader = null; NodeId? capitalDriverLeader = null; while (DateTime.UtcNow < deadline) { driverLeader = roleInfoA.RoleLeader("driver"); capitalDriverLeader = roleInfoA.RoleLeader("Driver"); if (driverLeader is not null && capitalDriverLeader is not null) break; await Task.Delay(200, TestContext.Current.CancellationToken); } driverLeader.ShouldNotBeNull("node A's own 'driver' role must elect itself leader"); capitalDriverLeader.ShouldNotBeNull( "node B's 'Driver' role must ALSO be tracked — a case-insensitive dictionary " + "would either lose this entry or clobber the 'driver' one with it"); driverLeader!.Value.ShouldNotBe( capitalDriverLeader!.Value, "the two roles are DISTINCT to Akka and must resolve to their own (different) " + "member — a merged dictionary slot would make these equal"); driverLeader.Value.ToString().ShouldContain(portA.ToString()); capitalDriverLeader.Value.ToString().ShouldContain(portB.ToString()); } finally { await hostB.StopAsync(TestContext.Current.CancellationToken); hostB.Dispose(); } } finally { await hostA.StopAsync(TestContext.Current.CancellationToken); hostA.Dispose(); } } /// /// Deterministic pin for Gitea #590: a RoleLeaderChanged event whose leader address is not (yet) /// in Cluster.State.Members must still record that leader. The handler used to resolve the /// address through the state snapshot, which is published asynchronously and can lag the event; /// a miss stored null permanently. Here the cluster never joins anything, so the leader address /// is guaranteed absent from the snapshot — the lagging-snapshot case, made exact. /// [Fact] public async Task RoleLeaderChanged_is_recorded_even_when_the_leader_is_not_in_the_state_snapshot() { var port = FreePort(); var options = new AkkaClusterOptions { SystemName = SystemName, Hostname = "127.0.0.1", PublicHostname = "127.0.0.1", Port = port, Roles = new[] { "driver" }, SeedNodes = Array.Empty(), }; var system = Akka.Actor.ActorSystem.Create(SystemName, Akka.Configuration.ConfigurationFactory.ParseString( "akka.actor.provider = cluster\nakka.remote.dot-netty.tcp.hostname = 127.0.0.1\n" + $"akka.remote.dot-netty.tcp.port = {port}")); try { using var info = new ClusterRoleInfo( system, Options.Create(options), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var driver = new Akka.Actor.Address("akka.tcp", SystemName, "127.0.0.1", 11111); var capital = new Akka.Actor.Address("akka.tcp", SystemName, "127.0.0.1", 22222); info.HandleRoleLeaderChanged(new Akka.Cluster.ClusterEvent.RoleLeaderChanged("driver", driver)); info.HandleRoleLeaderChanged(new Akka.Cluster.ClusterEvent.RoleLeaderChanged("Driver", capital)); info.RoleLeader("driver")!.Value.ToString().ShouldContain("11111"); info.RoleLeader("Driver")!.Value.ToString().ShouldContain("22222"); } finally { await system.Terminate(); } } }