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; /// /// The bridge that carries the Primary-gate decision out of the actor and into /// , where the alarm store-and-forward drain can read it. /// /// /// /// The drain runs on a timer owned by the sink, not on a mailbox, so it cannot receive /// itself — but it must be Primary-scoped, because /// the queue it drains replicates to the pair peer and two draining nodes would deliver /// every alarm event twice. /// /// /// These cases deliberately DIVERGE from in the /// unknown-role case: the write gate denies when a peer may exist, this one allows. A duplicate /// history row is cheap and already contemplated by the at-least-once contract; a silently /// un-drained queue is not. See AlarmHistoryDrainGatePolicyTests. /// /// public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-roleview-test"); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); /// /// Before any snapshot arrives the view must read open, not closed. /// /// /// A deployment that runs no redundancy never publishes here at all. Defaulting closed /// would silently stop its alarm history forever — the exact failure the drain gate exists /// to avoid causing. /// [Fact] public void An_unpublished_view_reads_as_primary() { new RedundancyRoleView().ShouldDrainAlarmHistory.ShouldBeTrue(); } /// A Secondary closes the view — but only because the snapshot also names a Primary. [Fact] public void Secondary_role_closes_the_view_when_a_primary_exists() { var view = SpawnAndTellRole(RedundancyRole.Secondary, driverMemberCount: 2); AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout); } /// /// A Secondary whose Primary is some OTHER pair's node keeps draining: that node holds none of /// this node's rows, so deferring to it would deliver them never. /// [Fact] public void Secondary_role_keeps_the_view_open_when_the_primary_is_not_its_peer() { var view = new RecordingRoleView(); SpawnHost(view, driverMemberCount: 4).Tell(new RedundancyStateChanged( [ new NodeRedundancyState(TestNode, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), // A Primary DOES exist — in another pair. It is not this node's replication peer. new NodeRedundancyState(NodeId.Parse("some-other-pair:4053"), RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow), ], CorrelationId.NewId())); AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); } [Fact] public void Primary_role_opens_the_view() { var view = SpawnAndTellRole(RedundancyRole.Primary, driverMemberCount: 2); // Published, not current — `true` is the seed. See the note in the unknown-role theory. AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); } /// /// Unknown role ⇒ the view stays open, even with driver peers present — the deliberate /// divergence from the write gate's default-DENY. /// /// /// /// This case previously asserted the opposite, on the stated goal of mirroring the write /// gate. The Phase 2 live gate showed that goal to be wrong for this consumer: the rig runs /// four driver nodes in one cluster with no Redundancy section, so every role was /// unknown and the driver count was 4 — and every node logged "Historian drain suspended", /// including the two unpaired site-b nodes with no peer and no replication. Alarm history /// drained nowhere, where before Phase 2 it drained fine. /// /// /// See AlarmHistoryDrainGatePolicyTests for why the asymmetry of the two error costs /// makes fail-open correct here and fail-closed correct for device writes. /// /// [Theory] [InlineData(1)] [InlineData(2)] [InlineData(4)] public void Unknown_role_leaves_the_view_open_whatever_the_peer_count(int driverMemberCount) { // A snapshot that never names this node is how the role stays unknown in practice — the // documented identity-mismatch shape, not a contrived case. var view = SpawnAndTellForeignSnapshot(driverMemberCount); // Assert on what was PUBLISHED, never on the current value. `true` is also the seeded // default, so `AwaitAssert(() => view.ShouldDrainAlarmHistory.ShouldBeTrue())` passes at the // first poll — before the actor has processed the snapshot — and therefore passes just as // happily when the publish is wrong or absent. Verified: with the old write-gate verdict // restored, the current-value form stayed green and only this form goes red. AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); } /// A demotion must move the view, not just an initial promotion. [Fact] public void A_demotion_closes_a_previously_open_view() { var view = new RecordingRoleView(); var host = SpawnHost(view, driverMemberCount: 2); host.Tell(Snapshot(TestNode, RedundancyRole.Primary)); AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); host.Tell(Snapshot(TestNode, RedundancyRole.Secondary)); AwaitAssert(() => view.Published.ShouldBe([true, false]), duration: Timeout); } // ---------------- helpers ---------------- /// /// A view that remembers every published decision, so a test can assert on the actor's /// action rather than on a state the seed already satisfies. /// private sealed class RecordingRoleView : IRedundancyRoleView { private readonly List _published = []; private readonly Lock _gate = new(); private readonly RedundancyRoleView _inner = new(); /// Every value published so far, in order. public IReadOnlyList Published { get { lock (_gate) return _published.ToArray(); } } public bool ShouldDrainAlarmHistory => _inner.ShouldDrainAlarmHistory; public void Publish(bool shouldDrainAlarmHistory) { lock (_gate) _published.Add(shouldDrainAlarmHistory); _inner.Publish(shouldDrainAlarmHistory); } } private RecordingRoleView SpawnAndTellRole(RedundancyRole role, int driverMemberCount) { var view = new RecordingRoleView(); SpawnHost(view, driverMemberCount).Tell(Snapshot(TestNode, role)); return view; } private RecordingRoleView SpawnAndTellForeignSnapshot(int driverMemberCount) { var view = new RecordingRoleView(); SpawnHost(view, driverMemberCount).Tell(Snapshot(NodeId.Parse("some-other-node"), RedundancyRole.Primary)); return view; } /// The peer this node replicates with — the only node it may ever stand down for. private const string PeerHost = "the-peer"; private IActorRef SpawnHost(IRedundancyRoleView view, int driverMemberCount) => Sys.ActorOf(DriverHostActor.Props( NewInMemoryDbFactory(), TestNode, coordinator: null, driverMemberCountProvider: () => driverMemberCount, redundancyRoleView: view, replicationPeerHost: PeerHost)); /// /// A snapshot of a real pair: this node in , plus a peer holding the /// other half. The peer matters — a Secondary only stands down when a Primary exists, so a /// single-entry snapshot would silently change what these cases test. /// private static RedundancyStateChanged Snapshot(NodeId node, RedundancyRole role) => new( [ new NodeRedundancyState(node, role, IsClusterLeader: role == RedundancyRole.Primary, IsRoleLeaderForDriver: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), new NodeRedundancyState( NodeId.Parse($"{PeerHost}:4053"), role == RedundancyRole.Primary ? RedundancyRole.Secondary : RedundancyRole.Primary, IsClusterLeader: role != RedundancyRole.Primary, IsRoleLeaderForDriver: role != RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), ], CorrelationId.NewId()); }