test: verify missing-snapshot ServiceLevel timer behavior
v2-ci / unit-tests (pull_request) Successful in 1h0m42s
v2-ci / unit-tests (pull_request) Successful in 1h0m42s
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||||
@@ -134,6 +136,82 @@ public sealed class OpcUaPublishActorFreshnessTests : RuntimeActorTestBase
|
|||||||
AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)110), PresenceBudget);
|
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 sealed class ControlledClock
|
||||||
{
|
{
|
||||||
private long _ticks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
private long _ticks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
|
||||||
|
|||||||
Reference in New Issue
Block a user