fix(localdb): phase-2 live gate — 4 production defects found and fixed
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
This commit is contained in:
+55
@@ -22,6 +22,61 @@ public sealed class HistorianGatewayClientAdapterTests
|
||||
Assert.NotNull(adapter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A plaintext <c>h2c</c> gateway (an <c>http://</c> endpoint with <c>UseTls=false</c>) must
|
||||
/// construct, using nothing but documented defaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
|
||||
/// <c>docs/Historian.md</c> and CLAUDE.md both document <c>http://</c> as a supported
|
||||
/// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
|
||||
/// <b>unreachable</b>: this factory forwarded the TLS-only options unconditionally, and
|
||||
/// <see cref="ServerHistorianOptions.AllowUntrustedServerCertificate"/> defaults to
|
||||
/// <see langword="false"/>, so it always sent <c>RequireCertificateValidation=true</c> —
|
||||
/// which the client rejects outright when <c>UseTls=false</c>
|
||||
/// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So <b>every</b> h2c deployment crashed at startup, and the only way to avoid it was to
|
||||
/// set <c>AllowUntrustedServerCertificate=true</c> — asserting a certificate posture for a
|
||||
/// connection that has no certificate at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Adapter_constructs_for_plaintext_h2c_endpoint()
|
||||
{
|
||||
var opts = new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_x_y", UseTls = false,
|
||||
};
|
||||
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
|
||||
|
||||
adapter.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pinned CA path is likewise TLS-only, and must not leak into an h2c client — the same
|
||||
/// rejection, reached by a different field.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Adapter_ignores_tls_only_settings_on_a_plaintext_endpoint()
|
||||
{
|
||||
var opts = new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "http://localhost:5222",
|
||||
ApiKey = "histgw_x_y",
|
||||
UseTls = false,
|
||||
CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem",
|
||||
};
|
||||
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
|
||||
|
||||
adapter.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// archreview 06/S-11 — an enabled historian with an empty <c>Endpoint</c> must fail with a
|
||||
/// named, config-key-carrying <see cref="InvalidOperationException"/> (defense-in-depth for any
|
||||
|
||||
+140
-4
@@ -62,11 +62,18 @@ public sealed class ServerHistorianOptionsValidatorTests
|
||||
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a valid absolute https endpoint succeeds.</summary>
|
||||
/// <summary>
|
||||
/// Enabled with a valid absolute https endpoint <b>and a key</b> succeeds. The key is not
|
||||
/// incidental: this case previously omitted it and still asserted success, which pinned a config
|
||||
/// that actually crash-loops the host — see <see cref="Consumed_with_empty_api_key_fails"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Enabled_with_valid_https_endpoint_succeeds()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222" });
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret",
|
||||
});
|
||||
|
||||
result.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
@@ -96,16 +103,145 @@ public sealed class ServerHistorianOptionsValidatorTests
|
||||
f.Contains("ServerHistorian:Endpoint") && f.Contains("AlarmHistorian:Enabled"));
|
||||
}
|
||||
|
||||
/// <summary>Alarm-only mode with a valid endpoint succeeds.</summary>
|
||||
/// <summary>Alarm-only mode with a valid endpoint and a key succeeds.</summary>
|
||||
[Fact]
|
||||
public void Disabled_but_alarm_historian_enabled_with_valid_endpoint_succeeds()
|
||||
{
|
||||
var result = Sut(alarmHistorianEnabled: true)
|
||||
.Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "https://host:5222" });
|
||||
.Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = false, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret",
|
||||
});
|
||||
|
||||
result.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An empty <c>ApiKey</c> on a consumed section fails, because it <b>crashes</b> — it does not
|
||||
/// degrade.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Found by the LocalDb Phase 2 live gate. The rig booted with
|
||||
/// <c>AlarmHistorian:Enabled=true</c> and a valid endpoint but no key, and every driver
|
||||
/// node crash-looped on
|
||||
/// <c>ArgumentException: The gateway API key must not be empty (Parameter 'ApiKey')</c>
|
||||
/// thrown by <c>HistorianGatewayClientOptions.Validate()</c> — reached through
|
||||
/// <c>GatewayHistorian.CreateAlarmWriter</c> during Akka startup.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That is the *same* factory path, and the same crash-loop-under-a-restart-policy
|
||||
/// failure mode, this validator was built to convert into a named
|
||||
/// <c>OptionsValidationException</c>. The previous "empty ApiKey degrades, the gateway
|
||||
/// just rejects calls" premise was simply wrong: the client validates its own options
|
||||
/// at construction, so the process never reaches the point of making a call.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Consumed_with_empty_api_key_fails()
|
||||
{
|
||||
var result = Sut(alarmHistorianEnabled: true)
|
||||
.Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = false, Endpoint = "https://host:5222", ApiKey = "",
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f =>
|
||||
f.Contains("ServerHistorian:ApiKey") && f.Contains("AlarmHistorian:Enabled"));
|
||||
}
|
||||
|
||||
/// <summary>The read path has the identical crash, so it is gated identically.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_empty_api_key_fails()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true, Endpoint = "https://host:5222", ApiKey = "",
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key must never be echoed. An endpoint is not a secret and is quoted to make the error
|
||||
/// actionable; a key is, so the failure names only the setting.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Api_key_failure_never_echoes_the_key()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true, Endpoint = "https://host:5222", ApiKey = " ",
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldNotContain(f => f.Contains(" ", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>A section nobody consumes may be keyless — no failure.</summary>
|
||||
[Fact]
|
||||
public void Disabled_with_empty_api_key_succeeds()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, ApiKey = "" });
|
||||
|
||||
result.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>UseTls=true</c> against an <c>http://</c> endpoint fails — the third member of the
|
||||
/// crash-at-construction family, and the easiest of the three for an operator to trip.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Also found by the Phase 2 live gate, immediately after the <c>ApiKey</c> fix: the rig points
|
||||
/// at a deliberately unresolvable <c>http://</c> endpoint, and <c>UseTls</c> defaults to
|
||||
/// <see langword="true"/>, so every node crash-looped a second time on
|
||||
/// <c>ArgumentException: UseTls requires an https gateway endpoint</c>. Switching an endpoint
|
||||
/// from https to http without clearing <c>UseTls</c> is an ordinary migration slip, and it takes
|
||||
/// the host down rather than degrading it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Consumed_with_use_tls_against_http_endpoint_fails()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = true,
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f =>
|
||||
f.Contains("ServerHistorian:UseTls") && f.Contains("http://host:5222"));
|
||||
}
|
||||
|
||||
/// <summary>An http endpoint with TLS explicitly off is the valid h2c shape — no failure.</summary>
|
||||
[Fact]
|
||||
public void Consumed_with_http_endpoint_and_tls_off_succeeds()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
||||
});
|
||||
|
||||
result.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mirror slip — <c>UseTls=false</c> against an <c>https://</c> endpoint — also fails, so the
|
||||
/// scheme and the flag are pinned to agree in both directions rather than in one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Consumed_with_tls_off_against_https_endpoint_fails()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:UseTls"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wiring guard (the "register-AND-consume" trap): resolving <c>IOptions<ServerHistorianOptions>.Value</c>
|
||||
/// after <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions,TValidator}"/> throws
|
||||
|
||||
+109
-30
@@ -21,10 +21,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
/// every alarm event twice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These cases mirror <see cref="DriverHostActorPrimaryGateTests"/>'s matrix on purpose:
|
||||
/// the view must reach exactly the same verdict as the inbound-write and native-ack gates,
|
||||
/// because a second, subtly different notion of "am I the Primary" is how a pair ends up
|
||||
/// with one node writing and neither draining.
|
||||
/// These cases deliberately DIVERGE from <see cref="DriverHostActorPrimaryGateTests"/> in the
|
||||
/// unknown-role case: the write gate denies when a peer may exist, this one allows. A duplicate
|
||||
/// history row is cheap and already contemplated by the at-least-once contract; a silently
|
||||
/// un-drained queue is not. See <c>AlarmHistoryDrainGatePolicyTests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
|
||||
@@ -43,15 +43,37 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
|
||||
[Fact]
|
||||
public void An_unpublished_view_reads_as_primary()
|
||||
{
|
||||
new RedundancyRoleView().ShouldServiceAsPrimary.ShouldBeTrue();
|
||||
new RedundancyRoleView().ShouldDrainAlarmHistory.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A Secondary closes the view — but only because the snapshot also names a Primary.</summary>
|
||||
[Fact]
|
||||
public void Secondary_role_closes_the_view()
|
||||
public void Secondary_role_closes_the_view_when_a_primary_exists()
|
||||
{
|
||||
var view = SpawnAndTellRole(RedundancyRole.Secondary, driverMemberCount: 2);
|
||||
|
||||
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
|
||||
AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Secondary whose Primary is some OTHER pair's node keeps draining: that node holds none of
|
||||
/// this node's rows, so deferring to it would deliver them never.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Secondary_role_keeps_the_view_open_when_the_primary_is_not_its_peer()
|
||||
{
|
||||
var view = new RecordingRoleView();
|
||||
SpawnHost(view, driverMemberCount: 4).Tell(new RedundancyStateChanged(
|
||||
[
|
||||
new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
|
||||
IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow),
|
||||
// A Primary DOES exist — in another pair. It is not this node's replication peer.
|
||||
new NodeRedundancyState(NodeId.Parse("some-other-pair:4053"), RedundancyRole.Primary,
|
||||
IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow),
|
||||
],
|
||||
CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -59,67 +81,118 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
|
||||
{
|
||||
var view = SpawnAndTellRole(RedundancyRole.Primary, driverMemberCount: 2);
|
||||
|
||||
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
|
||||
// Published, not current — `true` is the seed. See the note in the unknown-role theory.
|
||||
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>Unknown role + a real driver peer ⇒ closed, matching the write gate's default-DENY.</summary>
|
||||
[Fact]
|
||||
public void Unknown_role_with_a_driver_peer_closes_the_view()
|
||||
/// <summary>
|
||||
/// Unknown role ⇒ the view stays <b>open</b>, even with driver peers present — the deliberate
|
||||
/// divergence from the write gate's default-DENY.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This case previously asserted the opposite, on the stated goal of mirroring the write
|
||||
/// gate. The Phase 2 live gate showed that goal to be wrong for this consumer: the rig runs
|
||||
/// four driver nodes in one cluster with no <c>Redundancy</c> section, so every role was
|
||||
/// unknown and the driver count was 4 — and every node logged "Historian drain suspended",
|
||||
/// including the two unpaired site-b nodes with no peer and no replication. Alarm history
|
||||
/// drained nowhere, where before Phase 2 it drained fine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See <c>AlarmHistoryDrainGatePolicyTests</c> for why the asymmetry of the two error costs
|
||||
/// makes fail-open correct here and fail-closed correct for device writes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(4)]
|
||||
public void Unknown_role_leaves_the_view_open_whatever_the_peer_count(int driverMemberCount)
|
||||
{
|
||||
// A snapshot that never names this node is how the role stays unknown in practice — the
|
||||
// documented identity-mismatch shape, not a contrived case.
|
||||
var view = SpawnAndTellForeignSnapshot(driverMemberCount: 2);
|
||||
var view = SpawnAndTellForeignSnapshot(driverMemberCount);
|
||||
|
||||
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>Unknown role + no driver peer ⇒ open, preserving the single-node posture.</summary>
|
||||
[Fact]
|
||||
public void Unknown_role_without_a_driver_peer_leaves_the_view_open()
|
||||
{
|
||||
var view = SpawnAndTellForeignSnapshot(driverMemberCount: 1);
|
||||
|
||||
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
|
||||
// Assert on what was PUBLISHED, never on the current value. `true` is also the seeded
|
||||
// default, so `AwaitAssert(() => view.ShouldDrainAlarmHistory.ShouldBeTrue())` passes at the
|
||||
// first poll — before the actor has processed the snapshot — and therefore passes just as
|
||||
// happily when the publish is wrong or absent. Verified: with the old write-gate verdict
|
||||
// restored, the current-value form stayed green and only this form goes red.
|
||||
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>A demotion must move the view, not just an initial promotion.</summary>
|
||||
[Fact]
|
||||
public void A_demotion_closes_a_previously_open_view()
|
||||
{
|
||||
var view = new RedundancyRoleView();
|
||||
var view = new RecordingRoleView();
|
||||
var host = SpawnHost(view, driverMemberCount: 2);
|
||||
|
||||
host.Tell(Snapshot(TestNode, RedundancyRole.Primary));
|
||||
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
|
||||
AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout);
|
||||
|
||||
host.Tell(Snapshot(TestNode, RedundancyRole.Secondary));
|
||||
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
|
||||
AwaitAssert(() => view.Published.ShouldBe([true, false]), duration: Timeout);
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
private RedundancyRoleView SpawnAndTellRole(RedundancyRole role, int driverMemberCount)
|
||||
/// <summary>
|
||||
/// A view that remembers every published decision, so a test can assert on the actor's
|
||||
/// <i>action</i> rather than on a state the seed already satisfies.
|
||||
/// </summary>
|
||||
private sealed class RecordingRoleView : IRedundancyRoleView
|
||||
{
|
||||
var view = new RedundancyRoleView();
|
||||
private readonly List<bool> _published = [];
|
||||
private readonly Lock _gate = new();
|
||||
private readonly RedundancyRoleView _inner = new();
|
||||
|
||||
/// <summary>Every value published so far, in order.</summary>
|
||||
public IReadOnlyList<bool> Published
|
||||
{
|
||||
get { lock (_gate) return _published.ToArray(); }
|
||||
}
|
||||
|
||||
public bool ShouldDrainAlarmHistory => _inner.ShouldDrainAlarmHistory;
|
||||
|
||||
public void Publish(bool shouldDrainAlarmHistory)
|
||||
{
|
||||
lock (_gate) _published.Add(shouldDrainAlarmHistory);
|
||||
_inner.Publish(shouldDrainAlarmHistory);
|
||||
}
|
||||
}
|
||||
|
||||
private RecordingRoleView SpawnAndTellRole(RedundancyRole role, int driverMemberCount)
|
||||
{
|
||||
var view = new RecordingRoleView();
|
||||
SpawnHost(view, driverMemberCount).Tell(Snapshot(TestNode, role));
|
||||
return view;
|
||||
}
|
||||
|
||||
private RedundancyRoleView SpawnAndTellForeignSnapshot(int driverMemberCount)
|
||||
private RecordingRoleView SpawnAndTellForeignSnapshot(int driverMemberCount)
|
||||
{
|
||||
var view = new RedundancyRoleView();
|
||||
var view = new RecordingRoleView();
|
||||
SpawnHost(view, driverMemberCount).Tell(Snapshot(NodeId.Parse("some-other-node"), RedundancyRole.Primary));
|
||||
return view;
|
||||
}
|
||||
|
||||
/// <summary>The peer this node replicates with — the only node it may ever stand down for.</summary>
|
||||
private const string PeerHost = "the-peer";
|
||||
|
||||
private IActorRef SpawnHost(IRedundancyRoleView view, int driverMemberCount) =>
|
||||
Sys.ActorOf(DriverHostActor.Props(
|
||||
NewInMemoryDbFactory(),
|
||||
TestNode,
|
||||
coordinator: null,
|
||||
driverMemberCountProvider: () => driverMemberCount,
|
||||
redundancyRoleView: view));
|
||||
redundancyRoleView: view,
|
||||
replicationPeerHost: PeerHost));
|
||||
|
||||
/// <summary>
|
||||
/// A snapshot of a real pair: this node in <paramref name="role"/>, plus a peer holding the
|
||||
/// other half. The peer matters — a Secondary only stands down when a Primary exists, so a
|
||||
/// single-entry snapshot would silently change what these cases test.
|
||||
/// </summary>
|
||||
private static RedundancyStateChanged Snapshot(NodeId node, RedundancyRole role) =>
|
||||
new(
|
||||
[
|
||||
@@ -127,6 +200,12 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
|
||||
IsClusterLeader: role == RedundancyRole.Primary,
|
||||
IsRoleLeaderForDriver: role == RedundancyRole.Primary,
|
||||
AsOfUtc: DateTime.UtcNow),
|
||||
new NodeRedundancyState(
|
||||
NodeId.Parse($"{PeerHost}:4053"),
|
||||
role == RedundancyRole.Primary ? RedundancyRole.Secondary : RedundancyRole.Primary,
|
||||
IsClusterLeader: role != RedundancyRole.Primary,
|
||||
IsRoleLeaderForDriver: role != RedundancyRole.Primary,
|
||||
AsOfUtc: DateTime.UtcNow),
|
||||
],
|
||||
CorrelationId.NewId());
|
||||
}
|
||||
|
||||
@@ -36,3 +36,86 @@ public sealed class PrimaryGatePolicyTests
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -7,6 +7,7 @@ using Xunit;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||
|
||||
@@ -102,6 +103,109 @@ public sealed class AlarmHistorianRegistrationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node whose LocalDb is not replicated drains its own queue regardless of redundancy role.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Standing down is only safe because a peer holds the same rows and will send them
|
||||
/// instead. Without <c>LocalDb:Replication:PeerAddress</c> there is no such peer: the rows
|
||||
/// are here and nowhere else, so any deferral means nobody ever delivers them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by the Phase 2 live gate. The redundancy role is a CLUSTER-WIDE election while the
|
||||
/// queue is PAIR-LOCAL, and on the docker-dev rig the elected driver Primary turned out to
|
||||
/// be a central node — one that carries the driver Akka role, replicates nobody's LocalDb
|
||||
/// and does not even run the alarm historian. Every site node suspended its drain in favour
|
||||
/// of a node that could not possibly deliver its events.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
// Neither key: an unpaired node. Its rows exist nowhere else.
|
||||
[InlineData(null, null, true)]
|
||||
// The dialing half of a pair — gated, because its partner holds the same rows.
|
||||
[InlineData("http://peer:9001", null, false)]
|
||||
// The LISTENING half. Same shared queue, but it never dials, so a PeerAddress-only test would
|
||||
// wrongly class it as unpaired and leave it permanently ungated.
|
||||
[InlineData(null, "9001", false)]
|
||||
public async Task Only_an_unreplicated_node_ignores_the_role_view(
|
||||
string? peerAddress, string? syncListenPort, bool expectDrain)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var dbPath = Path.Combine(tempDir, "node-local.db");
|
||||
|
||||
try
|
||||
{
|
||||
var config = ConfigFrom(new Dictionary<string, string?>
|
||||
{
|
||||
["AlarmHistorian:Enabled"] = "true",
|
||||
["LocalDb:Path"] = dbPath,
|
||||
["LocalDb:Replication:PeerAddress"] = peerAddress,
|
||||
["LocalDb:SyncListenPort"] = syncListenPort,
|
||||
});
|
||||
|
||||
var services = BaseServices();
|
||||
services.AddZbLocalDb(config, db =>
|
||||
{
|
||||
using var connection = db.CreateConnection();
|
||||
AlarmSfSchema.Apply(connection);
|
||||
db.RegisterReplicated(AlarmSfSchema.EventsTable);
|
||||
});
|
||||
|
||||
// A role view pinned SHUT: this node is a Secondary and a Primary exists elsewhere.
|
||||
services.AddSingleton<IRedundancyRoleView>(new StandDownRoleView());
|
||||
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
|
||||
|
||||
using (var provider = services.BuildServiceProvider())
|
||||
{
|
||||
var sink = provider.GetRequiredService<IAlarmHistorianSink>();
|
||||
await sink.EnqueueAsync(SampleEvent, CancellationToken.None);
|
||||
|
||||
await ((LocalDbStoreAndForwardSink)sink).DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
var status = sink.GetStatus();
|
||||
if (expectDrain)
|
||||
{
|
||||
// Delivered and removed — not parked behind a gate that nobody else will open.
|
||||
status.DrainState.ShouldNotBe(HistorianDrainState.NotPrimary);
|
||||
status.QueueDepth.ShouldBe(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Held for the peer that shares this queue.
|
||||
status.DrainState.ShouldBe(HistorianDrainState.NotPrimary);
|
||||
status.QueueDepth.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A view that always says "some other node is the Primary".</summary>
|
||||
private sealed class StandDownRoleView : IRedundancyRoleView
|
||||
{
|
||||
public bool ShouldDrainAlarmHistory => false;
|
||||
|
||||
public void Publish(bool shouldDrainAlarmHistory) { }
|
||||
}
|
||||
|
||||
private static AlarmHistorianEvent SampleEvent => new(
|
||||
AlarmId: "eq/alarm-1",
|
||||
EquipmentPath: "line-1/eq-1",
|
||||
AlarmName: "temp-high",
|
||||
AlarmTypeName: "LimitAlarm",
|
||||
Severity: ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High,
|
||||
EventKind: "Activated",
|
||||
Message: "temperature high",
|
||||
User: "system",
|
||||
Comment: null,
|
||||
TimestampUtc: new DateTime(2026, 7, 21, 0, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
/// <summary>
|
||||
/// An enabled historian with no LocalDb registered must fail at resolution, loudly.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user