using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// /// Single decision point for the Primary data-plane gates (inbound device writes, native alarm acks, /// fleet-wide alerts publishes). A KNOWN role wins outright; an UNKNOWN role (no redundancy snapshot /// yet, or the snapshot never mentioned this node — the 03/S5 identity-mismatch shape) is resolved by /// cluster membership: a single-driver cluster stays default-ALLOW (no peer exists — the /// boot-window / single-node posture), a multi-driver cluster is default-DENY (a real Primary peer /// exists; do not touch the shared field device until the redundancy snapshot proves this node is it). /// Fixes archreview 03/S4 (primary gate defaulted to allow while role unknown → dual-primary window). /// public static class PrimaryGatePolicy { /// Decide whether this node should service a Primary-only data-plane operation. /// This node's last-known redundancy role, or null when unknown /// (no snapshot received yet, or the snapshot omitted this node). /// Count of Up cluster members carrying the driver role. /// true to service as Primary; false to deny. public static bool ShouldServiceAsPrimary(RedundancyRole? localRole, int driverMemberCount) => localRole switch { RedundancyRole.Primary => true, RedundancyRole.Secondary or RedundancyRole.Detached => false, _ => driverMemberCount <= 1, // unknown: allow only when no driver peer exists }; /// /// Decide whether this node should drain the replicated alarm store-and-forward queue. /// /// /// /// Deliberately not . That gate protects a shared /// field device, where two nodes acting at once is dangerous and irreversible, so an unknown /// role with a peer present must deny. This gate protects an append-only historian, and the /// delivery contract is already at-least-once across a failover, by design — identical /// events even collapse to a single row, because ids hash the payload. /// /// /// The two error costs are therefore asymmetric: a false allow costs a duplicate history row; /// a false deny silently stops the alarm audit trail and eventually evicts it at the capacity /// wall. So this decision keys on the role alone and opens when the role is unknown — /// no membership tie-break, because cluster-wide driver count says nothing about whether /// this node has a redundant partner. /// /// /// Found by the LocalDb Phase 2 live gate: on a rig of four driver nodes in one cluster with /// no Redundancy section, borrowing the write gate's verdict suspended the drain on /// every node — including unpaired ones — so nothing drained anywhere. /// /// /// This node's last-known redundancy role, or null when unknown. /// /// Whether the node holding in the same snapshot is a node /// that shares this node's queue — i.e. its LocalDb replication partner. /// /// Merely knowing that a Primary exists is not enough, because the redundancy role is /// a CLUSTER-WIDE election while the alarm queue is PAIR-LOCAL. A fleet runs many pairs in /// one cluster, so the elected driver Primary is usually in some other pair — and on the /// docker-dev rig it is a central node, which carries the driver Akka role, replicates /// nobody's LocalDb and does not even run the alarm historian. Deferring to it means this /// node's events are delivered by no one, ever. /// /// /// true to drain; false only when a node holding these same rows is doing it. public static bool ShouldDrainAlarmHistory(RedundancyRole? localRole, bool queueSharingPeerIsPrimary) => localRole switch { RedundancyRole.Primary => true, RedundancyRole.Secondary or RedundancyRole.Detached => !queueSharingPeerIsPrimary, _ => true, // unknown role ⇒ drain; a duplicate row beats a silent gap }; /// /// Decide whether this node should capture continuous-historization values into its /// durable outbox (Gitea #537). /// /// /// /// Same rule as , deliberately — and for the same /// reason. The historian is append-only and the error costs are asymmetric: a false /// allow costs a duplicate sample, a false deny costs a permanent hole in the trend. So an /// unknown role captures, and only an explicit Secondary/Detached stands down. /// /// /// But it gates the CAPTURE, not the drain — the opposite of the alarm sink. That /// choice follows from where the queue lives. The alarm queue is in LocalDb and /// replicates, so the Secondary already holds the Primary's rows and gating the /// drain loses nothing. The historization outbox is a node-local FasterLog that replicates /// to nobody: gating only its drain would leave the standby appending forever to a queue no /// one empties (unbounded disk, and a flood of stale duplicates the moment it is promoted), /// while gating the capture leaves it holding nothing. Values already captured are still /// drained by the node that captured them, so a demotion loses nothing that was recorded. /// /// /// The residual cost is a capture gap across a failover, bounded by how long the newly /// promoted node takes to see its first snapshot. That is inherent to any non-duplicating /// design; the alternative — both nodes capturing — is what #537 was filed about. /// /// /// This node's last-known redundancy role, or null when unknown. /// /// Whether the node holding in the same snapshot is one /// that serves this node's address space, and therefore captures the very same historized /// refs — its per-cluster mesh partner. /// /// The scoping matters for the same reason it does on the alarm queue, and the mistake is /// easier to make here because it looks safe: the redundancy election reaches only as far as /// the caller can prove, whereas historization is fleet-wide by tag name. A node that stands /// down in favour of a Primary running some other cluster's configuration hands its /// tags to a node that never records them, and nothing says so — the drain simply reports /// success on an empty outbox forever. Callers that cannot establish the sharing pass /// false, which never stands down; duplicates are recoverable, a silent gap is not. /// /// /// true to capture; false only when a node recording these same tags is doing it. public static bool ShouldRecordHistorizedValues( RedundancyRole? localRole, bool addressSpaceSharingPeerIsPrimary) => localRole switch { RedundancyRole.Primary => true, RedundancyRole.Secondary or RedundancyRole.Detached => !addressSpaceSharingPeerIsPrimary, _ => true, // unknown role ⇒ capture; a duplicate sample beats a hole in the trend }; }