c86c7cbf35
v2-ci / unit-tests (push) Has been cancelled
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
160 lines
9.1 KiB
C#
160 lines
9.1 KiB
C#
using Akka.Cluster;
|
||
using Akka.TestKit;
|
||
using Akka.TestKit.Xunit2;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||
|
||
/// <summary>
|
||
/// Mirrors the ControlPlane test harness: single-node Akka cluster (self-join) + in-memory
|
||
/// EF Core <see cref="OtOpcUaConfigDbContext"/> via an <see cref="IDbContextFactory{TContext}"/>.
|
||
/// </summary>
|
||
public abstract class RuntimeActorTestBase : TestKit
|
||
{
|
||
/// <summary>
|
||
/// Shared upper bound for a <b>presence</b> wait (<c>AwaitAssert</c> / <c>AwaitCondition</c>).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para><b>A budget, not a delay.</b> These helpers poll and return within one poll interval of the
|
||
/// condition holding (<c>ExpectMsg</c> the instant the message arrives; <c>AwaitAssert</c> every 100 ms,
|
||
/// Akka's default; <c>AwaitCondition</c> every <see cref="PollInterval"/> through this class's overloads —
|
||
/// Akka's own default there is <c>max / 10</c> 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
|
||
/// <em>cannot</em> make a genuinely failing assertion pass — it only changes how quickly a real
|
||
/// breakage is reported. That is the opposite of an <b>absence</b> window (<c>ExpectNoMsg</c>,
|
||
/// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on
|
||
/// every run. Absence windows keep their own short, individually-calibrated literals and must not be
|
||
/// routed through here.</para>
|
||
/// <para><b>Why it exists (Gitea #500).</b> This assembly runs 44 Akka test classes roughly 14-way
|
||
/// parallel, each with its own <see cref="TestKit"/> ActorSystem. Budgets of 300–500 ms sized on an
|
||
/// idle machine are comfortable in isolation and marginal under that contention: three consecutive
|
||
/// 30-run verification rounds each surfaced a <em>different</em> test failing on one, in three
|
||
/// different files. Fixing them one at a time was chasing a distribution rather than a defect.</para>
|
||
/// <para>Public rather than protected so Harness helpers that are not themselves
|
||
/// <see cref="TestKit"/> subclasses — <see cref="V3ArtifactSeeder"/> — budget their waits from the
|
||
/// same constant instead of re-deriving one.</para>
|
||
/// </remarks>
|
||
public static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
|
||
|
||
/// <summary>
|
||
/// Poll interval for every <c>AwaitCondition</c> in this class's subclasses that does not pass its own
|
||
/// (lmxopcua#775). Akka's own default is <c>max / 10</c> of the DILATED budget — 1.5 s for a 15 s budget
|
||
/// locally, 4.5 s under CI's <c>AKKA_TEST_TIMEFACTOR=3</c> — 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). <c>AwaitAssert</c>
|
||
/// is unaffected: its default interval is a fixed 100 ms. Public, like <see cref="PresenceBudget"/>, for
|
||
/// test classes that are not subclasses (a bare <see cref="TestKit"/>) to pass explicitly.
|
||
/// </summary>
|
||
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20);
|
||
|
||
/// <summary>
|
||
/// <see cref="TestKitBase.AwaitCondition(Func{bool}, TimeSpan?, TimeSpan?, string, CancellationToken)"/> with
|
||
/// <paramref name="interval"/> defaulting to <see cref="PollInterval"/> instead of Akka's <c>max / 10</c>.
|
||
/// Hides the TestKit overloads, so every subclass call without an interval binds here.
|
||
/// </summary>
|
||
/// <param name="conditionIsFulfilled">The condition to poll.</param>
|
||
/// <param name="max">The budget (dilated by Akka); null takes the enclosing <c>Within</c> or the default.</param>
|
||
/// <param name="interval">The poll interval; null means <see cref="PollInterval"/>.</param>
|
||
/// <param name="message">The failure message.</param>
|
||
/// <param name="cancellationToken">Cancels the wait.</param>
|
||
protected new void AwaitCondition(Func<bool> conditionIsFulfilled, TimeSpan? max = null, TimeSpan? interval = null,
|
||
string? message = null, CancellationToken cancellationToken = default) =>
|
||
base.AwaitCondition(conditionIsFulfilled, max, interval ?? PollInterval, message!, cancellationToken);
|
||
|
||
/// <summary>The message-carrying overload of <see cref="AwaitCondition(Func{bool}, TimeSpan?, TimeSpan?, string?, CancellationToken)"/>,
|
||
/// polled every <see cref="PollInterval"/>.</summary>
|
||
/// <param name="conditionIsFulfilled">The condition to poll.</param>
|
||
/// <param name="max">The budget (dilated by Akka).</param>
|
||
/// <param name="message">The failure message.</param>
|
||
/// <param name="cancellationToken">Cancels the wait.</param>
|
||
protected new void AwaitCondition(Func<bool> conditionIsFulfilled, TimeSpan? max, string message,
|
||
CancellationToken cancellationToken = default) =>
|
||
base.AwaitCondition(conditionIsFulfilled, max, PollInterval, message, cancellationToken);
|
||
|
||
/// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary>
|
||
/// <remarks>
|
||
/// <c>akka.test.single-expect-default</c> is raised from Akka's 3 s to <see cref="PresenceBudget"/>
|
||
/// (#606): it is the budget of every duration-less <c>ExpectMsg</c>/<c>ExpectTerminated</c>/
|
||
/// <c>AwaitAssert</c>, all presence waits. No test here calls a duration-less <c>ExpectNoMsg()</c>,
|
||
/// the one absence wait that would also read it.
|
||
/// <para><c>akka.test.timefactor</c> comes from <see cref="TestTimeFactor"/>.</para>
|
||
/// </remarks>
|
||
protected static string AkkaTestHocon => BaseHocon + TestTimeFactor.Hocon;
|
||
|
||
private const string BaseHocon = @"
|
||
akka {
|
||
loglevel = ""WARNING""
|
||
test.single-expect-default = 15s
|
||
extensions = [
|
||
""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools""
|
||
]
|
||
actor {
|
||
provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
|
||
}
|
||
remote.dot-netty.tcp {
|
||
hostname = ""127.0.0.1""
|
||
port = 0
|
||
}
|
||
cluster {
|
||
seed-nodes = []
|
||
roles = [""driver""]
|
||
min-nr-of-members = 1
|
||
run-coordinated-shutdown-when-down = off
|
||
}
|
||
}";
|
||
|
||
/// <summary>
|
||
/// The same single-node HOCON at a caller-chosen <c>loglevel</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A log event below <c>akka.loglevel</c> is never published to the EventStream at all, so
|
||
/// neither <c>EventFilter</c> nor an EventStream-subscribed probe can see it. A test whose
|
||
/// subject IS a log line (e.g. "this Debug line must be emitted exactly once, not in a spin")
|
||
/// therefore has to raise the level for its own ActorSystem; the default stays
|
||
/// <c>WARNING</c> so the other 40-odd classes keep their quiet output.
|
||
/// </remarks>
|
||
/// <param name="level">An Akka log level — <c>DEBUG</c>, <c>INFO</c>, <c>WARNING</c>, <c>ERROR</c>.</param>
|
||
/// <returns>The base test HOCON with its <c>loglevel</c> replaced.</returns>
|
||
protected static string AkkaTestHoconAtLevel(string level) =>
|
||
AkkaTestHocon.Replace(@"loglevel = ""WARNING""", $@"loglevel = ""{level}""", StringComparison.Ordinal);
|
||
|
||
/// <summary>Initializes a new instance of the RuntimeActorTestBase class with Akka test setup.</summary>
|
||
protected RuntimeActorTestBase() : this(AkkaTestHocon)
|
||
{
|
||
}
|
||
|
||
/// <summary>Initializes a new instance of the RuntimeActorTestBase class with caller-supplied HOCON.</summary>
|
||
/// <param name="hocon">The Akka configuration for this class's ActorSystem — usually
|
||
/// <see cref="AkkaTestHoconAtLevel"/>.</param>
|
||
protected RuntimeActorTestBase(string hocon) : base(hocon)
|
||
{
|
||
var cluster = Akka.Cluster.Cluster.Get(Sys);
|
||
cluster.Join(cluster.SelfAddress);
|
||
AwaitCondition(() => cluster.State.Members.Any(m => m.Status == MemberStatus.Up),
|
||
TimeSpan.FromSeconds(5));
|
||
}
|
||
|
||
/// <summary>Creates a new in-memory database factory for testing.</summary>
|
||
/// <param name="dbName">The database name, or null to generate a unique name.</param>
|
||
/// <returns>An IDbContextFactory for the in-memory database.</returns>
|
||
protected static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory(string? dbName = null)
|
||
{
|
||
dbName ??= Guid.NewGuid().ToString("N");
|
||
return new InMemoryConfigDbFactory(dbName);
|
||
}
|
||
|
||
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||
{
|
||
/// <summary>Creates a new in-memory database context.</summary>
|
||
/// <returns>A new OtOpcUaConfigDbContext instance.</returns>
|
||
public OtOpcUaConfigDbContext CreateDbContext()
|
||
{
|
||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||
.UseInMemoryDatabase(dbName)
|
||
.Options;
|
||
return new OtOpcUaConfigDbContext(opts);
|
||
}
|
||
}
|
||
}
|