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(); } } /// /// Gitea #537 — the continuous-historization capture gate. /// /// /// /// Same role rule as the alarm drain, and for the same reason: the historian is append-only, so /// a false allow costs a duplicate sample while a false deny costs a permanent hole in the /// trend. What differs is the peer question — the alarm queue is pair-local and /// replicated, so the peer that matters is whoever holds a copy of these rows; historization is /// fleet-wide by tag name, so the peer that matters is whoever records these same refs. /// /// /// It also gates the opposite half of the pipeline. The alarm sink gates its DRAIN, because the /// Secondary already holds a replica of the Primary's queue. The historization outbox is /// node-local and replicates to nobody, so gating only the drain would leave the standby /// appending forever to a queue no one empties. See /// PrimaryGatePolicy.ShouldRecordHistorizedValues. /// /// public sealed class HistorizationCaptureGatePolicyTests { [Theory] [InlineData(RedundancyRole.Primary, false, true)] [InlineData(RedundancyRole.Primary, true, true)] // Primary captures even if another claims it [InlineData(RedundancyRole.Secondary, true, false)] // its mesh partner is recording these refs [InlineData(RedundancyRole.Detached, true, false)] public void Known_role_decides_against_the_peer( RedundancyRole role, bool peerIsPrimary, bool expected) => PrimaryGatePolicy.ShouldRecordHistorizedValues(role, peerIsPrimary).ShouldBe(expected); /// /// A Secondary whose Primary does not record these refs keeps capturing. /// /// /// This is the case that makes the peer argument load-bearing rather than decorative. Under a /// fleet-wide election the elected Primary is usually running some other cluster's /// configuration entirely, and it will never record this node's tags. A bare "somebody is /// Primary ⇒ stand down" rule ends this cluster's historization outright, and reports nothing /// wrong: the drain simply succeeds on an empty outbox forever. It is the same scope mismatch /// that once suspended the alarm drain on every node of the docker-dev rig at once. /// [Fact] public void A_secondary_whose_primary_records_other_tags_keeps_capturing() => PrimaryGatePolicy .ShouldRecordHistorizedValues(RedundancyRole.Secondary, addressSpaceSharingPeerIsPrimary: false) .ShouldBeTrue("a Secondary must not defer to a Primary that records none of its tags"); /// /// Unknown role captures — a deployment that never publishes a role must still historize. /// [Theory] [InlineData(false)] [InlineData(true)] public void Unknown_role_captures_whatever_the_peer_says(bool peerIsPrimary) => PrimaryGatePolicy.ShouldRecordHistorizedValues(null, peerIsPrimary).ShouldBeTrue(); /// /// Pinned against a future "harmonisation": with no role known and a peer present, the /// device-write gate denies while this one allows. /// [Fact] public void It_diverges_from_the_write_gate_when_the_role_is_unknown() { PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 4).ShouldBeFalse(); PrimaryGatePolicy .ShouldRecordHistorizedValues(localRole: null, addressSpaceSharingPeerIsPrimary: true) .ShouldBeTrue(); } }