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
185 lines
8.1 KiB
C#
185 lines
8.1 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.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Gitea #537 — the bridge that carries the continuous-historization <b>capture</b> verdict out of
|
|
/// the actor and into <see cref="IHistorizationRoleView"/>, where the recorder reads it per value.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Both nodes of a redundant pair serve the same address space and see the same dependency-mux
|
|
/// fan-out, so an ungated recorder writes every sample twice. Measured on the docker-dev central
|
|
/// pair against a real gateway: the write counter advanced twice per value, and halved when one
|
|
/// node was stopped.
|
|
/// </para>
|
|
/// <para>
|
|
/// These cases share the role rule with <see cref="DriverHostActorRoleViewTests"/> but differ on
|
|
/// which peer counts. The alarm queue replicates, so the relevant peer is whoever holds a copy
|
|
/// of this node's rows; historization is fleet-wide by historian tag name, so the relevant peer
|
|
/// is whoever records this node's refs — a partner in its own per-cluster mesh.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DriverHostActorHistorizationRoleViewTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-histview-test");
|
|
private static readonly NodeId MeshPeer = NodeId.Parse("mesh-peer:4053");
|
|
private static readonly NodeId ForeignNode = NodeId.Parse("other-cluster:4053");
|
|
private static readonly TimeSpan Timeout = PresenceBudget;
|
|
|
|
/// <summary>
|
|
/// Before any snapshot arrives the view must read open, not closed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A deployment that runs no redundancy never publishes here at all. Defaulting closed would
|
|
/// leave it with no trend data whatsoever and nothing to say why.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void An_unpublished_view_captures()
|
|
=> new RedundancyRoleView().ShouldRecordHistorizedValues.ShouldBeTrue();
|
|
|
|
/// <summary>A Secondary stands down for a Primary in its own mesh — the duplicate-write case.</summary>
|
|
[Fact]
|
|
public void Secondary_stands_down_for_a_primary_in_its_own_mesh()
|
|
{
|
|
var view = new RecordingHistorizationView();
|
|
SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot(
|
|
(TestNode, RedundancyRole.Secondary), (MeshPeer, RedundancyRole.Primary)));
|
|
|
|
AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Secondary whose Primary is in ANOTHER cluster keeps capturing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// That node runs a different configuration and records none of these tags, so standing down for
|
|
/// it would end this cluster's historization silently — the drain would simply report success on
|
|
/// an empty outbox forever. Same scope mismatch that once suspended the alarm drain fleet-wide.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void Secondary_keeps_capturing_when_the_primary_records_other_tags()
|
|
{
|
|
var view = new RecordingHistorizationView();
|
|
SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot(
|
|
(TestNode, RedundancyRole.Secondary), (ForeignNode, RedundancyRole.Primary)));
|
|
|
|
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>
|
|
/// With NO way to establish which peers share this node's address space, it never stands down.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The predicate is null on a node carrying no cluster-scoped role — the legacy, pre-Phase-6
|
|
/// shape, where the redundancy election is fleet-wide and the elected Primary usually belongs to
|
|
/// some other cluster. Duplicates are recoverable; a silent gap is not, so unproven means
|
|
/// capture. Delete the null check in the wiring and this is the case that goes red.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void An_unprovable_scope_never_stands_down()
|
|
{
|
|
var view = new RecordingHistorizationView();
|
|
SpawnHost(view, sharesScopeWith: null).Tell(Snapshot(
|
|
(TestNode, RedundancyRole.Secondary), (MeshPeer, RedundancyRole.Primary)));
|
|
|
|
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>Primary captures.</summary>
|
|
[Fact]
|
|
public void Primary_captures()
|
|
{
|
|
var view = new RecordingHistorizationView();
|
|
SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot(
|
|
(TestNode, RedundancyRole.Primary), (MeshPeer, RedundancyRole.Secondary)));
|
|
|
|
// Published, not current — `true` is also the seed, so asserting the property would pass before
|
|
// the actor had processed anything.
|
|
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unknown role captures — the snapshot never names this node, the documented identity-mismatch
|
|
/// shape.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Unknown_role_captures()
|
|
{
|
|
var view = new RecordingHistorizationView();
|
|
SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot(
|
|
(MeshPeer, RedundancyRole.Primary), (ForeignNode, RedundancyRole.Secondary)));
|
|
|
|
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>A promotion must MOVE the view, not merely leave it at its open seed.</summary>
|
|
[Fact]
|
|
public void A_promotion_reopens_a_closed_view()
|
|
{
|
|
var view = new RecordingHistorizationView();
|
|
IActorRef host = SpawnHost(view, sharesScopeWith: MeshPeer);
|
|
|
|
host.Tell(Snapshot((TestNode, RedundancyRole.Secondary), (MeshPeer, RedundancyRole.Primary)));
|
|
AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
|
|
|
|
host.Tell(Snapshot((TestNode, RedundancyRole.Primary), (MeshPeer, RedundancyRole.Secondary)));
|
|
AwaitAssert(() => view.Published.ShouldBe([false, true]), duration: Timeout);
|
|
}
|
|
|
|
// ---------------- helpers ----------------
|
|
|
|
/// <summary>
|
|
/// A view that remembers every published decision, so a test asserts on the actor's ACTION
|
|
/// rather than on a state its open seed already satisfies.
|
|
/// </summary>
|
|
private sealed class RecordingHistorizationView : IHistorizationRoleView
|
|
{
|
|
private readonly List<bool> _published = [];
|
|
private readonly Lock _gate = new();
|
|
private readonly RedundancyRoleView _inner = new();
|
|
|
|
/// <summary>Every value published so far, in order.</summary>
|
|
public IReadOnlyList<bool> Published
|
|
{
|
|
get { lock (_gate) return _published.ToArray(); }
|
|
}
|
|
|
|
public bool ShouldRecordHistorizedValues => _inner.ShouldRecordHistorizedValues;
|
|
|
|
public void PublishHistorizationRole(bool shouldRecordHistorizedValues)
|
|
{
|
|
lock (_gate) _published.Add(shouldRecordHistorizedValues);
|
|
_inner.PublishHistorizationRole(shouldRecordHistorizedValues);
|
|
}
|
|
}
|
|
|
|
private IActorRef SpawnHost(IHistorizationRoleView view, NodeId? sharesScopeWith) =>
|
|
Sys.ActorOf(DriverHostActor.Props(
|
|
NewInMemoryDbFactory(),
|
|
TestNode,
|
|
ackRouter: null,
|
|
driverMemberCountProvider: () => 2,
|
|
historizationRoleView: view,
|
|
sharesHistorizationScopeWithLocal:
|
|
sharesScopeWith is { } peer ? node => node == peer : null));
|
|
|
|
private static RedundancyStateChanged Snapshot(params (NodeId Node, RedundancyRole Role)[] nodes) =>
|
|
new(
|
|
nodes.Select(n => new NodeRedundancyState(
|
|
n.Node,
|
|
n.Role,
|
|
IsClusterLeader: n.Role == RedundancyRole.Primary,
|
|
IsDriverPrimary: n.Role == RedundancyRole.Primary,
|
|
AsOfUtc: DateTime.UtcNow))
|
|
.ToList(),
|
|
CorrelationId.NewId());
|
|
}
|