using System.Diagnostics.CodeAnalysis; using Akka.Actor; using Akka.Cluster.Tools.PublishSubscribe; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster; 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.Protos.MeshTransport.V1; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication; public class CentralCommunicationActorTests : ControlPlaneActorTestBase { private const string DeploymentsTopic = "deployments"; private static MeshTransportOptions GrpcMode(int refreshSeconds = 60) => new() { Mode = MeshTransportOptions.ModeGrpc, ContactRefreshSeconds = refreshSeconds, DispatchTimeoutSeconds = 10, }; /// Seeds a cluster whose nodes carry a GrpcPort — the command plane's shape. private static void SeedGrpcCluster( IDbContextFactory factory, string clusterId, params (string NodeId, string Host, int? GrpcPort, bool Enabled, bool Maintenance)[] nodes) { using var db = factory.CreateDbContext(); db.ServerClusters.Add(new ServerCluster { ClusterId = clusterId, Name = clusterId, Enterprise = "ZB", Site = "Site", RedundancyMode = RedundancyMode.None, NodeCount = 1, CreatedBy = "test", }); foreach (var n in nodes) { db.ClusterNodes.Add(new ClusterNode { NodeId = n.NodeId, ClusterId = clusterId, Host = n.Host, AkkaPort = 4053, GrpcPort = n.GrpcPort, ApplicationUri = $"urn:{n.NodeId}", Enabled = n.Enabled, MaintenanceMode = n.Maintenance, CreatedBy = "test", }); } db.SaveChanges(); } private IActorRef Spawn( IDbContextFactory factory, MeshTransportOptions options, IActorRef? coordinator = null) => Sys.ActorOf(CentralCommunicationActor.Props(factory, options, () => coordinator)); [Fact] public void Dps_mode_publishes_on_the_carried_topic() { // Dps is the pair-local default and stays in the tree: it is the correct transport for a // single-mesh topology, which every integration test and every profile that leaves // MeshTransport:Mode unset still runs. var factory = NewInMemoryDbFactory(); var mediatorProbe = CreateTestProbe(); // Subscribe on the probe's behalf: SubscribeAck goes to the SENDER, so telling the mediator // without an explicit sender would ack to TestActor and this wait would hang. DistributedPubSub.Get(Sys).Mediator.Tell( new Subscribe(DeploymentsTopic, mediatorProbe.Ref), mediatorProbe.Ref); mediatorProbe.ExpectMsg(TimeSpan.FromSeconds(5)); var actor = Spawn(factory, new MeshTransportOptions()); var dispatch = new DispatchDeployment( new DeploymentId(Guid.NewGuid()), new RevisionHash("abc"), new CorrelationId(Guid.NewGuid())); actor.Tell(new MeshCommand(DeploymentsTopic, dispatch)); mediatorProbe.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(dispatch); } [Fact] public void ApplyAck_is_forwarded_to_the_coordinator_preserving_the_sender() { var factory = NewInMemoryDbFactory(); var coordinator = CreateTestProbe(); var node = CreateTestProbe(); var actor = Spawn(factory, GrpcMode(), coordinator.Ref); var ack = new ApplyAck( new DeploymentId(Guid.NewGuid()), new NodeId("host-a:4053"), ApplyAckOutcome.Applied, null, new CorrelationId(Guid.NewGuid())); actor.Tell(ack, node.Ref); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(ack); // Forward, not Tell: the coordinator must see the node, not this relay. coordinator.LastSender.ShouldBe(node.Ref); } [Fact] public void An_unresolvable_coordinator_logs_rather_than_losing_the_ack_silently() { var factory = NewInMemoryDbFactory(); var actor = Spawn(factory, GrpcMode(), coordinator: null); var ack = new ApplyAck( new DeploymentId(Guid.NewGuid()), new NodeId("host-a:4053"), ApplyAckOutcome.Applied, null, new CorrelationId(Guid.NewGuid())); EventFilter.Warning(contains: "not resolvable").ExpectOne(TimeSpan.FromSeconds(5), () => actor.Tell(ack)); } [Fact] public void A_faulted_contact_load_warns_and_leaves_the_actor_alive() { var factory = NewInMemoryDbFactory(); var actor = Spawn(factory, GrpcMode()); // Filter on a phrase unique to the DB-failure warning. EventFilter matches case-INSENSITIVELY, // so a looser "was NOT" would also catch this actor's other warnings and the assertion would // fail with two matches instead of one. EventFilter.Warning(contains: "contact set was NOT refreshed").ExpectOne( TimeSpan.FromSeconds(5), () => actor.Tell(new Status.Failure(new InvalidOperationException("db down")))); // Still serving: a transient DB outage must not take the command boundary down with it. actor.Tell(new MeshCommand(DeploymentsTopic, "anything")); ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } // ------------------------------------------------------------------ Mode = Grpc // // The cross-mesh transport. The fan-out must reach EVERY enabled, non-maintenance node in every // cluster, because the node side has no ClusterId or node filter at all; scoping happens later, // inside the artifact. A narrower fan would leave nodes silently on their old configuration // while the deployment sealed green. [Fact] public void Grpc_mode_fans_one_command_to_every_enabled_node_across_all_clusters() { // THE assertion of this transport: one command, one call per node, across every cluster. var factory = NewInMemoryDbFactory(); SeedGrpcCluster( factory, "C1", ("host-a:4053", "host-a", 4056, true, false), ("host-b:4053", "host-b", 4056, true, false)); SeedGrpcCluster(factory, "C2", ("host-c:4053", "host-c", 4056, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 3, TimeSpan.FromSeconds(5)); rig.Dialer.Calls.Select(c => c.Endpoint).ShouldBe( new[] { "http://host-a:4056", "http://host-b:4056", "http://host-c:4056" }, ignoreOrder: true); // Each node is dialled with ITS OWN cluster's id — that is what selects the pre-shared key, // so getting it wrong turns every command into a PermissionDenied at the node. rig.Dialer.Calls.Single(c => c.Endpoint.Contains("host-c")).ClusterId.ShouldBe("C2"); rig.Dialer.Calls.Where(c => !c.Endpoint.Contains("host-c")).ShouldAllBe(c => c.ClusterId == "C1"); } [Fact] public void Grpc_mode_publishes_nothing_on_pubsub() { // DPS is mesh-wide and cannot cross an application-Cluster boundary, so a command that also // went out on the topic would be delivered twice inside central's own mesh and not at all // anywhere else. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var mediatorProbe = CreateTestProbe(); DistributedPubSub.Get(Sys).Mediator.Tell( new Subscribe(DeploymentsTopic, mediatorProbe.Ref), mediatorProbe.Ref); mediatorProbe.ExpectMsg(TimeSpan.FromSeconds(5)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5)); mediatorProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } [Fact] public void Grpc_mode_does_not_dial_disabled_or_maintenance_nodes() { // Disabled and maintenance-mode rows are not nodes central talks to; the same filter the // contact load applies, asserted here so the fan cannot drift from it. var factory = NewInMemoryDbFactory(); SeedGrpcCluster( factory, "C1", ("host-a:4053", "host-a", 4056, true, false), ("host-b:4053", "host-b", 4056, false, false), ("host-c:4053", "host-c", 4056, true, true)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5)); rig.Dialer.Calls[0].Endpoint.ShouldContain("host-a"); // And no late arrival from the two excluded rows. ExpectNoMsg(TimeSpan.FromMilliseconds(300)); rig.Dialer.Calls.Count.ShouldBe(1); } [Fact] public void A_node_with_no_GrpcPort_is_skipped_with_a_warning_and_the_others_are_still_dialled() { // Mirrors TelemetryNodeSource: a row with no port exposes no endpoint yet. It must cost that // node its commands, never the whole fan. var factory = NewInMemoryDbFactory(); SeedGrpcCluster( factory, "C1", ("host-a:4053", "host-a", null, true, false), ("host-b:4053", "host-b", 4056, true, false)); GrpcRig? rig = null; EventFilter.Warning(contains: "has no GrpcPort").ExpectOne( TimeSpan.FromSeconds(5), () => rig = SpawnGrpcRig(factory)); rig.ShouldNotBeNull(); AwaitContactsLoaded(rig); rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5)); rig.Dialer.Calls[0].Endpoint.ShouldBe("http://host-b:4056"); } [Fact] public void A_node_that_regains_then_loses_its_GrpcPort_warns_again() { // Warn-once is per continuous skip run, not per process. The suppression exists so a // permanently portless row does not repeat every minute; carried across a repair it would // silence the SECOND time a node loses its endpoint — the occurrence an operator most needs // to see, because it is a regression rather than a known gap. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", null, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); SetGrpcPort(factory, "host-a:4053", 4056); rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts()); AwaitCondition( () => rig.Psk.KnownClusterPublications.Count >= 2, TimeSpan.FromSeconds(5)); SetGrpcPort(factory, "host-a:4053", null); EventFilter.Warning(contains: "has no GrpcPort").ExpectOne(TimeSpan.FromSeconds(5), () => rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts())); } [Fact] public void One_failing_node_does_not_stop_the_fan_and_is_logged_by_node_id() { // Fire-and-forget: a failure is logged and DROPPED. No retry, no queue, and above all no // abort of the remaining nodes — the deploy path's apply deadline is the backstop. var factory = NewInMemoryDbFactory(); SeedGrpcCluster( factory, "C1", ("host-a:4053", "host-a", 4056, true, false), ("host-b:4053", "host-b", 4056, true, false)); var dialer = new RecordingDialer { FailEndpointsContaining = "host-a", Fault = new Grpc.Core.RpcException( new Grpc.Core.Status(Grpc.Core.StatusCode.Unavailable, "node down")), }; var rig = SpawnGrpcRig(factory, dialer); AwaitContactsLoaded(rig); // The node id, not the endpoint: an operator reading the log is looking at ClusterNode rows. EventFilter.Warning(contains: "host-a:4053").ExpectOne(TimeSpan.FromSeconds(5), () => rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()))); // Both were attempted; the healthy one is unaffected by its neighbour's failure. AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5)); // Still serving after a per-node failure — no supervision restart, no state loss. rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 4, TimeSpan.FromSeconds(5)); } [Fact] public void A_psk_miss_costs_only_that_node_and_never_becomes_a_fan_level_abort() { // The dialer refuses a keyless cluster locally, so it arrives here as an ordinary per-node // failure. The fan-level assertion is that the other cluster is still dialled. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); SeedGrpcCluster(factory, "C2", ("host-b:4053", "host-b", 4056, true, false)); var dialer = new RecordingDialer { FailClusters = ["C2"], Fault = new Grpc.Core.RpcException( new Grpc.Core.Status(Grpc.Core.StatusCode.Unauthenticated, "no key")), }; var rig = SpawnGrpcRig(factory, dialer); AwaitContactsLoaded(rig); EventFilter.Warning(contains: "Unauthenticated").ExpectOne(TimeSpan.FromSeconds(5), () => rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()))); AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5)); } [Fact] public void A_command_with_no_grpc_target_is_dropped_with_a_warning() { var factory = NewInMemoryDbFactory(); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); EventFilter.Warning(contains: "dropping").ExpectOne(TimeSpan.FromSeconds(5), () => rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()))); rig.Dialer.Calls.ShouldBeEmpty(); } [Fact] public void A_payload_that_is_not_a_mesh_command_record_is_dropped_with_an_error() { // The mapper refuses to encode an unknown payload rather than shipping something the node // cannot decode. That refusal must cost the command, not the actor. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); EventFilter.Error(contains: "Cannot encode").ExpectOne(TimeSpan.FromSeconds(5), () => rig.Actor.Tell(new MeshCommand(DeploymentsTopic, "not a command record"))); rig.Dialer.Calls.ShouldBeEmpty(); // Still serving. rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5)); } [Fact] public void The_envelope_carries_the_commands_own_correlation_and_the_whole_fan_shares_it() { var factory = NewInMemoryDbFactory(); SeedGrpcCluster( factory, "C1", ("host-a:4053", "host-a", 4056, true, false), ("host-b:4053", "host-b", 4056, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); var correlationId = new CorrelationId(Guid.NewGuid()); rig.Actor.Tell(new MeshCommand(DeploymentsTopic, new DispatchDeployment( new DeploymentId(Guid.NewGuid()), new RevisionHash("abc"), correlationId))); AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5)); rig.Dialer.Calls.ShouldAllBe(c => c.Envelope.CorrelationId == correlationId.ToString()); // One envelope object, encoded once and fanned — not re-encoded per node. rig.Dialer.Calls.Select(c => c.Envelope).Distinct().Count().ShouldBe(1); } [Fact] public void An_alarm_command_gets_one_minted_transport_correlation_shared_by_the_whole_fan() { // AlarmCommand is the one record with no correlation member of its own, so the envelope's is // the only handle an operator has on it. Minting per fan rather than per node is what makes // "this alarm command" one traceable event instead of N unrelated ones. var factory = NewInMemoryDbFactory(); SeedGrpcCluster( factory, "C1", ("host-a:4053", "host-a", 4056, true, false), ("host-b:4053", "host-b", 4056, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); rig.Actor.Tell(new MeshCommand( "alarms", new AlarmCommand("alarm-1", "Acknowledge", "operator", null, null))); AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5)); rig.Dialer.Calls.Select(c => c.Envelope.CorrelationId).Distinct().Count().ShouldBe(1); rig.Dialer.Calls[0].Envelope.CorrelationId.ShouldNotBeNullOrWhiteSpace(); rig.Dialer.Calls[0].Envelope.AlarmCommand.AlarmId.ShouldBe("alarm-1"); } [Fact] public void The_contact_refresh_publishes_the_known_cluster_set_and_drops_cached_psks() { // Phase 1A security register item 1, wired: the gate is only a control if something keeps it // current, and the refresh is the moment central's picture of the fleet is up to date. The // InvalidateAll on the same tick is what makes a rotated key take effect within one refresh // instead of requiring a central restart. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); SeedGrpcCluster(factory, "C2", ("host-b:4053", "host-b", null, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); // C2 is included even though its only node has no GrpcPort: the gate is about which clusters // central KNOWS, not which it can currently dial. Excluding it would deny that cluster's acks. rig.Psk.KnownClusterPublications[0].ShouldBe(new[] { "C1", "C2" }, ignoreOrder: true); rig.Psk.InvalidateAllCount.ShouldBeGreaterThanOrEqualTo(1); } [Fact] public void A_slow_dial_does_not_park_the_actors_mailbox() { // The dialer's first act is a PSK lookup, which blocks on the secret store — bounded, but // seconds, and repeated after every refresh because the refresh drops the cache. An async // method runs synchronously to its first await, so dialing straight from the receive would // stall this actor for that long PER NODE, and deployment acks queue behind that mailbox. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var coordinator = CreateTestProbe(); var dialer = new RecordingDialer { BlockBeforeFirstAwait = true }; var rig = SpawnGrpcRig(factory, dialer, coordinator: coordinator.Ref); AwaitContactsLoaded(rig); try { rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); // The ack must be handled while the dial is still parked. var ack = new ApplyAck( new DeploymentId(Guid.NewGuid()), new NodeId("host-a:4053"), ApplyAckOutcome.Applied, null, new CorrelationId(Guid.NewGuid())); rig.Actor.Tell(ack); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).ShouldBe(ack); } finally { dialer.Release(); } } [Fact] public void A_node_added_after_start_joins_the_fan_on_the_next_refresh() { // The refresh is not decoration: operators add and retire nodes, and central must pick them // up without a restart. Under Grpc that is the ONLY thing keeping the dispatch set current — // nothing on the node side announces itself; the DB row is the only source. // // Driven by telling the actor its own RefreshContacts tick rather than by shortening // ContactRefreshSeconds: a one-second timer would run for the whole fact, adding DB load to // every test sharing the runner, and would make this assertion depend on wall-clock timing. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); using (var db = factory.CreateDbContext()) { db.ClusterNodes.Add(new ClusterNode { NodeId = "host-b:4053", ClusterId = "C1", Host = "host-b", AkkaPort = 4053, GrpcPort = 4056, ApplicationUri = "urn:host-b", Enabled = true, CreatedBy = "test", }); db.SaveChanges(); } rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts()); AwaitCondition( () => rig.Psk.KnownClusterPublications.Count >= 2, TimeSpan.FromSeconds(5)); rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5)); rig.Dialer.Calls.Select(c => c.Endpoint).ShouldBe( new[] { "http://host-a:4056", "http://host-b:4056" }, ignoreOrder: true); // Once PER REFRESH, not once at startup. A key can rotate without any membership change, so // an invalidation that only fired when the cluster set moved would serve the pre-rotation // key forever and a rotation would need a central restart. rig.Psk.InvalidateAllCount.ShouldBeGreaterThanOrEqualTo(2); } [Theory] [InlineData(MeshTransportOptions.ModeDps)] [InlineData(MeshTransportOptions.ModeGrpc)] public void The_known_cluster_gate_is_published_in_every_transport_mode(string mode) { // Replaces an earlier fact asserting the OPPOSITE, whose premise did not survive Phase 1B: the // gate feeds CentralCommandAuthInterceptor, which is registered on every admin node, and the // ack service it protects is mapped by LISTENER PORT — neither consults MeshTransport:Mode. // Gating the publication on Grpc meant a rollback to Dps left that endpoint serving with a // permanently inactive gate, silently restoring both the timing oracle and the // pre-authentication store/audit flood. The set is central's cluster inventory, not // transport state. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var psk = new RecordingPskProvider(); Sys.ActorOf(CentralCommunicationActor.Props( factory, new MeshTransportOptions { Mode = mode, ContactRefreshSeconds = 60 }, () => null, () => new RecordingDialer(), () => psk)); AwaitCondition( () => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5)); psk.KnownClusterPublications[0].ShouldBe(new[] { "C1" }); psk.InvalidateAllCount.ShouldBeGreaterThanOrEqualTo(1); } [Fact] public void The_gate_set_includes_a_cluster_the_fan_set_excludes() { // The two sets answer different questions. The FAN asks "who does central talk to" — enabled, // non-maintenance, ported. The GATE asks "is this a cluster central has ever heard of", and // must say yes for a cluster whose every node is disabled or in maintenance: such a cluster // can still send a deployment ack, and refusing it would quietly turn maintenance mode into // an authentication switch. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); SeedGrpcCluster(factory, "C-DISABLED", ("host-b:4053", "host-b", 4056, false, false)); SeedGrpcCluster(factory, "C-MAINT", ("host-c:4053", "host-c", 4056, true, true)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); rig.Psk.KnownClusterPublications[0].ShouldBe( new[] { "C1", "C-DISABLED", "C-MAINT" }, ignoreOrder: true); rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5)); ExpectNoMsg(TimeSpan.FromMilliseconds(300)); rig.Dialer.Calls.Count.ShouldBe(1); rig.Dialer.Calls[0].ClusterId.ShouldBe("C1"); } [Fact] public void The_gate_is_published_after_the_dispatch_set_is_adopted_and_before_the_cache_is_dropped() { // Two orderings in one handler, both load-bearing. SetKnownClusters must precede // InvalidateAll, or the first lookup after a refresh re-reads the store under the PREVIOUS // membership — briefly resolving a cluster central has just retired. And the dispatch set is // adopted first so a command processed immediately after the publication already fans over // the current targets. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var rig = SpawnGrpcRig(factory); AwaitContactsLoaded(rig); rig.Psk.Sequence.ShouldBe(new[] { "SetKnownClusters", "InvalidateAll" }); // Targets-before-gate, observed the only way it can be from outside the actor: the very next // message finds the dispatch set already populated. rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5)); } [Fact] public void Grpc_mode_with_no_dialer_wired_drops_the_command_with_an_error() { // Mis-wiring, not misconfiguration: Mode=Grpc validates fine but the DI graph did not supply // a dialer. Silence here would be a fleet-wide command outage with no symptom until a deploy // stalled at its apply deadline two minutes later. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var actor = Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => null, dialer: null, pskProvider: null)); // A never-supplied factory (dialer: null here) is silent at PreStart and on every retry — // see Resolve's remarks — so this dispatch-time Error is the ONLY log line the mis-wiring // produces. That is what makes the plain "no command dialer is wired" phrase safe to match // without a second source racing it, unlike before this actor stopped double-reporting the // same absence at two severities. EventFilter.Error(contains: "no command dialer is wired").ExpectOne( TimeSpan.FromSeconds(5), () => actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()))); } // ------------------------------------------------------------------ PreStart resolve-race logging // // PreStart's eager resolve of IMeshCommandDialer/IMeshPskProvider loses a millisecond-wide race // against the Akka builder's remaining starters on essentially every admin boot (live gates: 1A // 2/3 boots, 1B 4/4 boots) and self-heals at the memoized properties' first use. The two facts // below are the headline pin: a healthy boot must produce ZERO Warning/Error from this actor, and // a GENUINELY broken factory must still be reported loudly, exactly once. [Fact] public void A_healthy_boot_produces_no_warning_or_error_even_though_PreStart_loses_the_resolve_race() { // Models the ordinary boot: both factories throw on their FIRST invocation (PreStart, which // loses the race) and succeed on every subsequent one (the memoized properties' first-use // retry, long after every starter has finished). This must be silent — no Warning, no Error // — from PreStart all the way through the first contact load and the first dispatch, or a // soak error-grep on a healthy rig is impossible. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var dialer = new RecordingDialer(); var psk = new RecordingPskProvider(); var dialerCalls = 0; var pskCalls = 0; IMeshCommandDialer? DialerFactory() { dialerCalls++; return dialerCalls == 1 ? throw new InvalidOperationException("starter-ordering race") : dialer; } IMeshPskProvider? PskFactory() { pskCalls++; return pskCalls == 1 ? throw new InvalidOperationException("starter-ordering race") : psk; } IActorRef? actor = null; EventFilter.Warning().And.Error().Expect(0, TimeSpan.FromSeconds(5), () => { actor = Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => null, DialerFactory, PskFactory)); // First use of the PSK provider: the contact-refresh timer's initial (TimeSpan.Zero) // tick, observed via the gate publish. AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5)); // First use of the dialer: an ordinary dispatch, retried until the contact load (which // races this on the same timer tick) has adopted the gRPC target set. AwaitCondition(() => { actor!.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())); return dialer.Calls.Count >= 1; }, TimeSpan.FromSeconds(5)); }); // Both retries actually happened — this fact is not vacuously true because the factories // never got a second chance to run. dialerCalls.ShouldBeGreaterThanOrEqualTo(2); pskCalls.ShouldBeGreaterThanOrEqualTo(2); } /// Counts contact loads and fails the first failFirst of them. private sealed class CountingDbFactory( IDbContextFactory inner, int failFirst = 0) : IDbContextFactory { private int _calls; public int Calls => Volatile.Read(ref _calls); public OtOpcUaConfigDbContext CreateDbContext() { if (Interlocked.Increment(ref _calls) <= failFirst) throw new InvalidOperationException("ConfigDb unreachable at boot"); return inner.CreateDbContext(); } public Task CreateDbContextAsync(CancellationToken ct = default) => Task.FromResult(CreateDbContext()); } private static MeshTransportOptions ModeOptions(string mode) => new() { Mode = mode, ContactRefreshSeconds = 60, DispatchTimeoutSeconds = 10, }; [Theory] [InlineData(MeshTransportOptions.ModeGrpc)] [InlineData(MeshTransportOptions.ModeDps)] public void The_gate_publishes_within_seconds_when_the_provider_resolves_late_under_the_default_refresh( string mode) { // #654: the first RefreshContacts can land before the registry-bound PSK graph is resolvable. // The next PERIODIC tick is 60 s away; the bounded bootstrap retry must publish in seconds. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var psk = new RecordingPskProvider(); var calls = 0; IMeshPskProvider? PskFactory() => Interlocked.Increment(ref calls) <= 3 ? throw new InvalidOperationException("race") : psk; EventFilter.Error().Expect(0, TimeSpan.FromSeconds(1), () => { Sys.ActorOf(CentralCommunicationActor.Props( factory, ModeOptions(mode), () => null, () => new RecordingDialer(), PskFactory)); AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(10)); }); } [Fact] public void A_failed_first_contact_load_retries_quickly_under_the_default_refresh() { var inner = NewInMemoryDbFactory(); SeedGrpcCluster(inner, "C1", ("host-a:4053", "host-a", 4056, true, false)); var factory = new CountingDbFactory(inner, failFirst: 1); var psk = new RecordingPskProvider(); EventFilter.Warning().Expect(2, TimeSpan.FromSeconds(10), () => Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => null, () => new RecordingDialer(), () => psk))); AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(10)); } [Fact] public void Bootstrap_retries_stop_once_the_gate_is_published() { // The provider throws on PreStart (call 1) and on the first load's resolve (call 2), which // schedules a 1 s retry. A manual refresh then publishes while that retry is still PENDING; // stop-on-publish must cancel it. Without the cancel the timer fires, loads again and // republishes (loads == 3, publications == 2). var inner = NewInMemoryDbFactory(); SeedGrpcCluster(inner, "C1", ("host-a:4053", "host-a", 4056, true, false)); var factory = new CountingDbFactory(inner); var psk = new RecordingPskProvider(); var calls = 0; IMeshPskProvider? PskFactory() => Interlocked.Increment(ref calls) <= 2 ? throw new InvalidOperationException("race") : psk; var actor = Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => null, () => new RecordingDialer(), PskFactory, bootstrapRetryInterval: TimeSpan.FromSeconds(1))); // Positive control: the failed first resolve happened, so a retry is pending. AwaitCondition(() => Volatile.Read(ref calls) >= 2, TimeSpan.FromSeconds(5)); Thread.Sleep(100); actor.Tell(new CentralCommunicationActor.RefreshContacts()); AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5)); Thread.Sleep(2500); factory.Calls.ShouldBe(2); psk.KnownClusterPublications.Count.ShouldBe(1); } [Fact] public void A_permanently_failing_load_retry_budget_is_bounded() { var inner = NewInMemoryDbFactory(); var factory = new CountingDbFactory(inner, failFirst: int.MaxValue); Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => null, () => new RecordingDialer(), () => new RecordingPskProvider(), bootstrapRetryInterval: TimeSpan.FromMilliseconds(20))); EventFilter.Warning().Expect(32, TimeSpan.FromSeconds(10), () => { }); Thread.Sleep(1500); // 1 initial + 15 retries; the 60 s periodic tick has not fired again. factory.Calls.ShouldBe(16); } [Fact] public void A_permanently_unresolvable_pskProvider_is_reported_once_per_incarnation_not_once_per_refresh() { // The counterpart pin: a factory that NEVER succeeds is the genuinely permanent absence and // must still be loud — but PublishKnownClusters runs on the contact-refresh timer for the // actor's whole life, so an unguarded Error would repeat every ContactRefreshSeconds forever. // Guarded exactly like _warnedPortlessNodes: once per incarnation, not once per refresh. // // The factory counts its own invocations rather than relying on a dialer/dispatch side // effect: LoadContactsFromDb is fire-and-forget, so a signal that isn't the resolve itself // (e.g. a dispatched command landing) can close an AwaitCondition window before the SECOND // contact load has actually re-invoked PublishKnownClusters, making the "does not repeat" // half true for the wrong reason. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var pskCalls = 0; IMeshPskProvider? PskFactory() { pskCalls++; throw new InvalidOperationException("permanently broken"); } IActorRef actor = null!; EventFilter.Error(contains: "will NEVER be published").ExpectOne(TimeSpan.FromSeconds(10), () => { actor = Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => null, () => new RecordingDialer(), pskProvider: PskFactory, bootstrapRetryInterval: TimeSpan.FromMilliseconds(50))); // pskCalls == 1 is PreStart's own attempt; reaching 2 is PROOF the first contact load // ran and PublishKnownClusters's property access performed the first-use retry — not // merely that the actor was constructed. AwaitCondition(() => pskCalls >= 2, TimeSpan.FromSeconds(5)); }); // The bootstrap retry is BOUNDED: with a 50 ms interval and a budget of 15 it has long // finished, so the call count plateaus instead of growing with elapsed time. Thread.Sleep(500); var plateau = pskCalls; Thread.Sleep(500); pskCalls.ShouldBe(plateau); var pskCallsAfterFirstLoad = pskCalls; // A SECOND contact load must NOT repeat the Error. The pin only means something if that // second load actually happened and retried the resolve again — AwaitCondition on a // STRICTLY higher pskCalls count, taken INSIDE the very block whose zero-Error assertion // depends on it, is what proves that rather than assuming it from elapsed time. EventFilter.Error(contains: "will NEVER be published").Expect(0, TimeSpan.FromSeconds(5), () => { actor.Tell(new CentralCommunicationActor.RefreshContacts()); AwaitCondition(() => pskCalls > pskCallsAfterFirstLoad, TimeSpan.FromSeconds(5)); }); } [Fact] public void A_supplied_dialer_factory_that_always_throws_still_drops_each_command_loudly_while_PreStart_stays_quiet() { // The symmetric case to Grpc_mode_with_no_dialer_wired_drops_the_command_with_an_error // above: there the factory was never supplied (Resolve returns null with no exception, // silent at every call site); here a factory WAS supplied and never succeeds, so it goes // through Resolve's catch on both the PreStart attempt (firstUse: false, Debug) and every // first-use retry (firstUse: true, still Debug — see Resolve's remarks on why severity is // decided by the consumer, not by Resolve). The loud signal is DispatchOverGrpc's existing // per-dispatch Error, unaffected by an exception being the reason the dialer is null rather // than a bare absence, and unguarded — unlike the PSK provider, every dispatch reports it. var factory = NewInMemoryDbFactory(); SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false)); var psk = new RecordingPskProvider(); IActorRef actor = null!; EventFilter.Warning().And.Error().Expect(0, TimeSpan.FromSeconds(5), () => { actor = Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => null, dialer: () => throw new InvalidOperationException("permanently broken"), pskProvider: () => psk)); // Proof PreStart's resolve attempt and the first contact load it schedules have // actually run, so this block's silence is not vacuous. AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5)); }); EventFilter.Error(contains: "no command dialer is wired").ExpectOne(TimeSpan.FromSeconds(5), () => actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()))); // A SECOND dispatch must ALSO log it: this signal is per-dispatch and unguarded, the // opposite shape from the PSK provider's once-per-incarnation guard above. EventFilter.Error(contains: "no command dialer is wired").ExpectOne(TimeSpan.FromSeconds(5), () => actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()))); } private static void SetGrpcPort( IDbContextFactory factory, string nodeId, int? grpcPort) { using var db = factory.CreateDbContext(); var node = db.ClusterNodes.Single(n => n.NodeId == nodeId); node.GrpcPort = grpcPort; db.SaveChanges(); } private static DispatchDeployment NewDispatch() => new( new DeploymentId(Guid.NewGuid()), new RevisionHash("abc"), new CorrelationId(Guid.NewGuid())); /// A spawned gRPC-mode actor plus the two seams the tests observe it through. private sealed record GrpcRig(IActorRef Actor, RecordingDialer Dialer, RecordingPskProvider Psk); private GrpcRig SpawnGrpcRig( IDbContextFactory factory, RecordingDialer? dialer = null, IActorRef? coordinator = null) { dialer ??= new RecordingDialer(); var psk = new RecordingPskProvider(); var actor = Sys.ActorOf(CentralCommunicationActor.Props( factory, GrpcMode(), () => coordinator, () => dialer, () => psk)); return new GrpcRig(actor, dialer, psk); } /// /// Waits until the actor has applied a contact load. The known-cluster publication is the /// signal because it happens in the same handler, immediately after the target set is /// adopted — and unlike the target set itself it is observable from outside the actor. A /// command told before this point would be dropped as "no targets" and never retried, which /// is correct behaviour and a flaky test. /// private void AwaitContactsLoaded(GrpcRig rig) => AwaitCondition( () => rig.Psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5)); /// Records every dial and answers however the test asks; never opens a socket. private sealed class RecordingDialer : IMeshCommandDialer { private readonly object _gate = new(); private readonly List _calls = []; private readonly ManualResetEventSlim _released = new(false); /// /// When true, every dial blocks its calling thread synchronously until /// . Models the dialer's pre-await PSK lookup. /// public bool BlockBeforeFirstAwait { get; set; } /// Unblocks every dial parked by . public void Release() => _released.Set(); /// Every dial made. Copied under the lock — the fan runs on thread-pool threads. public IReadOnlyList Calls { get { lock (_gate) return _calls.ToList(); } } /// Endpoints whose substring match makes the dial fault. public string? FailEndpointsContaining { get; set; } /// Cluster ids whose dials fault. public string[] FailClusters { get; set; } = []; /// /// The fault raised for a matching dial. Fully qualified because this file's bare /// Status is Akka's — an existing fact drives the actor's Status.Failure /// handler, and importing Grpc.Core here would make that reference ambiguous. /// public Grpc.Core.RpcException? Fault { get; set; } public Task DispatchAsync( string nodeEndpoint, CommandEnvelope envelope, string clusterId, CancellationToken ct) { lock (_gate) _calls.Add(new Dial(nodeEndpoint, envelope, clusterId)); if (BlockBeforeFirstAwait) { // Blocks the CALLING thread, exactly as IMeshPskProvider.TryGetPsk does on a cold // cache: an async method runs synchronously to its first await, so a dial invoked // straight from the actor would park the mailbox here. _released.Wait(TimeSpan.FromSeconds(30)); } var shouldFail = (FailEndpointsContaining is not null && nodeEndpoint.Contains(FailEndpointsContaining)) || FailClusters.Contains(clusterId, StringComparer.OrdinalIgnoreCase); return shouldFail && Fault is not null ? Task.FromException(Fault) : Task.CompletedTask; } /// One recorded dial. public sealed record Dial(string Endpoint, CommandEnvelope Envelope, string ClusterId); } /// Records the gate and invalidation calls the contact refresh is supposed to make. private sealed class RecordingPskProvider : IMeshPskProvider { private readonly object _gate = new(); private readonly List> _publications = []; private readonly List _sequence = []; private int _invalidateAllCount; /// Names of the mutating calls made, in the order they arrived. public IReadOnlyList Sequence { get { lock (_gate) return _sequence.ToList(); } } /// Every known-cluster set published, in order. public IReadOnlyList> KnownClusterPublications { get { lock (_gate) return _publications.ToList(); } } /// How many times the whole cache was dropped. public int InvalidateAllCount => Volatile.Read(ref _invalidateAllCount); public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk) { psk = "psk"; return true; } public bool Matches(string clusterId, string presented) => true; public void SetKnownClusters(IReadOnlyCollection clusterIds) { lock (_gate) { _publications.Add(clusterIds.ToList()); _sequence.Add(nameof(SetKnownClusters)); } } public void Invalidate(string clusterId) { } public void InvalidateAll() { Interlocked.Increment(ref _invalidateAllCount); lock (_gate) _sequence.Add(nameof(InvalidateAll)); } } }