From c86c7cbf35c5f023e17133fd59201bd1d1a7c4f0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 15:28:58 -0500 Subject: [PATCH] test: shared actor test bases poll AwaitCondition every 20 ms, not max/10 (lmxopcua#775) Akka TestKit's AwaitCondition without an interval polls every max/10 of the DILATED budget. Measured here: a condition that turned true at 2 s under a 15 s budget returned after 3003 ms at timefactor 1 (3 polls) and 4502 ms at timefactor 3 (2 polls). AwaitAssert / AwaitAssertAsync are unaffected (a fixed 100 ms: 21 polls in ~2 s at both factors). RuntimeActorTestBase and ControlPlaneActorTestBase now declare AwaitCondition overloads that hide TestKit's and default the interval to PollInterval (20 ms), so every subclass call without an interval binds to them (measured after: returns 5-22 ms after the condition holds, at both factors). PresenceBudget's doc no longer claims the helpers return "the instant the condition holds" - it states the per-helper poll behaviour. Bare-TestKit classes with AwaitCondition calls get an explicit interval: TelemetryDialDeadlineTests / TelemetryDialSupervisorTests (next to their own undilated 5 s / 8 s WaitForCount deadlines and a 1 s dial deadline) and OpcUaEndpointStepDownActorTests (RuntimeActorTestBase.PollInterval, now public like PresenceBudget). ContinuousHistorizationRecorderTypedValueTests' AwaitConditionAsync was left alone (historian code is being changed in parallel). Suite results (Release, 0 warnings): - Runtime.Tests 946/3 skipped/0 failed at timefactor 3 (1 m 45 s, was 2 m 57 s) and 1 (1 m 18 s, was 2 m 57 s); - ControlPlane.Tests 319/0 failed at 3 (1 m 51 s) and 1 (1 m 11 s); - AdminUI.Tests Telemetry 15/0 failed at 3 and 1. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- .../Telemetry/TelemetryDialDeadlineTests.cs | 8 +++- .../Telemetry/TelemetryDialSupervisorTests.cs | 10 +++-- .../Harness/ControlPlaneTestHarness.cs | 39 ++++++++++++++++- .../Harness/RuntimeActorTestBase.cs | 43 ++++++++++++++++++- .../OpcUaEndpointStepDownActorTests.cs | 8 ++-- 5 files changed, 97 insertions(+), 11 deletions(-) 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.