using System.Collections.Concurrent; using Akka.Actor; using Akka.TestKit; using Akka.TestKit.Xunit2; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Health; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; public sealed class OpcUaPublishActorFreshnessTests : RuntimeActorTestBase { [Theory] [InlineData(false, 240, 200)] [InlineData(true, 250, 210)] public void DbLess_timer_demotes_without_incoming_messages(bool primary, byte fresh, byte stale) { var publisher = new RecordingPublisher(); var clock = new ControlledClock(); var local = NodeId.Parse("freshness-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbLess: true, staleWindow: TimeSpan.FromSeconds(1), healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read)); actor.Tell(Snapshot(local, primary, clock.Now)); AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh }), PresenceBudget); // Equality remains fresh. Observe several real ticks with a fixed clock; no actor Tell. clock.Advance(TimeSpan.FromSeconds(1)); var reads = clock.Reads; AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget); // Drain earlier self-enqueued publications without triggering a health recomputation. actor.Tell(new Identify("freshness-barrier"), TestActor); ExpectMsg().Subject.ShouldBe(actor); publisher.Levels.ShouldBe(new[] { fresh }); clock.Advance(TimeSpan.FromTicks(1)); // No further Tell: only the production timer can notice age crossing the window. AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale }), PresenceBudget); // A new fresh snapshot restores service, after which ticks continue to age that snapshot. actor.Tell(Snapshot(local, primary, clock.Now)); AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale, fresh }), PresenceBudget); clock.Advance(TimeSpan.FromSeconds(1) + TimeSpan.FromTicks(1)); AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale, fresh, stale }), PresenceBudget); } [Fact] public void DbLess_timer_expires_negative_peer_verdict_only_after_exact_boundary() { var publisher = new RecordingPublisher(); var clock = new ControlledClock(); var local = NodeId.Parse("peer-verdict-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbLess: true, staleWindow: TimeSpan.FromSeconds(10), probeFreshnessWindow: TimeSpan.FromSeconds(2), healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read)); actor.Tell(Snapshot(local, false, clock.Now)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget); actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0 }), PresenceBudget); clock.Advance(TimeSpan.FromSeconds(2)); var reads = clock.Reads; AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget); // Drain earlier self-enqueued publications without triggering a health recomputation. actor.Tell(new Identify("freshness-barrier"), TestActor); ExpectMsg().Subject.ShouldBe(actor); publisher.Levels.ShouldBe(new byte[] { 240, 0 }); clock.Advance(TimeSpan.FromTicks(1)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240 }), PresenceBudget); } [Fact] public void Stopping_DbLess_actor_cancels_freshness_timer_and_publications() { var publisher = new RecordingPublisher(); var clock = new ControlledClock(); var local = NodeId.Parse("stopped-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbLess: true, staleWindow: TimeSpan.FromSeconds(1), healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read)); actor.Tell(Snapshot(local, false, clock.Now)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget); var reads = clock.Reads; AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget); Watch(actor); Sys.Stop(actor); ExpectTerminated(actor); reads = clock.Reads; clock.Advance(TimeSpan.FromSeconds(2)); ExpectNoMsg(TimeSpan.FromMilliseconds(150)); // Six timer periods after confirmed termination. clock.Reads.ShouldBe(reads); publisher.Levels.ShouldBe(new byte[] { 240 }); } [Fact] public void DbBacked_timer_refreshes_probe_status_periodically() { var publisher = new RecordingPublisher(); var clock = new ControlledClock(); var local = NodeId.Parse("db-backed-periodic-node"); var db = CreateTestProbe(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: db.Ref, healthTickInterval: TimeSpan.FromMilliseconds(100), utcNow: clock.Read)); actor.Tell(Snapshot(local, false, clock.Now)); db.ExpectMsg(); db.Reply(new DbHealthProbeActor.DbHealthStatus(true, clock.Now, null)); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), PresenceBudget); // A second request comes from the periodic timer, not the immediate startup tick. db.ExpectMsg(); db.Reply(new DbHealthProbeActor.DbHealthStatus(false, clock.Now, "unreachable")); AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)100), PresenceBudget); } [Fact] public void DbBacked_timer_ask_timeout_demotes_last_known_healthy_node() { var publisher = new RecordingPublisher(); var clock = new ControlledClock(); var local = NodeId.Parse("db-backed-timeout-node"); var db = CreateTestProbe(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbHealthProbe: db.Ref, healthTickInterval: TimeSpan.FromMilliseconds(100), utcNow: clock.Read)); actor.Tell(Snapshot(local, true, clock.Now)); db.ExpectMsg(); db.Reply(new DbHealthProbeActor.DbHealthStatus(true, clock.Now, null)); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), PresenceBudget); // Leave subsequent real Ask requests unanswered: the timeout status must reach the actor. db.ExpectMsg(); AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)110), PresenceBudget); } [Fact] public void Missing_local_snapshot_entry_retries_zero_on_timer_and_recovers() { var publisher = new RetryZeroPublisher(); var clock = new ControlledClock(); var local = NodeId.Parse("missing-entry-node"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: local, dbLess: true, healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read)); actor.Tell(Snapshot(local, false, clock.Now)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget); // Only this snapshot Tell requests the first zero. Fail it and the next attempt so // subsequent zero attempts must come from the periodic timer, exercising the null-entry // branch repeatedly rather than mistaking the Detached branch for missing-node coverage. actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0 }), PresenceBudget); publisher.ZeroAttempts.ShouldBeGreaterThanOrEqualTo(3); actor.Tell(Snapshot(local, false, clock.Now)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240 }), PresenceBudget); clock.Advance(TimeSpan.FromSeconds(30) + TimeSpan.FromTicks(1)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240, 200 }), PresenceBudget); } [Fact] public void No_snapshot_startup_does_not_publish_across_virtual_timer_periods() { // This actor requires the Cluster extension, but the absent/missing-entry guards do // not inspect membership. Do not self-join: the ordinary harness waits for Up in its // constructor, which cannot progress with a frozen scheduler. using var local = new TestKit(AkkaTestHocon + @" akka.scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit"""); var scheduler = (TestScheduler)local.Sys.Scheduler; var publisher = new RetryZeroPublisher(); var clock = new ControlledClock(); var actor = local.ActorOfAsTestActorRef(OpcUaPublishActor.PropsForTests( serviceLevel: publisher, localNode: NodeId.Parse("no-snapshot-node"), dbLess: true, healthTickInterval: TimeSpan.FromSeconds(1), utcNow: clock.Read)); // TestActorRef uses the calling-thread dispatcher: Advance completes delivery before // these assertions, including the immediate startup tick and three periodic ticks. for (var period = 0; period < 3; period++) scheduler.Advance(TimeSpan.FromSeconds(1)); publisher.Levels.ShouldBeEmpty(); publisher.ZeroAttempts.ShouldBe(0); clock.Reads.ShouldBe(0); // Positive control: the same registered timer must subsequently retry a failed zero. // No whole snapshot at startup means no publication; a received snapshot missing the // local entry means zero. Keep these two policies explicitly distinct. actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now)); publisher.ZeroAttempts.ShouldBe(1); scheduler.Advance(TimeSpan.FromSeconds(1)); publisher.ZeroAttempts.ShouldBe(2); publisher.Levels.ShouldBeEmpty(); scheduler.Advance(TimeSpan.FromSeconds(1)); publisher.ZeroAttempts.ShouldBe(3); publisher.Levels.ShouldBe(new byte[] { 0 }); clock.Reads.ShouldBe(0); } private sealed class RetryZeroPublisher : IServiceLevelPublisher { private readonly ConcurrentQueue _levels = new(); private int _zeroAttempts; public byte[] Levels => _levels.ToArray(); public int ZeroAttempts => Volatile.Read(ref _zeroAttempts); public void Publish(byte serviceLevel) { if (serviceLevel == 0 && Interlocked.Increment(ref _zeroAttempts) <= 2) throw new InvalidOperationException("test zero-publication failure"); _levels.Enqueue(serviceLevel); } } private sealed class ControlledClock { private long _ticks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; private int _reads; public DateTime Now => new(Interlocked.Read(ref _ticks), DateTimeKind.Utc); public int Reads => Volatile.Read(ref _reads); public DateTime Read() { Interlocked.Increment(ref _reads); return Now; } public void Advance(TimeSpan age) => Interlocked.Add(ref _ticks, age.Ticks); } private static RedundancyStateChanged Snapshot(NodeId local, bool primary, DateTime asOf) => new(new[] { new NodeRedundancyState(local, primary ? RedundancyRole.Primary : RedundancyRole.Secondary, IsClusterLeader: primary, IsDriverPrimary: primary, asOf) }, CorrelationId.NewId()); private sealed class RecordingPublisher : IServiceLevelPublisher { private readonly ConcurrentQueue _levels = new(); public byte[] Levels => _levels.ToArray(); public void Publish(byte serviceLevel) => _levels.Enqueue(serviceLevel); } }