diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs index 7b0c8ed5..92fa44de 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs @@ -23,6 +23,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Telemetry; /// public sealed class TelemetryDialDeadlineTests : TestKit { + /// Explicit AwaitCondition poll interval: Akka's default is max / 10 of the dilated budget, i.e. + /// seconds under CI's timefactor (lmxopcua#775). + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8); [Fact] @@ -35,7 +39,7 @@ public sealed class TelemetryDialDeadlineTests : TestKit first.Ct.IsCancellationRequested.ShouldBeFalse("the deadline has not elapsed yet"); // The stalled dial is cut at the deadline... - AwaitCondition(() => first.Ct.IsCancellationRequested, Timeout); + AwaitCondition(() => first.Ct.IsCancellationRequested, Timeout, PollInterval); // ...and the supervisor re-dials (a stalled dial is a transport failure, never a silent end). var invocations = dial.WaitForCount(2, Timeout); invocations[1].CorrelationId.ShouldBe("central-node-1-2"); @@ -49,7 +53,7 @@ public sealed class TelemetryDialDeadlineTests : TestKit Spawn(dial, callTimeoutSeconds: 1, sinks); var first = dial.WaitForCount(1, Timeout).Single(); - AwaitCondition(() => sinks.HealthStore.Upserts.Count == 1, Timeout); + AwaitCondition(() => sinks.HealthStore.Upserts.Count == 1, Timeout, PollInterval); // Positive control: well past the deadline, the established stream is untouched. await Task.Delay(TimeSpan.FromSeconds(2.5)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs index f07fd6d3..31a8f33e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs @@ -23,6 +23,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Telemetry; /// public sealed class TelemetryDialSupervisorTests : TestKit { + /// Explicit AwaitCondition poll interval: Akka's default is max / 10 of the dilated budget, i.e. + /// seconds under CI's timefactor (lmxopcua#775). + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); [Fact] @@ -66,7 +70,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit actor.Tell(new TelemetryDialSupervisor.RefreshNodes()); // node-1's dialer is cancelled and dropped. - AwaitCondition(() => node1.Ct.IsCancellationRequested, Timeout); + AwaitCondition(() => node1.Ct.IsCancellationRequested, Timeout, PollInterval); // node-3 gets a fresh dialer. var invocations = dial.WaitForCount(3, Timeout); invocations.ShouldContain(i => i.Target.NodeId == "node-3" && i.CorrelationId == "central-node-3-1"); @@ -98,7 +102,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit actor.Tell(new TelemetryDialSupervisor.RefreshNodes()); // The old node-1 stream is torn down and a fresh dialer opens at the NEW endpoint (gen 2). - AwaitCondition(() => node1Original.Ct.IsCancellationRequested, Timeout); + AwaitCondition(() => node1Original.Ct.IsCancellationRequested, Timeout, PollInterval); var invocations = dial.WaitForCount(3, Timeout); invocations.ShouldContain(i => i.Target.NodeId == "node-1" @@ -246,7 +250,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit Sys.Stop(actor); - AwaitCondition(() => inv.All(i => i.Ct.IsCancellationRequested), Timeout); + AwaitCondition(() => inv.All(i => i.Ct.IsCancellationRequested), Timeout, PollInterval); } [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs index aba03afd..d98828a0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs @@ -1,4 +1,5 @@ using Akka.Cluster; +using Akka.TestKit; using Akka.TestKit.Xunit2; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; @@ -21,7 +22,9 @@ public abstract class ControlPlaneActorTestBase : TestKit /// AwaitAssert) — the same doctrine as RuntimeActorTestBase.PresenceBudget. /// /// - /// A budget, not a delay: the helpers return the instant the condition holds, so raising it costs + /// A budget, not a delay: the helpers return within one poll interval of the condition holding + /// (AwaitCondition polls every through this class's overloads, not Akka's + /// max / 10 of the dilated budget — lmxopcua#775), so raising it costs /// nothing on the happy path and cannot make a genuinely failing assertion pass. Several actors here /// do their first EF Core (InMemory) work inside the waited-for step — model build is a multi-second /// cold cost on a loaded CI runner — and 3–5 s budgets sized on an idle machine failed there (#606). @@ -29,6 +32,40 @@ public abstract class ControlPlaneActorTestBase : TestKit /// protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15); + /// + /// Poll interval for every AwaitCondition in this class's subclasses that does not pass its own + /// (lmxopcua#775). Akka's own default is max / 10 of the DILATED budget — 1.5 s for a 15 s budget + /// locally, 4.5 s under CI's AKKA_TEST_TIMEFACTOR=3 — so a wait could overshoot the moment its + /// condition held by seconds. That wasted CI time everywhere, and failed tests where an undilated + /// deadline in the code under test elapsed during the overshoot (lmxopcua 342a825d). AwaitAssert + /// is unaffected: its default interval is a fixed 100 ms. + /// + protected static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20); + + /// + /// with + /// defaulting to instead of Akka's max / 10. + /// Hides the TestKit overloads, so every subclass call without an interval binds here. + /// + /// The condition to poll. + /// The budget (dilated by Akka); null takes the enclosing Within or the default. + /// The poll interval; null means . + /// The failure message. + /// Cancels the wait. + protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max = null, TimeSpan? interval = null, + string? message = null, CancellationToken cancellationToken = default) => + base.AwaitCondition(conditionIsFulfilled, max, interval ?? PollInterval, message!, cancellationToken); + + /// The message-carrying overload of , + /// polled every . + /// The condition to poll. + /// The budget (dilated by Akka). + /// The failure message. + /// Cancels the wait. + protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max, string message, + CancellationToken cancellationToken = default) => + base.AwaitCondition(conditionIsFulfilled, max, PollInterval, message, cancellationToken); + /// Gets the Akka configuration for test actors, including cluster and pub-sub extensions. /// /// akka.test.single-expect-default is raised from Akka's 3 s to : diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs index 3e52f0e0..8f130035 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs @@ -1,4 +1,5 @@ using Akka.Cluster; +using Akka.TestKit; using Akka.TestKit.Xunit2; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; @@ -15,8 +16,11 @@ public abstract class RuntimeActorTestBase : TestKit /// Shared upper bound for a presence wait (AwaitAssert / AwaitCondition). /// /// - /// A budget, not a delay. These helpers poll and return the instant the condition holds, - /// so the value is how long to wait before giving up. Raising it costs nothing on the happy path and + /// A budget, not a delay. These helpers poll and return within one poll interval of the + /// condition holding (ExpectMsg the instant the message arrives; AwaitAssert every 100 ms, + /// Akka's default; AwaitCondition every through this class's overloads — + /// Akka's own default there is max / 10 of the dilated budget, 4.5 s in CI, lmxopcua#775), so the + /// value is how long to wait before giving up. Raising it costs nothing on the happy path and /// cannot make a genuinely failing assertion pass — it only changes how quickly a real /// breakage is reported. That is the opposite of an absence window (ExpectNoMsg, /// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on @@ -33,6 +37,41 @@ public abstract class RuntimeActorTestBase : TestKit /// public static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15); + /// + /// Poll interval for every AwaitCondition in this class's subclasses that does not pass its own + /// (lmxopcua#775). Akka's own default is max / 10 of the DILATED budget — 1.5 s for a 15 s budget + /// locally, 4.5 s under CI's AKKA_TEST_TIMEFACTOR=3 — so a wait could overshoot the moment its + /// condition held by seconds. That wasted CI time everywhere, and failed tests where an undilated + /// deadline in the code under test elapsed during the overshoot (lmxopcua 342a825d). AwaitAssert + /// is unaffected: its default interval is a fixed 100 ms. Public, like , for + /// test classes that are not subclasses (a bare ) to pass explicitly. + /// + public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20); + + /// + /// with + /// defaulting to instead of Akka's max / 10. + /// Hides the TestKit overloads, so every subclass call without an interval binds here. + /// + /// The condition to poll. + /// The budget (dilated by Akka); null takes the enclosing Within or the default. + /// The poll interval; null means . + /// The failure message. + /// Cancels the wait. + protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max = null, TimeSpan? interval = null, + string? message = null, CancellationToken cancellationToken = default) => + base.AwaitCondition(conditionIsFulfilled, max, interval ?? PollInterval, message!, cancellationToken); + + /// The message-carrying overload of , + /// polled every . + /// The condition to poll. + /// The budget (dilated by Akka). + /// The failure message. + /// Cancels the wait. + protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max, string message, + CancellationToken cancellationToken = default) => + base.AwaitCondition(conditionIsFulfilled, max, PollInterval, message, cancellationToken); + /// Gets the Akka test HOCON configuration string for single-node cluster setup. /// /// akka.test.single-expect-default is raised from Akka's 3 s to diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs index 2ac05597..aa2ea86a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs @@ -56,11 +56,13 @@ akka { _nodeA = new TestKit(Hocon + TestTimeFactor.Hocon); var clusterA = Akka.Cluster.Cluster.Get(_nodeA.Sys); clusterA.Join(clusterA.SelfAddress); - AwaitCondition(() => UpCount(clusterA) == 1, RuntimeActorTestBase.PresenceBudget); + AwaitCondition(() => UpCount(clusterA) == 1, RuntimeActorTestBase.PresenceBudget, + RuntimeActorTestBase.PollInterval); var clusterB = Akka.Cluster.Cluster.Get(Sys); clusterB.Join(clusterA.SelfAddress); - AwaitCondition(() => UpCount(clusterA) == 2 && UpCount(clusterB) == 2, RuntimeActorTestBase.PresenceBudget); + AwaitCondition(() => UpCount(clusterA) == 2 && UpCount(clusterB) == 2, RuntimeActorTestBase.PresenceBudget, + RuntimeActorTestBase.PollInterval); // Fixture sanity: A must be the Primary going in, or every assertion below is about the wrong node. ClusterActiveNode.SelfIsActive(clusterA, RoleParser.Driver).ShouldBeTrue("node A formed the cluster, so it is the oldest Up driver member"); @@ -100,7 +102,7 @@ akka { var clusterB = Akka.Cluster.Cluster.Get(Sys); AwaitCondition(() => ClusterActiveNode.SelfIsActive(clusterB, RoleParser.Driver) && clusterB.State.Members.Count(m => m.Status == MemberStatus.Up) == 1, - RuntimeActorTestBase.PresenceBudget); + RuntimeActorTestBase.PresenceBudget, RuntimeActorTestBase.PollInterval); } /// A failed endpoint on the SECONDARY changes nothing: there is no role to hand over.