55ae230fa4
- v2-ci.yml: upload-artifact@v4 -> @v3 + continue-on-error (act_runner reports as GHES and v4 refuses GHES, failing every run); runner-capacity diagnostic step; AKKA_TEST_TIMEFACTOR=3 on the test step. - Runtime/ControlPlane harnesses: akka.test.timefactor from AKKA_TEST_TIMEFACTOR (TestTimeFactor), single-expect-default 15s; ControlPlane gains PresenceBudget. - Leaked spinner threads: the infinite-loop timeout tests (Calculation, Host.IntegrationTests RoslynVirtualTagEvaluator, 3x Core.Scripting) pinned a core for the rest of the test process (abandoned, never stopped). Now a bounded 5 s wall-clock spin; outer compile-inclusive bound 30 s. - Real test races fixed: ScriptedAlarmEngine push-during-drain (Inactive is also the start state + persist-before-update => wait for the Cleared save); DriverReconnectE2e (both nodes host the driver; Created[] is last-writer-wins and ForceReconnect is a no-op while Connecting); ControlPlaneStarterOrder (lost-race heal waited for a 60 s refresh tick). - Fixed sleeps -> poll-for-condition: ReconnectSupervisor LastError, Modbus deadband steps (wait for observed reads), Modbus dispose hygiene precondition, FOCAS probe-loop log. - Short presence budgets -> PresenceBudget in the Runtime/ControlPlane tests seen failing on Gitea; Calculation Settle 10 -> 30 s. No assertion removed, nothing skipped. - infra/README.md §10 CI; stale TreatWarningsAsErrors comment in Directory.Build.props. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
135 lines
6.0 KiB
C#
135 lines
6.0 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Review R1 m2 (Gitea #597) — the Primary data-plane verdict published to <see cref="IDriverPrimaryRoleSource"/>
|
|
/// (read by the native-ack gate and the Sparkplug primary-host claim) mirrors the host's own live gate from the
|
|
/// moment the host starts, not only from the first redundancy snapshot.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>RedundancyRoleView</c> is seeded with the single-node verdict (allow). Before this fix nothing published to
|
|
/// it until a snapshot arrived, so on a pair both nodes accepted a native ack through the whole boot window while
|
|
/// <c>DriverHostActor</c> — which evaluates the driver member count live — denied the upstream route on the
|
|
/// non-Primary. Assertions are on what was PUBLISHED: the seed already reads <c>true</c>, so a current-value
|
|
/// assertion of the single-node case would pass with no publish at all.
|
|
/// </remarks>
|
|
public sealed class DriverHostActorPrimaryRoleSourceTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-primary-source-test");
|
|
private static readonly TimeSpan Timeout = PresenceBudget;
|
|
|
|
/// <summary>On a pair, an unknown role publishes DENY at start — before any snapshot. The later Primary snapshot
|
|
/// is the positive control: the source is wired, and the deny was the verdict, not a missing publish.</summary>
|
|
[Fact]
|
|
public void A_paired_host_publishes_deny_before_the_first_snapshot()
|
|
{
|
|
var source = new RecordingPrimaryRoleSource();
|
|
var host = SpawnHost(source, () => 2);
|
|
|
|
AwaitAssert(() => source.Published.ShouldBe([false]), duration: Timeout);
|
|
|
|
host.Tell(Snapshot(RedundancyRole.Primary));
|
|
AwaitAssert(() => source.Published.ShouldBe([false, true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>A single-driver node keeps the allow posture — it is published, not merely seeded.</summary>
|
|
[Fact]
|
|
public void A_single_node_host_publishes_allow_at_start()
|
|
{
|
|
var source = new RecordingPrimaryRoleSource();
|
|
SpawnHost(source, () => 1);
|
|
|
|
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>A driver peer coming Up while the role is still unknown flips the published verdict to deny, with no
|
|
/// snapshot involved — the membership re-publish path.</summary>
|
|
[Fact]
|
|
public void A_peer_joining_before_any_snapshot_flips_the_published_verdict_to_deny()
|
|
{
|
|
var source = new RecordingPrimaryRoleSource();
|
|
var members = 1;
|
|
var host = SpawnHost(source, () => Volatile.Read(ref members));
|
|
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
|
|
|
|
Volatile.Write(ref members, 2);
|
|
host.Tell(DriverHostActor.RepublishPrimaryVerdict.Instance);
|
|
|
|
AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review R6 MINOR-3 — the same flip driven by a real cluster MEMBER EVENT rather than a direct
|
|
/// <see cref="DriverHostActor.RepublishPrimaryVerdict"/> tell, so it fails if the host's
|
|
/// <c>Cluster.Subscribe(… IMemberEvent)</c> or its <c>IMemberEvent</c> handler is deleted. The event is published
|
|
/// on the ActorSystem EventStream, which is exactly how the cluster's domain-event publisher delivers it to a
|
|
/// <c>Cluster.Subscribe</c> subscriber — and to nothing else. Re-published inside the wait because the
|
|
/// subscription is registered asynchronously by the publisher actor after <c>PreStart</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_cluster_member_event_flips_the_published_verdict_without_a_direct_republish()
|
|
{
|
|
var source = new RecordingPrimaryRoleSource();
|
|
var members = 1;
|
|
SpawnHost(source, () => Volatile.Read(ref members));
|
|
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
|
|
|
|
Volatile.Write(ref members, 2);
|
|
var memberUp = new Akka.Cluster.ClusterEvent.MemberUp(Akka.Cluster.Cluster.Get(Sys).SelfMember);
|
|
AwaitAssert(() =>
|
|
{
|
|
Sys.EventStream.Publish(memberUp);
|
|
source.Published.ShouldBe([true, false]);
|
|
}, duration: Timeout, interval: TimeSpan.FromMilliseconds(100));
|
|
}
|
|
|
|
private IActorRef SpawnHost(IDriverPrimaryRoleSource source, Func<int> driverMemberCount) =>
|
|
Sys.ActorOf(DriverHostActor.Props(
|
|
NewInMemoryDbFactory(),
|
|
TestNode,
|
|
ackRouter: null,
|
|
driverMemberCountProvider: driverMemberCount,
|
|
primaryRoleSource: source));
|
|
|
|
private static RedundancyStateChanged Snapshot(RedundancyRole role) =>
|
|
new(
|
|
[
|
|
new NodeRedundancyState(TestNode, role,
|
|
IsClusterLeader: role == RedundancyRole.Primary,
|
|
IsDriverPrimary: role == RedundancyRole.Primary,
|
|
AsOfUtc: DateTime.UtcNow),
|
|
],
|
|
CorrelationId.NewId());
|
|
|
|
/// <summary>Remembers every published verdict, so tests assert on the actor's action, not on the seed.</summary>
|
|
private sealed class RecordingPrimaryRoleSource : IDriverPrimaryRoleSource
|
|
{
|
|
private readonly List<bool> _published = [];
|
|
private readonly Lock _gate = new();
|
|
|
|
/// <summary>Every value published so far, in order.</summary>
|
|
public IReadOnlyList<bool> Published
|
|
{
|
|
get { lock (_gate) return _published.ToArray(); }
|
|
}
|
|
|
|
public bool ShouldActAsPrimary
|
|
{
|
|
get { lock (_gate) return _published.Count == 0 || _published[^1]; }
|
|
}
|
|
|
|
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
|
{
|
|
lock (_gate) _published.Add(shouldActAsPrimary);
|
|
}
|
|
}
|
|
}
|