fix(r2-04): primary gates default-DENY on multi-driver clusters while role unknown (03/S4)
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
{ "id": "T7", "subject": "Galaxy counters galaxy.writes.advise_failed + galaxy.writes.unconfirmed (empty-statuses Good metered)", "status": "completed", "blockedBy": ["T6"] },
|
||||
{ "id": "T8", "subject": "Galaxy skip-gated live smoke: normal advised write still returns Good post-change", "status": "completed", "blockedBy": ["T6"] },
|
||||
{ "id": "T9", "subject": "PrimaryGatePolicy pure class + truth-table tests + otopcua.redundancy.primary_gate_denied counter (03/S4)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T10", "subject": "Wire DriverHostActor gates (:977/:1039/:1095) through PrimaryGatePolicy with member-count provider + S5 log-once", "status": "pending", "blockedBy": ["T9"] },
|
||||
{ "id": "T10", "subject": "Wire DriverHostActor gates (:977/:1039/:1095) through PrimaryGatePolicy with member-count provider + S5 log-once", "status": "completed", "blockedBy": ["T9"] },
|
||||
{ "id": "T11", "subject": "DriverHostActorPrimaryGateTests: deny-on-unknown multi-node, allow single-node, ack/emit variants, meter tags", "status": "pending", "blockedBy": ["T10"] },
|
||||
{ "id": "T12", "subject": "ScriptedAlarmHostActor alerts-emit gate adopts PrimaryGatePolicy (consistency extension)", "status": "pending", "blockedBy": ["T9"] },
|
||||
{ "id": "T13", "subject": "In-process 2-node delivery-path test (TwoNodeClusterHarness): DPS-delivered snapshot drives the gate", "status": "pending", "blockedBy": ["T11"] },
|
||||
|
||||
@@ -192,14 +192,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>
|
||||
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
||||
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The inbound
|
||||
/// write gate in <see cref="HandleRouteNodeWrite"/> reuses this signal — the SAME one the
|
||||
/// scripted-alarm emit gate uses (<c>ScriptedAlarmHostActor._localRole</c>): only the Primary
|
||||
/// services writes, default-allow while unknown so single-node deploys + the boot window never
|
||||
/// reject (a node is the sole Primary until told otherwise).
|
||||
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The Primary
|
||||
/// data-plane gates (<see cref="HandleRouteNodeWrite"/>, <see cref="HandleRouteNativeAlarmAck"/>,
|
||||
/// <see cref="ForwardNativeAlarm"/>) resolve this through <see cref="PrimaryGatePolicy"/>: a KNOWN
|
||||
/// role wins outright; an UNKNOWN role is resolved by cluster membership — a single-driver cluster
|
||||
/// stays default-ALLOW (boot-window / single-node), a multi-driver cluster default-DENIES until a
|
||||
/// snapshot proves this node is Primary (archreview 03/S4 — closes the dual-primary boot window).
|
||||
/// </summary>
|
||||
private RedundancyRole? _localRole;
|
||||
|
||||
/// <summary>Test seam (archreview 03/S4): overrides the count of Up <c>driver</c>-role cluster members
|
||||
/// the Primary gate reads while the role is unknown. Null ⇒ read the live cluster state (0 on a
|
||||
/// non-cluster ActorRefProvider). See <see cref="DriverMemberCount"/>.</summary>
|
||||
private readonly Func<int>? _driverMemberCountProvider;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>Cached cluster DistributedPubSub mediator, resolved once in <see cref="PreStart"/> (on the
|
||||
/// actor thread) and reused for the Primary-gated native-alarm <c>alerts</c> fan-out in
|
||||
/// <see cref="ForwardNativeAlarm"/> instead of re-resolving it per-publish. Mirrors
|
||||
@@ -308,11 +318,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null) =>
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null,
|
||||
Func<int>? driverMemberCountProvider = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
|
||||
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory));
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -338,6 +349,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <param name="invokerFactory">Phase 6.1 resilience-invoker factory used to attach an
|
||||
/// <see cref="IDriverCapabilityInvoker"/> to each spawned driver; defaults to
|
||||
/// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when null.</param>
|
||||
/// <param name="driverMemberCountProvider">Test seam (archreview 03/S4): overrides the count of Up
|
||||
/// <c>driver</c>-role cluster members the Primary gate reads while the role is unknown. When null the
|
||||
/// default reads <c>Cluster.Get(Context.System).State.Members</c> (0 on a non-cluster ActorRefProvider).</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -353,13 +367,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null)
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null,
|
||||
Func<int>? driverMemberCountProvider = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_localNode = localNode;
|
||||
_coordinatorOverride = coordinator;
|
||||
_driverFactory = driverFactory ?? NullDriverFactory.Instance;
|
||||
_invokerFactory = invokerFactory ?? NullDriverCapabilityInvokerFactory.Instance;
|
||||
_driverMemberCountProvider = driverMemberCountProvider;
|
||||
_localRoles = localRoles ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
_dependencyMux = dependencyMux;
|
||||
_opcUaPublishActor = opcUaPublishActor;
|
||||
@@ -964,17 +980,29 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
||||
return;
|
||||
}
|
||||
// PRIMARY GATE (archreview 03/S4) — decided ONCE for this transition: the OPC UA condition write
|
||||
// below stays UNGATED (a Secondary keeps its address space warm for failover), but only the Primary
|
||||
// publishes the single cluster-wide alerts copy. Denied on a Secondary/Detached, OR on a multi-driver
|
||||
// cluster while the role is unknown (default-deny closes the dual-primary boot window that would
|
||||
// otherwise historize duplicate AVEVA alarm rows). Metered + Debug-logged once when denied.
|
||||
var serviceAlertsAsPrimary = ShouldServiceAsPrimary();
|
||||
if (!serviceAlertsAsPrimary)
|
||||
{
|
||||
OtOpcUaTelemetry.PrimaryGateDenied.Add(1,
|
||||
new KeyValuePair<string, object?>("site", "alarm-emit"),
|
||||
new KeyValuePair<string, object?>("reason", PrimaryGateDenyReason()));
|
||||
_log.Debug("DriverHost {Node}: native-alarm alerts publish suppressed for ({Driver},{Ref}) — not primary",
|
||||
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
||||
}
|
||||
|
||||
foreach (var nodeId in nodeIds)
|
||||
{
|
||||
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
||||
nodeId, snapshot, msg.Args.SourceTimestampUtc));
|
||||
|
||||
// Warm-standby dedup: the OPC UA condition write above is UNGATED (the secondary keeps its
|
||||
// address space warm), but only the Primary publishes the cluster-wide alerts transition.
|
||||
// Default-emit until told we are Secondary/Detached so single-node deploys + the boot window
|
||||
// never drop transitions — the SAME signal HandleRouteNodeWrite gates writes on.
|
||||
if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) continue;
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
|
||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null);
|
||||
@@ -1034,11 +1062,18 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
private void HandleRouteNodeWrite(RouteNodeWrite msg)
|
||||
{
|
||||
// PRIMARY GATE FIRST — only the Primary services operator writes (same signal as the alarm-emit
|
||||
// gate; unknown role ⇒ treated as Primary so single-node deploys + the boot window aren't blocked).
|
||||
if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached)
|
||||
// PRIMARY GATE FIRST (archreview 03/S4) — only the Primary services operator writes. A KNOWN
|
||||
// Secondary/Detached is denied "not primary" (unchanged); an UNKNOWN role denies ONLY on a
|
||||
// multi-driver cluster ("not primary (role unknown)"), while a single-driver/boot-window cluster is
|
||||
// still serviced. The rejection rides the established optimistic-write self-correction surface (the
|
||||
// node reverts to its prior value with a Bad blip + a Part 8 audit event) — writes are NOT queued.
|
||||
if (!ShouldServiceAsPrimary())
|
||||
{
|
||||
Sender.Tell(new NodeWriteResult(false, "not primary"));
|
||||
var reason = _localRole is null ? "not primary (role unknown)" : "not primary";
|
||||
OtOpcUaTelemetry.PrimaryGateDenied.Add(1,
|
||||
new KeyValuePair<string, object?>("site", "write"),
|
||||
new KeyValuePair<string, object?>("reason", PrimaryGateDenyReason()));
|
||||
Sender.Tell(new NodeWriteResult(false, reason));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1090,12 +1125,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
private void HandleRouteNativeAlarmAck(RouteNativeAlarmAck msg)
|
||||
{
|
||||
// PRIMARY GATE FIRST — only the Primary services operator acks (same signal as the inbound-write +
|
||||
// alarm-emit gates; unknown role ⇒ treated as Primary so single-node deploys + the boot window aren't blocked).
|
||||
if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached)
|
||||
// PRIMARY GATE FIRST (archreview 03/S4) — only the Primary pushes the ack upstream. Same policy as
|
||||
// the inbound-write gate. A KNOWN Secondary/Detached is dropped at Debug (unchanged); an UNKNOWN role
|
||||
// on a multi-driver cluster is dropped at WARNING (an operator ack silently not reaching the upstream
|
||||
// alarm system is a swallowed failure worth surfacing).
|
||||
if (!ShouldServiceAsPrimary())
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Cond} — not primary",
|
||||
_localNode, msg.ConditionNodeId);
|
||||
OtOpcUaTelemetry.PrimaryGateDenied.Add(1,
|
||||
new KeyValuePair<string, object?>("site", "ack"),
|
||||
new KeyValuePair<string, object?>("reason", PrimaryGateDenyReason()));
|
||||
if (_localRole is null)
|
||||
{
|
||||
_log.Warning("DriverHost {Node}: dropping native-alarm ack for {Cond} — not primary (role unknown on multi-driver cluster)",
|
||||
_localNode, msg.ConditionNodeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Cond} — not primary",
|
||||
_localNode, msg.ConditionNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1119,9 +1167,43 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.FullName, msg.Comment, msg.OperatorUser));
|
||||
}
|
||||
|
||||
/// <summary>Caches this node's <see cref="RedundancyRole"/> from a cluster redundancy snapshot so
|
||||
/// <see cref="HandleRouteNodeWrite"/> can gate inbound writes to the Primary. A snapshot that doesn't
|
||||
/// mention this node leaves the cached role unchanged ⇒ default-allow. Mirrors
|
||||
/// <summary>Whether this node should service a Primary-only data-plane operation right now — the single
|
||||
/// gate decision (archreview 03/S4), delegating to <see cref="PrimaryGatePolicy"/> with the live driver
|
||||
/// member count.</summary>
|
||||
/// <returns><c>true</c> to service as Primary; <c>false</c> to deny.</returns>
|
||||
private bool ShouldServiceAsPrimary() =>
|
||||
PrimaryGatePolicy.ShouldServiceAsPrimary(_localRole, DriverMemberCount());
|
||||
|
||||
/// <summary>Count of Up cluster members carrying the <c>driver</c> role. Uses the injected test seam when
|
||||
/// present; otherwise reads the live cluster state guarded by try/catch so a non-cluster
|
||||
/// ActorRefProvider (legacy/test harnesses) yields 0 ⇒ the single-node default-allow posture.</summary>
|
||||
/// <returns>The number of Up driver-role members, or 0 when the cluster extension is unavailable.</returns>
|
||||
private int DriverMemberCount()
|
||||
{
|
||||
if (_driverMemberCountProvider is not null) return _driverMemberCountProvider();
|
||||
try
|
||||
{
|
||||
return Akka.Cluster.Cluster.Get(Context.System).State.Members
|
||||
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up && m.Roles.Contains(ServiceCollectionExtensions.DriverRole));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0; // non-cluster ActorRefProvider (legacy harnesses) ⇒ single-node posture
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Primary-gate deny reason tag for the denial meter (<c>secondary|detached|role-unknown</c>).</summary>
|
||||
private string PrimaryGateDenyReason() => _localRole switch
|
||||
{
|
||||
RedundancyRole.Secondary => "secondary",
|
||||
RedundancyRole.Detached => "detached",
|
||||
_ => "role-unknown",
|
||||
};
|
||||
|
||||
/// <summary>Caches this node's <see cref="RedundancyRole"/> from a cluster redundancy snapshot so the
|
||||
/// Primary data-plane gates can resolve their decision. A snapshot that doesn't mention this node leaves
|
||||
/// the cached role unchanged; when this node has a real driver peer (count > 1) that omission is the
|
||||
/// 03/S5 identity-mismatch shape, logged ONCE at Warning. Mirrors
|
||||
/// <c>ScriptedAlarmHostActor.OnRedundancyStateChanged</c> / <c>OpcUaPublishActor</c>.</summary>
|
||||
private void OnRedundancyStateChanged(RedundancyStateChanged msg)
|
||||
{
|
||||
@@ -1129,6 +1211,18 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
if (local is not null)
|
||||
{
|
||||
_localRole = local.Role;
|
||||
return;
|
||||
}
|
||||
|
||||
// S5 diagnostic: the snapshot never mentioned this node. On a multi-driver cluster that means the
|
||||
// Primary gate stays default-DENY indefinitely for an unknown role — surface it once so a silent
|
||||
// identity mismatch (e.g. an ApplicationUri/NodeId skew) is diagnosable.
|
||||
if (!_warnedSnapshotMissingLocalNode && DriverMemberCount() > 1)
|
||||
{
|
||||
_warnedSnapshotMissingLocalNode = true;
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: redundancy snapshot omitted this node (snapshotNodes={SnapshotNodes}); Primary data-plane gate stays default-DENY while role is unknown on a multi-driver cluster",
|
||||
_localNode, string.Join(",", msg.Nodes.Select(n => n.NodeId)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user