using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// archreview 03/S4 — the single Primary data-plane gate decision. A KNOWN role wins outright; an /// UNKNOWN role (no snapshot yet, or the snapshot never mentioned this node) is resolved by cluster /// membership: a single-driver cluster stays default-ALLOW (boot-window / single-node posture), a /// multi-driver cluster is default-DENY (a real Primary peer exists — don't touch the shared field /// device until the snapshot proves this node is Primary). /// public sealed class PrimaryGatePolicyTests { [Theory] // Primary → always service, regardless of member count. [InlineData(RedundancyRole.Primary, 0, true)] [InlineData(RedundancyRole.Primary, 1, true)] [InlineData(RedundancyRole.Primary, 2, true)] // Secondary / Detached → never service, regardless of member count. [InlineData(RedundancyRole.Secondary, 0, false)] [InlineData(RedundancyRole.Secondary, 2, false)] [InlineData(RedundancyRole.Detached, 0, false)] [InlineData(RedundancyRole.Detached, 2, false)] public void Known_role_wins_regardless_of_member_count(RedundancyRole role, int members, bool expected) => PrimaryGatePolicy.ShouldServiceAsPrimary(role, members).ShouldBe(expected); [Theory] // Unknown role: allow only when no driver peer exists (count <= 1). [InlineData(0, true)] [InlineData(1, true)] [InlineData(2, false)] [InlineData(3, false)] public void Unknown_role_resolves_by_membership(int members, bool expected) => PrimaryGatePolicy.ShouldServiceAsPrimary(null, members).ShouldBe(expected); } /// /// The alarm-history drain gate, which deliberately does not mirror the device-write gate. /// /// /// /// Why a second policy is correct here. The write gate protects a shared field device: /// two nodes driving one PLC is dangerous and irreversible, so an unknown role with a peer /// present must deny. The drain writes to an append-only historian instead, and Phase 2's /// delivery contract is already at-least-once across a failover, by design — identical /// events even collapse to one row, because ids are a hash of the payload. /// /// /// So the two error costs are wildly asymmetric: a false allow costs a duplicate history row, /// while a false deny silently stops the alarm audit trail and eventually evicts it at the /// capacity wall. This gate therefore keys on the role alone and opens when the role is /// unknown. /// /// /// Found by the LocalDb Phase 2 live gate. The rig runs four driver nodes in one Akka /// cluster with no Redundancy section, so every node's role was unknown and the /// cluster-wide driver count was 4 — and every node, including the two unpaired site-b nodes /// that have no peer and no replication at all, logged "Historian drain suspended". Nothing /// drained anywhere. Before Phase 2 that deployment drained fine, because the drain was ungated. /// /// public sealed class AlarmHistoryDrainGatePolicyTests { [Theory] // A Primary always drains its own queue. [InlineData(RedundancyRole.Primary, true, true)] [InlineData(RedundancyRole.Primary, false, true)] // A Secondary stands down ONLY when a Primary actually exists to stand up. [InlineData(RedundancyRole.Secondary, true, false)] [InlineData(RedundancyRole.Detached, true, false)] [InlineData(RedundancyRole.Secondary, false, true)] [InlineData(RedundancyRole.Detached, false, true)] public void A_known_role_stands_down_only_for_a_primary_that_exists( RedundancyRole role, bool queueSharingPeerIsPrimary, bool expected) => PrimaryGatePolicy.ShouldDrainAlarmHistory(role, queueSharingPeerIsPrimary).ShouldBe(expected); /// /// Unknown role ⇒ drain, whoever else is out there. The first case the live gate broke on, and /// the one that separates this policy from . /// [Theory] [InlineData(true)] [InlineData(false)] public void An_unknown_role_drains(bool queueSharingPeerIsPrimary) => PrimaryGatePolicy.ShouldDrainAlarmHistory(null, queueSharingPeerIsPrimary).ShouldBeTrue(); /// /// A Secondary whose Primary is NOT its queue-sharing peer keeps draining — the deepest of the /// live gate's findings. /// /// /// RedundancyStateActor derives roles from Akka's cluster-wide RoleLeader("driver"), /// but the alarm queue is pair-local. On the docker-dev rig the elected Primary is a CENTRAL node /// — it carries the driver Akka role, replicates nobody's LocalDb, and does not even run the /// alarm historian — so under a plain "a Primary exists ⇒ stand down" rule every site node /// suspended its drain in favour of a node that could not possibly deliver its events. The /// buffer then grows on every node until the capacity wall evicts the oldest: silent, permanent /// loss of the audit trail the queue exists to protect. /// [Fact] public void A_secondary_whose_peer_is_not_the_primary_keeps_draining() { // A Primary exists somewhere in the cluster, but it does not hold this node's rows. PrimaryGatePolicy.ShouldDrainAlarmHistory(RedundancyRole.Secondary, queueSharingPeerIsPrimary: false) .ShouldBeTrue("a Secondary must not defer to a Primary that cannot deliver its events"); } /// /// The divergence, pinned explicitly: with a peer present and no role known, the write gate /// denies and the drain gate allows. If someone later "harmonises" the two, this fails. /// [Fact] public void The_two_gates_deliberately_disagree_when_the_role_is_unknown_and_a_peer_exists() { PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 4).ShouldBeFalse(); PrimaryGatePolicy.ShouldDrainAlarmHistory(localRole: null, queueSharingPeerIsPrimary: true).ShouldBeTrue(); } }