f9f1b8fcee
Gate record: docs/plans/2026-07-20-localdb-phase2-live-gate.md. Checks 1, 2, 5, 6 pass. Checks 3 and 4 are NOT satisfied — the defect they were meant to confirm turned out to be the opposite of what the plan assumed. Three defects crash-looped every driver node before check 1 could even run: 1. An empty ServerHistorian:ApiKey kills the host. ServerHistorianOptions- Validator exists to turn exactly that class of failure into a named OptionsValidationException, but its documented fail tier explicitly excluded ApiKey on the reasoning that a keyless client "degrades — the gateway rejects calls". It does not: the client validates its own options at construction, so the process dies during Akka startup and never makes a call. 2. UseTls disagreeing with the endpoint scheme kills the host too, in both directions (both messages confirmed in the shipped client assembly). Moving an endpoint from https to http without clearing UseTls is an ordinary migration slip. 3. Plaintext h2c was UNREACHABLE. HistorianGatewayClientAdapter forwarded the TLS-only options unconditionally, and AllowUntrustedServerCertificate defaults to false, so it always sent RequireCertificateValidation=true — which the client rejects outright when UseTls=false. Every http:// deployment crashed, though the scheme is documented as the supported way to select h2c, and the only workaround was to assert a certificate posture for a connection that has no certificate. The fourth was the blocker, and it is Phase 2's own: 4. The drain gate deferred to a Primary that cannot deliver. Redundancy roles are elected CLUSTER-WIDE; the alarm queue is PAIR-LOCAL. On the rig the elected driver Primary is central-1 — it carries the driver Akka role, replicates nobody's LocalDb and does not even run the alarm historian — so every driver node logged "Historian drain suspended", including the two site-b nodes that have no peer at all. Nothing drained anywhere, where before Phase 2 it drained fine. The cost is not a duplicate; it is the buffer growing to the capacity wall and evicting the audit trail it exists to protect. Fixed in three layers: a separate ShouldDrainAlarmHistory policy (unknown role drains; the two gates now deliberately disagree, and a test pins that); peer- host matching in DriverHostActor so a node stands down only for a Primary holding its rows; and AddAlarmHistorian short-circuiting the gate when replication is unconfigured — testing BOTH Replication:PeerAddress and SyncListenPort, since only the dialing half sets the former while both halves share the queue. Every one of these follows from the asymmetry: a false allow costs a duplicate row, which at-least-once delivery already accepts and payload-hash ids collapse; a false deny loses data silently. A third vacuous test, caught by the same delete-the-guard discipline: the role-view tests stayed green with the guard removed, because AwaitAssert polls until an assertion passes and the assertion was "reads open" — which is the SEEDED value, satisfied at the first poll before the actor processed anything. They now assert the sequence of published values through a recording view; the control then goes red for exactly the cases that matter. Migration evidence: 11 legacy rows across two deliberately overlapping files converged to exactly 9 identical rows on both nodes, proving D-6's payload-hash identity on real nodes rather than in a fixture. Open design fork, recorded in the gate doc rather than decided here: a pair cannot currently identify its own Primary, so both halves drain. Safe in every topology — nothing loses data — but the gate's de-duplication benefit is unrealised until roles are scoped per pair. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
122 lines
6.4 KiB
C#
122 lines
6.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The alarm-history drain gate, which deliberately does <b>not</b> mirror the device-write gate.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why a second policy is correct here.</b> 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 <i>at-least-once across a failover, by design</i> — identical
|
|
/// events even collapse to one row, because ids are a hash of the payload.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <b>alone</b> and opens when the role is
|
|
/// unknown.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Found by the LocalDb Phase 2 live gate.</b> The rig runs four driver nodes in one Akka
|
|
/// cluster with no <c>Redundancy</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Unknown role ⇒ drain, whoever else is out there. The first case the live gate broke on, and
|
|
/// the one that separates this policy from <see cref="PrimaryGatePolicy.ShouldServiceAsPrimary"/>.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void An_unknown_role_drains(bool queueSharingPeerIsPrimary)
|
|
=> PrimaryGatePolicy.ShouldDrainAlarmHistory(null, queueSharingPeerIsPrimary).ShouldBeTrue();
|
|
|
|
/// <summary>
|
|
/// A Secondary whose Primary is NOT its queue-sharing peer keeps draining — the deepest of the
|
|
/// live gate's findings.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>RedundancyStateActor</c> derives roles from Akka's cluster-wide <c>RoleLeader("driver")</c>,
|
|
/// 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.
|
|
/// </remarks>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
}
|