Merge remote-tracking branch 'origin/master' into test/task-assertion-return-discard
v2-ci / unit-tests (pull_request) Successful in 1h1m9s
v2-ci / unit-tests (pull_request) Successful in 1h1m9s
This commit is contained in:
+240
@@ -0,0 +1,240 @@
|
||||
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<ActorIdentity>().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<ActorIdentity>().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<DbHealthProbeActor.GetStatus>();
|
||||
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<DbHealthProbeActor.GetStatus>();
|
||||
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<DbHealthProbeActor.GetStatus>();
|
||||
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<DbHealthProbeActor.GetStatus>();
|
||||
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>(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<byte> _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<byte> _levels = new();
|
||||
public byte[] Levels => _levels.ToArray();
|
||||
public void Publish(byte serviceLevel) => _levels.Enqueue(serviceLevel);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user