fix(runtime): Primary verdict published from host start, cache boot never adopts malformed bytes, strict drain-gate predicate, HostAbortedException passthrough, seed only Up members
Review R1: - M1 (#609): DriverHostActor.StopChild releases the instance's resilience state before evicting the hub. - m2 (#597): DriverHostActor publishes ShouldServiceAsPrimary() to IDriverPrimaryRoleSource at start and on cluster membership changes (deduped), so the native-ack gate denies in a pair's boot window. - m4 (#601): ApplyCachedArtifact returns false on empty/malformed bytes; the cached revision is not adopted, Direct mode goes Stale (no RUNNING FROM CACHE), FetchAndCache lands Steady-with-no-revision and a re-dispatch of the same revision really applies. - m3 (#603): LocalDbReplicationPolicy splits into CapturesChanges (PeerAddress||ApiKey, registration) and SharesRowsWithPeer (ApiKey && (PeerAddress||SyncListenPort>0), alarm drain gate). - m5 (#618): Program.cs catch-all filters HostAbortedException (design-time tooling). - m6 (Cluster-002): ClusterRoleInfo seeds only Up/WeaklyUp members. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -3,26 +3,36 @@ using Microsoft.Extensions.Configuration;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE definition of "this node's LocalDb is replicated to a peer" (#603). Two consumers must agree
|
||||
/// on it, and used to carry separate predicates that diverged: the Host's table-registration step
|
||||
/// (<c>LocalDbSetup.ReplicationIsConfigured</c> - which nodes capture changes) and the alarm-historian
|
||||
/// drain gate (<c>AddAlarmHistorian</c> - which nodes stand down in favour of a peer). Lives in Runtime
|
||||
/// because Host references Runtime and not the reverse.
|
||||
/// The definitions of "this node's LocalDb is replicated to a peer" (#603) — one place, two deliberately
|
||||
/// different predicates for the two consumers: the Host's table-registration step
|
||||
/// (<c>LocalDbSetup.ReplicationIsConfigured</c> → <see cref="CapturesChanges"/>: which nodes capture
|
||||
/// changes) and the alarm-historian drain gate (<c>AddAlarmHistorian</c> → <see cref="SharesRowsWithPeer"/>:
|
||||
/// which nodes stand down in favour of a peer). Lives in Runtime because Host references Runtime and not
|
||||
/// the reverse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Predicate: <c>LocalDb:Replication:PeerAddress</c> OR <c>LocalDb:Replication:ApiKey</c> is
|
||||
/// non-blank.</b> The OR is required: only the dialling half of a pair sets <c>PeerAddress</c>, while
|
||||
/// the passive half carries the shared <c>ApiKey</c> (plus a listen port) and nothing else.
|
||||
/// <b>Why two predicates (review R1 m3).</b> The two answers cost differently when wrong. Registering a
|
||||
/// node that has no working sync path only costs capture triggers. Gating the drain of a node that
|
||||
/// shares its queue with nobody parks its alarm events behind a Primary that does not hold them —
|
||||
/// undelivered until the node is promoted, and drop-oldest at <c>Capacity</c> meanwhile. So
|
||||
/// registration stays permissive and the gate is strict. Every node the gate treats as sharing also
|
||||
/// captures, so the two never disagree in the harmful direction.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the drain gate takes the same predicate (it previously used PeerAddress OR
|
||||
/// <c>SyncListenPort</c>).</b> The gate's question is "does a peer hold the same rows?". The passive
|
||||
/// sync endpoint is fail-closed on the key, so a node with a listen port but no <c>ApiKey</c> rejects
|
||||
/// every inbound stream and shares its queue with nobody - gating it would park its alarm events
|
||||
/// behind a Primary that cannot deliver them. Conversely an <c>ApiKey</c>-only passive node IS
|
||||
/// registered and does receive its peer's rows, so it must be gated or both halves drain. The port
|
||||
/// is not evidence of a peer; the key is.
|
||||
/// <b>Registration: <c>LocalDb:Replication:PeerAddress</c> OR <c>LocalDb:Replication:ApiKey</c>.</b>
|
||||
/// The OR is required: only the dialling half of a pair sets <c>PeerAddress</c>, while the passive
|
||||
/// half carries the shared <c>ApiKey</c> (plus a listen port) and nothing else. Keying on
|
||||
/// <c>PeerAddress</c> alone would strip capture from every passive node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Drain gate: <c>ApiKey</c> AND (<c>PeerAddress</c> OR <c>LocalDb:SyncListenPort</c> > 0).</b> A
|
||||
/// peer holds this node's rows only over a working sync path: a dial (<c>PeerAddress</c>) or a
|
||||
/// listener (<c>SyncListenPort</c>), each authenticated by the key. Both auth directions fail closed,
|
||||
/// so a path without the key shares nothing, and a key with neither path (e.g. a fleet-wide
|
||||
/// secret/env <c>ApiKey</c> on a node with no peer) shares nothing either. Every docker-dev shape
|
||||
/// gives the same answer under both predicates: site-a-1 (PeerAddress + ApiKey + port) and site-a-2
|
||||
/// (ApiKey + port) are gated; central and site-b (neither) are not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LocalDbReplicationPolicy
|
||||
@@ -30,14 +40,31 @@ public static class LocalDbReplicationPolicy
|
||||
/// <summary>Configuration section holding the LocalDb replication options.</summary>
|
||||
public const string ReplicationSectionPath = "LocalDb:Replication";
|
||||
|
||||
/// <summary>Whether this node's LocalDb replicates to a pair peer.</summary>
|
||||
/// <summary>The dedicated passive-sync listener port key (same key as <c>LocalDbRegistration.SyncListenPortKey</c>).</summary>
|
||||
public const string SyncListenPortKey = "LocalDb:SyncListenPort";
|
||||
|
||||
/// <summary>Whether this node registers its replicated tables for change capture (table registration).</summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns><c>true</c> when <c>PeerAddress</c> or <c>ApiKey</c> is set under the replication section.</returns>
|
||||
public static bool IsConfigured(IConfiguration configuration)
|
||||
public static bool CapturesChanges(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
var section = configuration.GetSection(ReplicationSectionPath);
|
||||
return !string.IsNullOrWhiteSpace(section["PeerAddress"])
|
||||
|| !string.IsNullOrWhiteSpace(section["ApiKey"]);
|
||||
}
|
||||
|
||||
/// <summary>Whether a pair peer actually holds this node's replicated rows (the alarm drain gate).</summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns><c>true</c> when <c>ApiKey</c> is set AND either <c>PeerAddress</c> is set or
|
||||
/// <c>LocalDb:SyncListenPort</c> is a positive port.</returns>
|
||||
public static bool SharesRowsWithPeer(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
var section = configuration.GetSection(ReplicationSectionPath);
|
||||
if (string.IsNullOrWhiteSpace(section["ApiKey"])) return false;
|
||||
var dials = !string.IsNullOrWhiteSpace(section["PeerAddress"]);
|
||||
var listens = int.TryParse(configuration[SyncListenPortKey], out var port) && port > 0;
|
||||
return dials || listens;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user