diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs
index 17c5d312..374147a6 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs
@@ -197,8 +197,6 @@ public sealed class GatewayCeilingPagingTests : IDisposable
[InlineData(L - 1, 5000u)]
[InlineData(L, 5000u)]
[InlineData(L + 1, 5000u)]
- [InlineData(Ceiling - 17, 5000u)]
- [InlineData(Ceiling - 16, 5000u)]
[InlineData(Ceiling + 1, 5000u)]
[InlineData(L - 2, 0u)]
[InlineData(L - 1, 0u)]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/CentralCommunicationActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/CentralCommunicationActorTests.cs
index d4e8a1fb..9843c8fe 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/CentralCommunicationActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Communication/CentralCommunicationActorTests.cs
@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
+using Akka.Event;
+using Akka.TestKit;
+using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
@@ -680,7 +683,7 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
/// Counts contact loads and fails the first failFirst of them.
private sealed class CountingDbFactory(
- IDbContextFactory inner, int failFirst = 0)
+ IDbContextFactory inner, int failFirst = 0, Action? onLoad = null)
: IDbContextFactory
{
private int _calls;
@@ -689,7 +692,9 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
public OtOpcUaConfigDbContext CreateDbContext()
{
- if (Interlocked.Increment(ref _calls) <= failFirst)
+ var call = Interlocked.Increment(ref _calls);
+ onLoad?.Invoke(call);
+ if (call <= failFirst)
throw new InvalidOperationException("ConfigDb unreachable at boot");
return inner.CreateDbContext();
}
@@ -747,31 +752,51 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
[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).
+ // Freeze time only for this fact: the cluster harness and every other retry test retain
+ // their real scheduler. gRPC mode needs neither cluster membership nor pub-sub here.
+ using var local = new TestKit(@"akka {
+ loglevel = INFO
+ scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit""
+ }");
+ var scheduler = (TestScheduler)local.Sys.Scheduler;
+ var loads = local.CreateTestProbe();
+ var barrier = local.CreateTestProbe();
+ local.Sys.EventStream.Subscribe(barrier.Ref, typeof(UnhandledMessage));
var inner = NewInMemoryDbFactory();
SeedGrpcCluster(inner, "C1", ("host-a:4053", "host-a", 4056, true, false));
- var factory = new CountingDbFactory(inner);
+ var factory = new CountingDbFactory(inner, onLoad: n => loads.Ref.Tell(n));
var psk = new RecordingPskProvider();
var calls = 0;
+ // PreStart and the first contact load fail resolution. The latter registers a retry.
IMeshPskProvider? PskFactory() =>
Interlocked.Increment(ref calls) <= 2 ? throw new InvalidOperationException("race") : psk;
- var actor = Sys.ActorOf(CentralCommunicationActor.Props(
+ var actor = local.Sys.ActorOf(CentralCommunicationActor.Props(
factory, GrpcMode(), () => null, () => new RecordingDialer(), PskFactory,
bootstrapRetryInterval: TimeSpan.FromSeconds(1)));
+ // A mailbox barrier also waits for PreStart to register the zero-delay periodic tick.
+ var marker = new object();
+ actor.Tell(marker);
+ barrier.ExpectMsg(TimeSpan.FromSeconds(5)).Message.ShouldBeSameAs(marker);
+ scheduler.Advance(TimeSpan.Zero);
+ loads.ExpectMsg(1, TimeSpan.FromSeconds(5));
+ local.AwaitCondition(() => Volatile.Read(ref calls) == 2, TimeSpan.FromSeconds(5));
- // 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));
+ // This mailbox marker is handled after the failed ContactsLoaded handler returns.
+ // Unlike observing the provider call alone, it proves retry registration is complete.
+ actor.Tell(marker);
+ barrier.ExpectMsg(TimeSpan.FromSeconds(5)).Message.ShouldBeSameAs(marker);
- Thread.Sleep(2500);
+ local.EventFilter.Info(contains: "Known-cluster gate published").ExpectOne(
+ TimeSpan.FromSeconds(5), () => actor.Tell(new CentralCommunicationActor.RefreshContacts()));
+ loads.ExpectMsg(2, TimeSpan.FromSeconds(5));
+ // Cross the pending retry deadline, leaving the 60-second periodic tick untouched.
+ // LoadContactsFromDb uses Task.Run, so observe its boundary for the original absence
+ // budget rather than asserting before a mistakenly retained retry can start its read.
+ scheduler.Advance(TimeSpan.FromMilliseconds(2500));
+ loads.ExpectNoMsg(TimeSpan.FromMilliseconds(2500));
factory.Calls.ShouldBe(2);
psk.KnownClusterPublications.Count.ShouldBe(1);
}