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:
Joseph Doherty
2026-07-21 06:13:01 -04:00
parent 2e4ccf7fe9
commit f9f1b8fcee
14 changed files with 885 additions and 73 deletions
@@ -57,16 +57,23 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
}
// TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects
// each of them outright when UseTls=false ("<name> is a TLS-only option and requires
// UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer-
// Certificate defaults to false, so RequireCertificateValidation was always sent as true and
// every http:// deployment crashed at startup, even though the scheme is documented as the
// supported way to select h2c. There is no certificate to have a posture about here.
var clientOptions = new HistorianGatewayClientOptions
{
Endpoint = endpointUri,
ApiKey = options.ApiKey,
UseTls = options.UseTls,
CaCertificatePath = options.CaCertificatePath,
// INVERTED mapping: ServerHistorianOptions.AllowUntrustedServerCertificate (opt-in to accept
// a self-signed cert) is the negation of the client's RequireCertificateValidation. Allowing
// an untrusted cert == not requiring validation; a pinned CaCertificatePath always verifies.
RequireCertificateValidation = !options.AllowUntrustedServerCertificate,
CaCertificatePath = options.UseTls ? options.CaCertificatePath : null,
// INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate
// (opt-in to accept a self-signed cert) is the negation of the client's
// RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a
// pinned CaCertificatePath always verifies.
RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate,
DefaultCallTimeout = options.CallTimeout,
LoggerFactory = loggerFactory,
};
@@ -24,12 +24,25 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <see cref="ServerHistorianOptions.Enabled"/> in the Host, so <c>Enabled</c> covers it.
/// </para>
/// <para>
/// <b>Fail tier = provably-crashing configs only.</b> Only an empty / non-absolute / non-http(s)
/// <c>Endpoint</c> fails here (it throws in the factory). Empty <c>ApiKey</c> and non-positive
/// <c>MaxTieClusterOverfetch</c> degrade rather than crash (the gateway rejects calls / the node
/// manager surfaces a Bad read), so they stay operator warnings in
/// <b>Fail tier = provably-crashing configs only.</b> Three settings qualify, and they all fail
/// the same way — <c>HistorianGatewayClientOptions.Validate()</c> throws inside the client
/// factory before any call is attempted, so the host dies at startup:
/// an empty / non-absolute / non-http(s) <c>Endpoint</c>; an empty <c>ApiKey</c> ("The gateway
/// API key must not be empty"); and a <c>UseTls</c> that disagrees with the endpoint scheme
/// ("UseTls requires an https gateway endpoint" / "An https gateway endpoint requires UseTls").
/// A non-positive <c>MaxTieClusterOverfetch</c> genuinely degrades rather than crashes (the node
/// manager surfaces a Bad read), so it stays an operator warning in
/// <see cref="ServerHistorianOptions.Validate"/>. The <c>Endpoint</c> value is not a secret and
/// is echoed to make the error actionable; the <c>ApiKey</c> is never surfaced.
/// is echoed to make each error actionable; the <c>ApiKey</c> is never surfaced.
/// </para>
/// <para>
/// <b>The <c>ApiKey</c> and <c>UseTls</c> checks were added after two consecutive live-rig
/// crash-loops</b> (LocalDb Phase 2 gate). Both had been classified as degrading, on the
/// assumption that a bad client configuration would connect and be rejected by the gateway — but
/// the client validates its own options at construction, so the process dies during Akka startup
/// instead, which is precisely the failure this validator exists to convert into a named,
/// aggregated error. The lesson generalises: "degrades" is only true of settings the client
/// does not itself validate.
/// </para>
/// </remarks>
public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<ServerHistorianOptions>
@@ -62,5 +75,26 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<Serve
builder.RequireThat(
endpointValid,
$"ServerHistorian:Endpoint is empty or not an absolute http(s) URI ('{options.Endpoint}') — {reason}.");
// Deliberately not echoed: unlike the endpoint, the key is a secret.
builder.RequireThat(
!string.IsNullOrWhiteSpace(options.ApiKey),
$"ServerHistorian:ApiKey is empty — {reason}. The gateway client rejects a keyless "
+ "configuration at construction, so the host would crash on startup rather than degrade. "
+ "Supply it via the environment variable ServerHistorian__ApiKey.");
// The flag and the scheme must agree in BOTH directions — the client throws either way
// ("UseTls requires an https gateway endpoint" / "An https gateway endpoint requires UseTls").
// Only meaningful once the endpoint parsed; a malformed one already failed above.
if (endpointValid)
{
var https = uri!.Scheme == Uri.UriSchemeHttps;
builder.RequireThat(
options.UseTls == https,
$"ServerHistorian:UseTls is {options.UseTls.ToString().ToLowerInvariant()} but the "
+ $"endpoint is '{options.Endpoint}' — {reason}. The flag must match the scheme "
+ "(https ⇒ UseTls=true, http ⇒ UseTls=false); the gateway client rejects a mismatch "
+ "at construction, crashing the host on startup.");
}
}
}
@@ -257,6 +257,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// today, the alarm store-and-forward drain. Null on nodes that do not wire one.</summary>
private readonly IRedundancyRoleView? _redundancyRoleView;
/// <summary>
/// Host of this node's LocalDb replication partner — the only node that holds a copy of this
/// node's alarm queue, and therefore the only node it may ever stand down in favour of.
/// <c>null</c> when this node dials nobody (unpaired, or the listening half of a pair).
/// </summary>
private readonly string? _replicationPeerHost;
/// <summary>Debounces the S5 "snapshot omitted this node" Warning to once per process — a persistent
/// identity mismatch (03/S5) would otherwise log on every snapshot.</summary>
private bool _warnedSnapshotMissingLocalNode;
@@ -372,7 +379,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IDriverCapabilityInvokerFactory? invokerFactory = null,
Func<int>? driverMemberCountProvider = null,
IDeploymentArtifactCache? deploymentArtifactCache = null,
IRedundancyRoleView? redundancyRoleView = null) =>
IRedundancyRoleView? redundancyRoleView = null,
string? replicationPeerHost = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -382,7 +390,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView));
deploymentArtifactCache, redundancyRoleView, replicationPeerHost));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -433,10 +441,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IDriverCapabilityInvokerFactory? invokerFactory = null,
Func<int>? driverMemberCountProvider = null,
IDeploymentArtifactCache? deploymentArtifactCache = null,
IRedundancyRoleView? redundancyRoleView = null)
IRedundancyRoleView? redundancyRoleView = null,
string? replicationPeerHost = null)
{
_deploymentArtifactCache = deploymentArtifactCache;
_redundancyRoleView = redundancyRoleView;
_replicationPeerHost = replicationPeerHost;
_dbFactory = dbFactory;
_localNode = localNode;
_coordinatorOverride = coordinator;
@@ -1465,6 +1475,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
}
/// <summary>Host part of a <c>host:port</c> node id.</summary>
private static string HostOf(NodeId nodeId)
{
var text = nodeId.ToString();
var colon = text.LastIndexOf(':');
return colon < 0 ? text : text[..colon];
}
/// <summary>The Primary-gate deny reason tag for the denial meter (<c>secondary|detached|role-unknown</c>).</summary>
private string PrimaryGateDenyReason() => _localRole switch
{
@@ -1496,11 +1514,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, string.Join(",", msg.Nodes.Select(n => n.NodeId)));
}
// Publish on EVERY snapshot, including ones that leave the cached role untouched: the other
// half of the decision is the driver member count, which moves with cluster membership and
// not with this message. Snapshots are re-published on a heartbeat, so a membership change
// is picked up within one heartbeat rather than waiting for a role change that may never come.
_redundancyRoleView?.Publish(ShouldServiceAsPrimary());
// Publish on EVERY snapshot, including ones that leave the cached role untouched, so a role
// that changes without this node being named still reaches the drain within one heartbeat.
//
// NOTE the different policy: the drain gate keys on the role ALONE and opens when it is
// unknown, where the data-plane gate above resolves an unknown role by member count and
// denies. Publishing ShouldServiceAsPrimary() here suspended the drain on every node of a
// multi-driver cluster that had no redundancy configured — see ShouldDrainAlarmHistory.
// Defer ONLY to the node that holds a copy of our rows. The redundancy election is
// cluster-wide and the queue is pair-local, so "somebody is Primary" is not a licence to stop
// draining — that Primary is usually in another pair entirely, and cannot deliver our events.
var peerIsPrimary =
_replicationPeerHost is not null
&& msg.Nodes.Any(n => n.Role == RedundancyRole.Primary
&& HostOf(n.NodeId) == _replicationPeerHost);
_redundancyRoleView?.Publish(
PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary));
}
private void Stale()
@@ -25,4 +25,50 @@ public static class PrimaryGatePolicy
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
};
}
@@ -3,44 +3,55 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
/// <summary>
/// A singleton snapshot of the Primary-gate decision, so code that lives outside an actor can
/// ask "should this node be servicing Primary-only work right now?".
/// A singleton snapshot of the alarm-history drain decision, so code that lives outside an actor
/// can ask "should this node be draining the alarm queue right now?".
/// </summary>
/// <remarks>
/// <para>
/// Exists for the alarm store-and-forward drain. That drain runs on a timer owned by the
/// sink, not on an actor mailbox, so it cannot receive <c>RedundancyStateChanged</c>
/// directly — but it must be Primary-scoped, because the queue it drains replicates to the
/// directly — but it must be role-scoped, because the queue it drains replicates to the
/// pair peer and two draining nodes would deliver every event twice.
/// </para>
/// <para>
/// Deliberately publishes the <i>decision</i> rather than the role and member count that
/// produced it, so <see cref="PrimaryGatePolicy"/> stays the single place that decides.
/// Deliberately publishes the <i>decision</i> rather than the role that produced it, so
/// <see cref="PrimaryGatePolicy"/> stays the single place that decides.
/// </para>
/// <para>
/// <b>This is not the device-write gate.</b> It is fed by
/// <see cref="PrimaryGatePolicy.ShouldDrainAlarmHistory"/>, which opens on an unknown role
/// where <see cref="PrimaryGatePolicy.ShouldServiceAsPrimary"/> closes. The naming is
/// explicit about the consumer for that reason: an earlier revision published the
/// write-gate verdict here, and on a cluster with several driver nodes but no configured
/// redundancy it suspended the drain everywhere, so alarm history accumulated on every node
/// and left none.
/// </para>
/// </remarks>
public interface IRedundancyRoleView
{
/// <summary>Whether this node should service Primary-only work right now.</summary>
bool ShouldServiceAsPrimary { get; }
/// <summary>Whether this node should be draining the alarm store-and-forward queue right now.</summary>
bool ShouldDrainAlarmHistory { get; }
/// <summary>Records a fresh decision. Called by <c>DriverHostActor</c> on every redundancy snapshot.</summary>
/// <param name="shouldServiceAsPrimary">The decision <see cref="PrimaryGatePolicy"/> produced.</param>
void Publish(bool shouldServiceAsPrimary);
/// <param name="shouldDrainAlarmHistory">
/// The decision <see cref="PrimaryGatePolicy.ShouldDrainAlarmHistory"/> produced.
/// </param>
void Publish(bool shouldDrainAlarmHistory);
}
/// <summary>Thread-safe <see cref="IRedundancyRoleView"/> backed by a volatile field.</summary>
public sealed class RedundancyRoleView : IRedundancyRoleView
{
// Seeded with the decision for "role unknown, no driver peer" rather than a bare false. An
// unpublished view and a node with no peer are genuinely the same situation, and they must
// behave the same: a deployment that runs no redundancy at all never publishes here, and
// defaulting closed would silently stop its alarm history forever.
private volatile bool _shouldServiceAsPrimary =
PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 0);
// Seeded with the decision for "role unknown" rather than a bare false. An unpublished view and a
// node whose role nothing ever reports are genuinely the same situation, and they must behave the
// same: a deployment that runs no redundancy at all never publishes here, and defaulting closed
// would silently stop its alarm history forever.
private volatile bool _shouldDrainAlarmHistory =
PrimaryGatePolicy.ShouldDrainAlarmHistory(localRole: null, queueSharingPeerIsPrimary: false);
/// <inheritdoc/>
public bool ShouldServiceAsPrimary => _shouldServiceAsPrimary;
public bool ShouldDrainAlarmHistory => _shouldDrainAlarmHistory;
/// <inheritdoc/>
public void Publish(bool shouldServiceAsPrimary) => _shouldServiceAsPrimary = shouldServiceAsPrimary;
public void Publish(bool shouldDrainAlarmHistory) => _shouldDrainAlarmHistory = shouldDrainAlarmHistory;
}
@@ -107,6 +107,34 @@ public static class ServiceCollectionExtensions
// posture for a deployment that runs no redundancy at all.
services.TryAddSingleton<IRedundancyRoleView, RedundancyRoleView>();
// THE GATE ONLY APPLIES TO A REPLICATED QUEUE. Standing down is only ever safe because some
// other node holds the same rows and will send them instead — and the only node that does is
// this node's LocalDb replication peer. Without replication configured, these rows exist here
// and nowhere else, so deferring to anyone means they are never delivered by anyone.
//
// This is not hypothetical. The redundancy role is a CLUSTER-WIDE election
// (RedundancyStateActor keys on Akka's RoleLeader("driver")), while the queue is PAIR-LOCAL.
// On the docker-dev rig the elected driver Primary is a central node — which carries the
// driver Akka role, replicates nobody's LocalDb, and does not even run the alarm historian —
// so every site node dutifully suspended its drain in favour of a node that could not
// possibly deliver its events. Scoping the gate to "is my queue actually shared?" is what
// keeps the two scopes from disagreeing.
// BOTH halves of a pair share the queue, but only one of them dials: the initiator sets
// Replication:PeerAddress, its partner only sets SyncListenPort and waits. Testing the dial
// side alone would leave the listening half permanently ungated — one drainer per pair by
// accident rather than by role, and the wrong one whenever the roles swap.
var replicated =
!string.IsNullOrWhiteSpace(configuration["LocalDb:Replication:PeerAddress"])
|| !string.IsNullOrWhiteSpace(configuration["LocalDb:SyncListenPort"]);
if (!replicated)
{
Serilog.Log.Logger.ForContext<LocalDbStoreAndForwardSink>().Information(
"Alarm historian: LocalDb replication is not configured, so this node's queue is not "
+ "shared with any peer and the Primary drain gate does not apply — this node always "
+ "drains its own alarm queue.");
}
services.AddSingleton<IAlarmHistorianSink>(sp =>
{
// LocalDbStoreAndForwardSink takes a Serilog ILogger (not Microsoft.Extensions.Logging).
@@ -121,7 +149,7 @@ public static class ServiceCollectionExtensions
capacity: opts.Capacity,
deadLetterRetention: TimeSpan.FromDays(opts.DeadLetterRetentionDays),
maxAttempts: opts.MaxAttempts,
drainGate: () => roleView.ShouldServiceAsPrimary);
drainGate: () => !replicated || roleView.ShouldDrainAlarmHistory);
sink.StartDrainLoop(TimeSpan.FromSeconds(opts.DrainIntervalSeconds));
return sink;
});
@@ -252,6 +280,16 @@ public static class ServiceCollectionExtensions
// Registered by AddAlarmHistorian; absent when no durable sink is configured, in which
// case there is nothing downstream to inform.
var redundancyRoleView = resolver.GetService<IRedundancyRoleView>();
// Host of this node's LocalDb replication partner: the ONLY node that holds a copy of this
// node's alarm queue, and so the only node it may stand down in favour of. Null when this
// node dials nobody, which correctly means "never stand down".
var replicationPeerHost =
Uri.TryCreate(
resolver.GetService<IConfiguration>()?["LocalDb:Replication:PeerAddress"],
UriKind.Absolute,
out var peerUri)
? peerUri.Host
: null;
// Root script logger backs the ScriptedAlarm host's engine + script logging. Registered in
// Host DI inside the hasDriver block; may be absent in some role configs / test harnesses,
// in which case the DriverHostActor gracefully skips spawning the ScriptedAlarm host.
@@ -371,7 +409,8 @@ public static class ServiceCollectionExtensions
scriptRootLogger: scriptRootLogger,
invokerFactory: invokerFactory,
deploymentArtifactCache: deploymentArtifactCache,
redundancyRoleView: redundancyRoleView),
redundancyRoleView: redundancyRoleView,
replicationPeerHost: replicationPeerHost),
DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost);