e7fbe97759
Both defects were found by #491's live gate against a real HistorianGateway and filed rather than fixed at the time, because each one's fix is a design decision rather than a patch. #537 — both nodes of a redundant pair captured every value, so the historian received duplicate live samples for every historized tag (measured: the gateway's write counter advanced twice per value and halved when one central was stopped). The recorder now reads a capture gate per value, fed from the new PrimaryGatePolicy.ShouldRecordHistorizedValues through IHistorizationRoleView — a third verdict on the same RedundancyRoleView object, registered unconditionally on driver nodes rather than alongside the alarm section's IRedundancyRoleView (which only exists when that section is enabled; piggybacking is the #511 registration accident). Two things are deliberate and easy to get backwards: * It gates the CAPTURE, not the drain — the opposite of the alarm sink. That queue is in LocalDb and replicates, so the Secondary already holds the Primary's rows. The historization outbox is a node-local FasterLog replicating to nobody, so a drain-only gate would leave the standby appending forever to a queue no one empties: unbounded disk, then a flood of stale duplicates the moment it is promoted. Entries a node already captured are still drained by that node. * The peer it stands down for must RECORD THE SAME REFS, not merely hold the same rows — a Primary in this node's own per-cluster mesh. A node carrying no cluster-scoped role cannot establish that and never stands down: under a fleet-wide election the elected Primary is usually running a different cluster's configuration, and deferring to it would end this cluster's historization silently, with the drain reporting success on an empty outbox forever. That is the scope mismatch that once suspended the alarm drain on every node of the rig. An unknown role captures, matching the alarm drain — a duplicate sample is recoverable, a hole in the trend is not. #538 — the drain writes every per-tag group and then acks all-or-nothing (deliberate: RemoveAsync truncates THROUGH an id, so partial acking could drop older unwritten entries). One permanently failing tag therefore pinned the FIFO head while every healthy tag in the batch was re-submitted to the historian on every pass, indefinitely — duplicate samples and an outbox that never truncated, observed live at 8.3 MB from a single unprovisionable tag. The drain now tracks consecutive failures per historian tag and abandons that tag's entries once it exceeds MaxWriteAttempts (new, default 5), so the head advances. The counter only advances on a pass where some OTHER tag wrote successfully: that is what separates "this tag is poison" from "the backend is down". A count-only quarantine looks identical on the poison-tag case and silently discards the whole backlog during an ordinary gateway restart, turning a duplicate-samples bug into a lost-history one. Abandonment is also deferred until every failing tag is over budget, so nothing is discarded on a pass that could not have advanced the head anyway. Each abandonment logs a Warning naming the tag; the total rides RecorderStatus.AbandonedEntries. Capacity also gains a non-zero default (500,000 entries) as the disk backstop for the one case the quarantine deliberately cannot resolve — a historian refusing every write, where retaining is correct. Set 0 to restore unbounded retention. Every new test was falsified by reverting the production change it guards, and each one reddens alone: * drop the backend-reachable evidence -> only A_backend_that_fails_every_tag_abandons_nothing * never abandon -> only A_permanently_failing_tag_is_abandoned_so_healthy_tags_stop_being_rewritten * latch the capture gate at construction -> only The_capture_gate_is_re_read_per_value... * treat any Primary as a scope peer -> only the two scope-mismatch cases Full suite green. (Four OpcUaClient.Browser failures during the run were the opc-plc fixture being down on the shared Docker host; brought it back up and they pass.) Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
127 lines
8.2 KiB
C#
127 lines
8.2 KiB
C#
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public static class PrimaryGatePolicy
|
|
{
|
|
/// <summary>Decide whether this node should service a Primary-only data-plane operation.</summary>
|
|
/// <param name="localRole">This node's last-known redundancy role, or <c>null</c> when unknown
|
|
/// (no snapshot received yet, or the snapshot omitted this node).</param>
|
|
/// <param name="driverMemberCount">Count of Up cluster members carrying the <c>driver</c> role.</param>
|
|
/// <returns><c>true</c> to service as Primary; <c>false</c> to deny.</returns>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Decide whether this node should drain the replicated alarm store-and-forward queue.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Deliberately not <see cref="ShouldServiceAsPrimary"/>.</b> 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 <i>at-least-once across a failover, by design</i> — identical
|
|
/// events even collapse to a single row, because ids hash the payload.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <b>alone</b> and opens when the role is unknown —
|
|
/// no membership tie-break, because cluster-wide driver count says nothing about whether
|
|
/// <i>this</i> node has a redundant partner.
|
|
/// </para>
|
|
/// <para>
|
|
/// Found by the LocalDb Phase 2 live gate: on a rig of four driver nodes in one cluster with
|
|
/// no <c>Redundancy</c> section, borrowing the write gate's verdict suspended the drain on
|
|
/// every node — including unpaired ones — so nothing drained anywhere.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="localRole">This node's last-known redundancy role, or <c>null</c> when unknown.</param>
|
|
/// <param name="queueSharingPeerIsPrimary">
|
|
/// Whether the node holding <see cref="RedundancyRole.Primary"/> in the same snapshot is a node
|
|
/// that <b>shares this node's queue</b> — i.e. its LocalDb replication partner.
|
|
/// <para>
|
|
/// Merely knowing that <i>a</i> 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.
|
|
/// </para>
|
|
/// </param>
|
|
/// <returns><c>true</c> to drain; <c>false</c> only when a node holding these same rows is doing it.</returns>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Decide whether this node should <b>capture</b> continuous-historization values into its
|
|
/// durable outbox (Gitea #537).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Same rule as <see cref="ShouldDrainAlarmHistory"/>, deliberately — and for the same
|
|
/// reason.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>But it gates the CAPTURE, not the drain — the opposite of the alarm sink.</b> That
|
|
/// choice follows from where the queue lives. The alarm queue is in LocalDb and
|
|
/// <i>replicates</i>, 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="localRole">This node's last-known redundancy role, or <c>null</c> when unknown.</param>
|
|
/// <param name="addressSpaceSharingPeerIsPrimary">
|
|
/// Whether the node holding <see cref="RedundancyRole.Primary"/> in the same snapshot is one
|
|
/// that serves <b>this node's address space</b>, and therefore captures the very same historized
|
|
/// refs — its per-cluster mesh partner.
|
|
/// <para>
|
|
/// 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 <i>other</i> 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
|
|
/// <c>false</c>, which never stands down; duplicates are recoverable, a silent gap is not.
|
|
/// </para>
|
|
/// </param>
|
|
/// <returns><c>true</c> to capture; <c>false</c> only when a node recording these same tags is doing it.</returns>
|
|
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
|
|
};
|
|
}
|