Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryRoleSourceTests.cs
T
Joseph Doherty 55ae230fa4 ci(#606): Gitea-compatible trx upload + deterministic fixes for the CI-load test flakes
- 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
2026-10-08 19:44:31 -04:00

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);
}
}
}