docs: fill missing XML docs and strip tracking IDs from src comments

Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
This commit is contained in:
Joseph Doherty
2026-10-15 06:14:58 -04:00
parent ebcbd81fca
commit 826312befe
430 changed files with 4434 additions and 3923 deletions
@@ -85,7 +85,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
public static readonly TimeSpan DefaultApplyWedgeTimeout = TimeSpan.FromMinutes(5);
/// <summary>
/// Gitea #601 — how long an Applied ack waits for <c>OpcUaPublishActor</c>'s address-space apply
/// How long an Applied ack waits for <c>OpcUaPublishActor</c>'s address-space apply
/// outcome (<see cref="OpcUa.OpcUaPublishActor.RebuildCompleted"/>) before it is sent anyway.
/// </summary>
/// <remarks>
@@ -99,7 +99,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </remarks>
public static readonly TimeSpan DefaultApplyOutcomeTimeout = DeployTimings.ApplyOutcomeTimeout;
/// <summary>Gitea #601 — the reason prefix of a degraded ack sent because the outcome TIMED OUT, so an
/// <summary>The reason prefix of a degraded ack sent because the outcome TIMED OUT, so an
/// operator can tell "not reported in time" from a real sink failure.</summary>
public const string OutcomeNotReportedReasonPrefix = "address-space apply outcome not reported in time";
@@ -174,7 +174,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <see langword="null"/> when none was wired (per-cluster mesh Phase 4). On a driver-role node
/// this is the replicated LocalDb store (<c>LocalDbAlarmConditionStateStore</c>) so condition
/// state survives without central SQL; when null the actor skips spawning the alarm host (the
/// ConfigDb-backed EF fallback was retired in Phase 4 Task 9).
/// ConfigDb-backed EF fallback was retired).
/// </summary>
private readonly IAlarmStateStore? _alarmStateStore;
@@ -265,7 +265,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private RevisionHash? _currentRevision;
/// <summary>
/// Gitea #601 — the last address-space apply of <see cref="_currentRevision"/> reported DEGRADED (or no
/// The last address-space apply of <see cref="_currentRevision"/> reported DEGRADED (or no
/// outcome in time). A re-dispatch of that same revision then RE-APPLIES instead of taking the
/// idempotent "already at this revision" short-circuit: the short-circuit would ack a clean Applied
/// for an address space known to be partial, and the re-apply is what repairs it (a degraded
@@ -274,7 +274,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary>
private bool _currentRevisionDegraded;
/// <summary>Gitea #601 — Applied acks held until the address-space apply reports its outcome, keyed by
/// <summary>Applied acks held until the address-space apply reports its outcome, keyed by
/// the apply's correlation id (echoed back in <see cref="OpcUa.OpcUaPublishActor.RebuildCompleted"/>).</summary>
private readonly Dictionary<CorrelationId, PendingAck> _pendingAcks = new();
@@ -330,8 +330,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new();
/// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
/// equipment notifier NodeIds. Resolves a native alarm transition to the materialised Part 9 condition
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); the referencing
/// equipment notifier NodeIds are added on top. Resolves a native alarm transition to the materialised Part 9 condition
/// node(s). Alarm tags are conditions, not value variables, so they are kept OUT of the value maps +
/// value-subscription set.</summary>
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _alarmNodeIdByDriverRef = new();
@@ -408,7 +408,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// first apply — a child spawned before any apply is handed nothing, which is what it should have.
/// <para>It is only ever rebuilt by a SUCCESSFUL apply, so after a failed one it deliberately lingers on
/// the last-good composition — matching the address space, drivers and subscriptions that also keep
/// serving through a failed apply (#485/#486). A respawn or reprovision in that window therefore hands
/// serving through a failed apply. A respawn or reprovision in that window therefore hands
/// the child the last-known-good set, not an empty one.</para></summary>
private readonly Dictionary<string, DesiredSubscriptionSet> _desiredByDriver = new(StringComparer.Ordinal);
@@ -434,7 +434,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <summary>
/// Where the Primary <b>data-plane</b> verdict is published for drivers that must know it
/// directly (Gitea #511). Null on nodes that wire no redundancy manager.
/// directly. Null on nodes that wire no redundancy manager.
/// </summary>
/// <remarks>
/// Note the different verdict from <see cref="_redundancyRoleView"/>: that one carries
@@ -446,16 +446,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
/// <summary>
/// Gitea #537: where the continuous-historization <b>capture</b> verdict is published for the
/// Where the continuous-historization <b>capture</b> verdict is published for the
/// recorder, which taps the mux fan-out and so needs the decision off its own mailbox. Null on
/// nodes that wire no redundancy manager.
/// </summary>
private readonly IHistorizationRoleView? _historizationRoleView;
/// <summary>Gitea #674 review: where the HistoryUpdate verdict is published. Null on nodes that wire no redundancy manager.</summary>
/// <summary>Where the HistoryUpdate verdict is published. Null on nodes that wire no redundancy manager.</summary>
private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
/// <summary>Gitea #674 review: whether any redundancy snapshot has reached this host — the HistoryUpdate gate stays
/// <summary>Whether any redundancy snapshot has reached this host — the HistoryUpdate gate stays
/// closed until one has (see <see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/>).</summary>
private bool _redundancySnapshotReceived;
@@ -468,20 +468,20 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary>
private readonly Func<CommonsNodeId, bool>? _sharesHistorizationScopeWithLocal;
/// <summary>Gitea #672: whether an UNREACHABLE recording Primary still stands this node's historization
/// <summary>Whether an UNREACHABLE recording Primary still stands this node's historization
/// capture down (<c>false</c>) or not (<c>true</c>, the default). See <see cref="ComputeHistorizationVerdict"/>.</summary>
private readonly bool _captureWhilePrimaryUnreachable;
/// <summary>Gitea #672: the last redundancy snapshot's nodes, so a reachability change can re-derive the
/// <summary>The last redundancy snapshot's nodes, so a reachability change can re-derive the
/// historization capture verdict without waiting for the next snapshot — which, when the recording Primary
/// crashed, may not come until auto-down (the redundancy singleton usually lived on that Primary).</summary>
private IReadOnlyList<NodeRedundancyState>? _lastRedundancySnapshotNodes;
/// <summary>Gitea #672: peers the cluster failure detector currently reports unreachable (never this node).
/// <summary>Peers the cluster failure detector currently reports unreachable (never this node).
/// Fed by <see cref="Akka.Cluster.ClusterEvent.ReachabilityEvent"/>; cleared for an address that comes Up again.</summary>
private readonly HashSet<CommonsNodeId> _unreachableNodes = new();
/// <summary>Gitea #672: the unreachable recording Primary this node is currently capturing in place of, or
/// <summary>The unreachable recording Primary this node is currently capturing in place of, or
/// null — drives the once-per-transition Information log.</summary>
private CommonsNodeId? _capturingForUnreachablePrimary;
@@ -489,10 +489,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// path republishes only a CHANGED verdict.</summary>
private bool? _lastPublishedHistorizationVerdict;
/// <summary>Whether this actor holds its own (#672) cluster reachability subscription.</summary>
/// <summary>Whether this actor holds its own cluster reachability subscription.</summary>
private bool _subscribedToReachabilityEvents;
/// <summary>Gitea #601 — how long an Applied ack waits for the address-space outcome (see
/// <summary>How long an Applied ack waits for the address-space outcome (see
/// <see cref="DefaultApplyOutcomeTimeout"/>).</summary>
private readonly TimeSpan _applyOutcomeTimeout;
@@ -511,7 +511,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// re-publishes, and read once by <see cref="CommsRecoveryAudit"/> after the driver reconnects.
/// <para><b>It is a tripwire, not a mechanism.</b> Nothing depends on it being correct for the data
/// plane to behave: the host publishes nothing Good on reconnect either way. What it buys is that a
/// driver which reconnects and then fails to re-publish — the pre-STAB-18 MQTT shape, and what any
/// driver which reconnects and then fails to re-publish — the MQTT shape, and what any
/// future push driver without an initial push will do — produces a Warning and a metered count
/// instead of a subscription that silently reports Bad forever.</para>
/// <para>Keyed by RawPath, so a raw node and its UNS mirrors count once: the operator is being told
@@ -538,7 +538,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private readonly string? _replicationPeerHost;
/// <summary>
/// Review R6 MINOR-4: identifies the alarm-queue-sharing peer when <see cref="_replicationPeerHost"/> is
/// Identifies the alarm-queue-sharing peer when <see cref="_replicationPeerHost"/> is
/// unknown — the passive (listening) half of a pair, which replicates the same rows but dials nobody. Before
/// this the passive half had no peer to defer to, so as Secondary it kept draining alongside its Primary
/// partner and every alarm event reached the historian twice. Null ⇒ no peer known ⇒ never stand down.
@@ -636,8 +636,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
/// production (the real host is spawned).</param>
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since Phase 4
/// Task 9 retired the <c>EfAlarmConditionStateStore</c> fallback that consumed it. Defaults to null.</param>
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since the
/// <c>EfAlarmConditionStateStore</c> fallback that consumed it was retired. Defaults to null.</param>
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
/// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
/// unspawned — the graceful dev/None-deployment path.</param>
@@ -648,21 +648,47 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <param name="invokerFactory">Optional 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 not supplied.</param>
/// <param name="driverMemberCountProvider">Test seam: overrides the count of Up <c>driver</c>-role cluster
/// members the Primary gate reads while the role is unknown. When null the cluster state is read.</param>
/// <param name="deploymentArtifactCache">Optional node-local cache of applied deployment artifacts, so the node
/// can boot from its last-known-good configuration while central SQL is unreachable. Null skips caching.</param>
/// <param name="redundancyRoleView">Where the node's redundancy role is published and read. Null on nodes
/// that wire no redundancy manager.</param>
/// <param name="replicationPeerHost">Host of the LocalDb replication peer this node dials, used to identify the
/// partner that shares its alarm queue. Null when the node dials nobody.</param>
/// <param name="fetchAndCacheMode">When <c>true</c>, the node never reads central SQL for configuration and instead
/// fetches artifacts through <paramref name="artifactFetcher"/> and boots from its local cache.</param>
/// <param name="artifactFetcher">Fetches deployment artifacts from central over gRPC in
/// <paramref name="fetchAndCacheMode"/>. Null when that mode is off.</param>
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
/// driver-role node this is the replicated LocalDb store, so condition state persists with no
/// ConfigDb; null skips spawning the alarm host (the ConfigDb-backed EF fallback was retired in
/// Phase 4 Task 9). Defaults to null.</param>
/// <param name="telemetryHub">Per-cluster mesh Phase 5: optional node-local live-telemetry hub each
/// ConfigDb; null skips spawning the alarm host (the ConfigDb-backed EF fallback was retired).
/// Defaults to null.</param>
/// <param name="telemetryHub">Optional node-local live-telemetry hub each
/// Primary-gated native-alarm transition is also emitted into, and which is threaded into the spawned
/// VirtualTag + ScriptedAlarm hosts. Defaults to null (admin-only nodes / tests skip the emit).</param>
/// <param name="primaryRoleSource">Where the Primary data-plane verdict is published, for drivers that assert a
/// standing exclusive identity on a shared external system. Null on nodes that wire no redundancy manager.</param>
/// <param name="historizationRoleView">Where the continuous-historization capture verdict is published. Null on
/// nodes that wire no redundancy manager.</param>
/// <param name="sharesHistorizationScopeWithLocal">Tests whether a node named in a redundancy snapshot serves this
/// node's address space and therefore records the same historized refs. Null means this node never stands down.</param>
/// <param name="applyWedgeTimeout">How long the host may sit in <see cref="Applying"/> before the
/// wedge watchdog logs a Warning. Defaults to <see cref="DefaultApplyWedgeTimeout"/>.</param>
/// <param name="reprovisionBackoff">Backoff before a failed driver re-provision is retried. Null uses the default.</param>
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; tests shorten it.</param>
/// <param name="applyOutcomeTimeout">Gitea #601: how long an Applied ack waits for the address-space
/// <param name="sharesAlarmQueueWithLocal">Tests whether a node named in a redundancy snapshot holds this node's
/// replicated alarm queue, for a node that knows no <paramref name="replicationPeerHost"/>. Null means no such
/// peer is known.</param>
/// <param name="applyOutcomeTimeout">How long an Applied ack waits for the address-space
/// apply outcome before it is sent anyway, marked degraded ("outcome unknown"). Defaults to
/// <see cref="DefaultApplyOutcomeTimeout"/>; tests shorten it.</param>
/// <param name="captureWhilePrimaryUnreachable">When <c>true</c> (default), a node stops deferring historization
/// capture to a mesh-partner Primary the cluster failure detector reports unreachable.</param>
/// <param name="historyUpdateRoleView">Where the OPC UA HistoryUpdate verdict is published for the node manager's
/// HistoryUpdate Primary gate. Null on nodes that wire no redundancy manager.</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
@@ -729,8 +755,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// for historized VirtualTag results; defaults to <see cref="NullHistoryWriter"/>.</param>
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since Phase 4
/// Task 9 retired the <c>EfAlarmConditionStateStore</c> fallback that consumed it. Defaults to null.</param>
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since the
/// <c>EfAlarmConditionStateStore</c> fallback that consumed it was retired. Defaults to null.</param>
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
/// host; when null the host is left unspawned.</param>
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
@@ -745,19 +771,27 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// When supplied, each successful apply stores its artifact so the node can boot from its
/// last-known-good configuration while central SQL is unreachable. Null on admin-only nodes and in
/// tests that do not exercise the cache — caching is then simply skipped.</param>
/// <param name="redundancyRoleView">Where the node's redundancy role is published and read. Null on nodes
/// that wire no redundancy manager.</param>
/// <param name="replicationPeerHost">Host of the LocalDb replication peer this node dials, used to identify the
/// partner that shares its alarm queue. Null when the node dials nobody.</param>
/// <param name="fetchAndCacheMode">When <c>true</c>, the node never reads central SQL for configuration and instead
/// fetches artifacts through <paramref name="artifactFetcher"/> and boots from its local cache.</param>
/// <param name="artifactFetcher">Fetches deployment artifacts from central over gRPC in
/// <paramref name="fetchAndCacheMode"/>. Null when that mode is off.</param>
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
/// driver-role node this is the replicated LocalDb store; null skips spawning the alarm host (the
/// ConfigDb-backed EF fallback was retired in Phase 4 Task 9).</param>
/// <param name="telemetryHub">Per-cluster mesh Phase 5: optional node-local live-telemetry hub each
/// ConfigDb-backed EF fallback was retired).</param>
/// <param name="telemetryHub">Optional node-local live-telemetry hub each
/// Primary-gated native-alarm transition is also emitted into, and which is threaded into the spawned
/// VirtualTag + ScriptedAlarm hosts. Null (admin-only nodes / tests) skips the emit and passes null down.</param>
/// <param name="primaryRoleSource">Gitea #511: where the Primary <i>data-plane</i> verdict is published
/// <param name="primaryRoleSource">Where the Primary <i>data-plane</i> verdict is published
/// for drivers that assert a standing exclusive identity on a shared external system — today the MQTT
/// driver's Sparkplug primary-host STATE claim. Deliberately a separate parameter from
/// <paramref name="redundancyRoleView"/> even though the production object implements both: a test that
/// supplies only a role-view double must not silently also satisfy this, or its primary-role assertions
/// pass without the publish ever happening. Null on nodes that wire no redundancy manager.</param>
/// <param name="historizationRoleView">Gitea #537: where the continuous-historization <i>capture</i>
/// <param name="historizationRoleView">Where the continuous-historization <i>capture</i>
/// verdict is published, for the recorder that taps the mux fan-out. A third distinct parameter for
/// the same reason <paramref name="primaryRoleSource"/> is a second — the production object
/// implements all three, and a test double supplied for one must not silently satisfy the others.
@@ -769,21 +803,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <param name="applyWedgeTimeout">How long the host may sit in <see cref="Applying"/> before the
/// wedge watchdog logs a Warning. Defaults to <see cref="DefaultApplyWedgeTimeout"/>; a test shortens
/// it so the watchdog is observable in a test's lifetime.</param>
/// <param name="reprovisionBackoff">Backoff before a failed driver re-provision is retried. Null uses the default.</param>
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; a test shortens it for the same reason.</param>
/// <param name="sharesAlarmQueueWithLocal">Review R6 MINOR-4: tests whether a node named in a redundancy
/// <param name="sharesAlarmQueueWithLocal">Tests whether a node named in a redundancy
/// snapshot holds this node's replicated alarm queue, for a node that knows no
/// <paramref name="replicationPeerHost"/> — the PASSIVE half of a LocalDb pair (ApiKey + SyncListenPort, no
/// PeerAddress), whose partner is its per-cluster mesh partner. Ignored when
/// <paramref name="replicationPeerHost"/> is set. Null ⇒ no such peer is known and this node never stands its
/// drain down.</param>
/// <param name="captureWhilePrimaryUnreachable">Gitea #672 (<c>ContinuousHistorization:CaptureWhilePrimaryUnreachable</c>,
/// <param name="applyOutcomeTimeout">How long an Applied ack waits for the address-space apply outcome before it is
/// sent anyway, marked degraded ("outcome unknown"). Defaults to <see cref="DefaultApplyOutcomeTimeout"/>; tests
/// shorten it.</param>
/// <param name="captureWhilePrimaryUnreachable">(<c>ContinuousHistorization:CaptureWhilePrimaryUnreachable</c>,
/// default <c>true</c>): when a mesh-partner Primary this node would stand its historization capture down for is
/// reported UNREACHABLE by the cluster failure detector, stop deferring to it and capture — rather than waiting
/// for auto-down + a fresh redundancy snapshot. Affects ONLY the historization capture verdict; the alarm drain
/// and the Primary data-plane verdict are unchanged. <c>false</c> restores the pre-#672 behaviour.</param>
/// <param name="historyUpdateRoleView">Gitea #674 review: where the OPC UA HistoryUpdate verdict
/// and the Primary data-plane verdict are unchanged. <c>false</c> restores the earlier behaviour.</param>
/// <param name="historyUpdateRoleView">Where the OPC UA HistoryUpdate verdict
/// (<see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> — the data-plane verdict, but closed until the first
/// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no
/// redundancy manager.</param>
@@ -898,12 +936,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
/// <summary>
/// Review R1 m2 (Gitea #597): the published Primary data-plane verdict (<see cref="IDriverPrimaryRoleSource"/>
/// The published Primary data-plane verdict (<see cref="IDriverPrimaryRoleSource"/>
/// — the native-ack gate and the Sparkplug primary-host claim) must mirror <see cref="ShouldServiceAsPrimary"/>
/// from the moment this host starts, not only from the first redundancy snapshot. Before this, the view sat
/// on its single-node seed (allow) through the whole boot window, so on a pair BOTH nodes accepted a native
/// ack while this host — which evaluates the member count live — dropped the upstream route on the
/// non-Primary: the acked → unacked revert #597 targets. So publish once now, and again whenever cluster
/// non-Primary: the acked → unacked revert that the Primary gate prevents. So publish once now, and again whenever cluster
/// membership changes (a driver peer coming Up turns an unknown role from allow into deny before any
/// snapshot names this node). A non-cluster ActorRefProvider (legacy/test harnesses) just keeps the
/// initial publish.
@@ -915,7 +953,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// normally seconds, up to <c>Cluster:BootstrapGuard:PartnerProbeSeconds</c> (25 s) under the
/// bootstrap guard. In that pre-join window the Sparkplug primary-host claim can be held on both nodes of
/// a pair. Native ack is NOT exposed by it: the live route gate reads the same count and also allows, so
/// the view and the route still agree and the acked → unacked revert cannot occur (review R6 MINOR-2).
/// the view and the route still agree and the acked → unacked revert cannot occur.
/// Denying until self is Up was considered and NOT done: it would change the live data-plane gate (the
/// two must stay one decision) and so delay writes and acks on a genuinely single-node cluster during its
/// own join, and this actor has no reliable way to know that a partner is configured.
@@ -944,10 +982,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// own subscriber AFTER this event was delivered here — the two are not ordered.</summary>
private void OnDriverMembershipChanged(Akka.Cluster.ClusterEvent.IMemberEvent evt)
{
// Gitea #672: a member that comes Up at an address is reachable — the usual case being a restarted
// A member that comes Up at an address is reachable — the usual case being a restarted
// node's new incarnation. A REMOVED member deliberately stays in the set: it never answers again, and
// dropping it would re-derive the verdict from a cached snapshot that may still name it Primary,
// closing the gate on a dead node until the next snapshot (the very hole #672 closes).
// closing the gate on a dead node until the next snapshot (the very hole the reachability tracking closes).
if (evt is Akka.Cluster.ClusterEvent.MemberUp up
&& _unreachableNodes.Remove(ToNodeId(up.Member.Address)))
{
@@ -979,7 +1017,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
PublishPrimaryVerdict();
}
/// <summary>Gitea #674 review: publishes <see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> (no-op when no view
/// <summary>Publishes <see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> (no-op when no view
/// is wired). Rides every Primary-verdict publish point — host start, every snapshot, membership changes — so it is
/// closed from host start and opens only once a snapshot has arrived and the data-plane verdict allows.</summary>
private void PublishHistoryUpdateVerdict() =>
@@ -987,7 +1025,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
PrimaryGatePolicy.ShouldServeHistoryUpdate(_redundancySnapshotReceived, _localRole, DriverMemberCount()));
/// <summary>
/// Gitea #672: follow cluster reachability so the historization capture gate stops deferring to a recording
/// Follow cluster reachability so the historization capture gate stops deferring to a recording
/// Primary the failure detector already considers dead. Its OWN subscription, deliberately not folded into
/// <see cref="StartPrimaryVerdictPublishing"/>, which early-returns on nodes with no primary-role source.
/// <see cref="Akka.Cluster.ClusterEvent.MemberUp"/> rides along so a re-joined incarnation leaves the set.
@@ -1009,7 +1047,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
/// <summary>Gitea #672: a peer became unreachable or reachable again. Records it and re-derives ONLY the
/// <summary>A peer became unreachable or reachable again. Records it and re-derives ONLY the
/// historization capture verdict from the cached snapshot — the alarm drain and the Primary data-plane verdict
/// are deliberately untouched (a false allow there is a dual claimant, not a duplicate sample).</summary>
private void OnReachabilityChanged(Akka.Cluster.ClusterEvent.ReachabilityEvent evt)
@@ -1124,7 +1162,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// Per-cluster mesh Phase 4: condition state lives in the wired store — the replicated LocalDb
// store on a driver-role node, which persists with no ConfigDb. The ConfigDb-backed EF fallback
// was retired (Task 9); when no store was wired there is nowhere to persist condition state, so
// was retired; when no store was wired there is nowhere to persist condition state, so
// we skip the alarm host outright rather than run the engine against a store it can never save to
// (mirrors the _opcUaPublishActor-null skip above).
if (_alarmStateStore is null)
@@ -1267,7 +1305,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// cache boot, but this is the steady-state config source, not an outage fallback.
if (!ApplyCachedArtifact(deploymentId, cached.Artifact))
{
// #601 / review R1 m4: unusable cached bytes applied nothing, so they must not become the
// Unusable cached bytes applied nothing, so they must not become the
// current revision — that would ack a re-dispatch of the same revision as already applied
// without ever applying it. Steady-with-no-revision, exactly as for an empty cache.
_log.Warning(
@@ -1343,7 +1381,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
var deploymentId = DeploymentId.Parse(cached.DeploymentId);
var revision = RevisionHash.Parse(cached.RevisionHash);
// #601 / review R1 m4: unusable cached bytes applied nothing — the node is NOT serving a
// Unusable cached bytes applied nothing — the node is NOT serving a
// configuration, so it must not claim RUNNING FROM CACHE, adopt the revision (a re-dispatch of
// it would then be acked without being applied) or skip Stale's ConfigDb retry loop.
if (!ApplyCachedArtifact(deploymentId, cached.Artifact))
@@ -1389,12 +1427,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// depend on central.
/// </remarks>
/// <returns><see langword="false"/> when the bytes were empty or malformed and nothing was applied — the
/// caller must not adopt the cached revision (#601, review R1 m4).</returns>
/// caller must not adopt the cached revision.</returns>
private bool ApplyCachedArtifact(DeploymentId deploymentId, byte[] blob)
{
var correlation = CorrelationId.NewId();
// #601: null = the cached bytes were empty or malformed — no answer, not an empty configuration.
// Null = the cached bytes were empty or malformed — no answer, not an empty configuration.
// Keep last-known-good: no address-space rebuild and no subscription push from unusable bytes.
if (ReconcileDriversFromBlob(deploymentId, blob) is null)
return false;
@@ -1447,7 +1485,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private void Steady()
{
Receive<DispatchDeployment>(HandleDispatchFromSteady);
// #601 — the held Applied ack is released by the address-space outcome (or its timeout); both can
// The held Applied ack is released by the address-space outcome (or its timeout); both can
// land in any behaviour, so all three register them.
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
@@ -1563,7 +1601,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary>
private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg)
{
// One envelope per burst; the mux filters per item (it registered a batch receive in Task 9).
// One envelope per burst; the mux filters per item (it registered a batch receive).
_dependencyMux?.Tell(msg);
// 03/S15 recovery audit: clear whatever this burst re-published from the driver's Bad latch. Guarded
@@ -1628,7 +1666,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </para>
/// </summary>
/// <summary>
/// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native
/// A child driver's connectivity transition. Annotates the source-data Quality of EVERY native
/// alarm condition the driver owns: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>, restored →
/// <see cref="OpcUaStatus.Good"/>. This is the ONLY signal for a comms-lost native source, because a
/// disconnected driver emits no alarm transitions — the alarm feed goes silent, so without this a
@@ -1642,8 +1680,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// (<see cref="IDriverHealthPublisher"/>); a row per condition would be alarm-fatigue.
/// </para>
/// <para>
/// <b>#481 (Layer 4) — the same transition also reaches SCRIPTED alarms, via the dependency
/// mux.</b> #478 gave a scripted condition the worst quality of its input tags, but only for
/// <b>Layer 4 — the same transition also reaches SCRIPTED alarms, via the dependency
/// mux.</b> A scripted condition already got the worst quality of its input tags, but only for
/// inputs whose driver actually <i>publishes</i> a Bad data change. A poll driver whose device
/// goes unreachable publishes nothing at all — it emits this signal and goes silent — so the
/// engine kept its last Good value, <c>WorstInputStatus</c> stayed Good, and worse,
@@ -1664,13 +1702,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary>
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
{
// #481: the mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no
// The mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no
// publish actor (the mux-only test/dev path) must still see comms loss. Done before the early
// return for exactly that reason.
if (!msg.Connected)
{
FanCommsLossToDependencyMux(msg.DriverInstanceId);
// 03/S15: and the EXTERNAL data plane, which #481 stopped one seam short of.
// And the EXTERNAL data plane, which the dependency-mux fan-out stopped one seam short of.
FanCommsLossToValueNodes(msg.DriverInstanceId);
}
else
@@ -1682,7 +1720,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
if (_opcUaPublishActor is null) return;
// #519: name the actual cause. This is a connectivity transition, so the Bad case is
// Name the actual cause. This is a connectivity transition, so the Bad case is
// BadNoCommunication — the bare OpcUaQuality.Bad it used to send reached clients as 0x80000000,
// indistinguishable from any other fault.
var statusCode = msg.Connected ? OpcUaStatus.Good : OpcUaStatus.BadNoCommunication;
@@ -1718,7 +1756,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary>
/// <remarks>
/// <para>
/// <b>Why it was missing.</b> #481 built the per-driver ref index and wired comms loss to the
/// <b>Why it was missing.</b> The per-driver ref index was built and wired comms loss to the
/// internal consumers (the mux) and to Part 9 conditions, and stopped one seam short of the
/// external OPC UA data plane. A client subscribed to a value node therefore saw the last value
/// at <b>Good</b> quality for the whole outage, for every driver whose failure shape is "the
@@ -1832,7 +1870,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// observed — the very thing this whole path refuses to do. What the operator gets instead is a
/// named driver and a count, plus the <c>otopcua.driver.comms_latched_refs</c> counter, which is
/// the operator-visible signature of a driver that reconnects without re-publishing (the
/// pre-STAB-18 MQTT shape, or any future push driver that forgets its initial push).
/// MQTT shape, or any future push driver that forgets its initial push).
/// </para>
/// <para>
/// The latch is cleared afterwards either way: it has been reported, and leaving it would make
@@ -1865,13 +1903,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
/// <summary>
/// #481 (Layer 4) — push a synthetic <see cref="OpcUaStatus.BadNoCommunication"/> into the dependency
/// Layer 4 — push a synthetic <see cref="OpcUaStatus.BadNoCommunication"/> into the dependency
/// mux for every value ref the just-disconnected driver owns, so scripted alarms and virtual tags see
/// the source die instead of holding its last Good value indefinitely.
/// </summary>
/// <remarks>
/// <para>
/// <b>The ref set already existed.</b> #481 asked for a driver → all-tag-refs map to be built;
/// <b>The ref set already existed.</b> The fan-out needed a driver → all-tag-refs map to be built;
/// <see cref="_nodeIdByDriverRef"/> is exactly that, keyed <c>(DriverInstanceId, RawPath)</c> and
/// rebuilt from the composition's <c>RawTags</c> ∪ <c>UnsReferenceVariables</c> on every apply —
/// read here through the per-driver <see cref="_valueRefsByDriver"/> index, since that composite
@@ -1884,7 +1922,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <b>Value <c>null</c>, not the last-known value.</b> The honest statement is "there is no
/// current value", and the engine's <c>AreInputsReady</c> gate holds the predicate on any Bad
/// regardless, so no script observes the null. It does mean the Bad must survive
/// <c>WorstInputStatus</c>, which until #481 skipped every null-valued entry as cold start —
/// <c>WorstInputStatus</c>, which used to skip every null-valued entry as cold start —
/// hence the paired change stamping never-pushed paths <c>BadWaitingForInitialData</c> so the two
/// are tellable apart. Without that half this fan-out would be silently inert.
/// </para>
@@ -1953,8 +1991,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return;
}
// #626 — a QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
// transition. It rides the SAME quality-only node path the #477 connectivity annotation uses
// A QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
// transition. It rides the SAME quality-only node path the connectivity annotation uses
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
// snapshot: the projector never sees a client's acknowledgement (that is applied to the node by the
// SDK) and forgets a condition a deploy removed, so a projected "keep the state" can disagree with
@@ -1995,11 +2033,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). The projector
// decides WHICH rows the transition warrants: none for a re-statement (Gitea #647 review: a Raise on an
// already-active condition from a respawned driver; #656: a flagged snapshot/first-sight Retrigger or
// Clear that changes nothing the condition shows), and up to two for an ActiveAcknowledged (#656:
// decides WHICH rows the transition warrants: none for a re-statement (a Raise on an
// already-active condition from a respawned driver; a flagged snapshot/first-sight Retrigger or
// Clear that changes nothing the condition shows), and up to two for an ActiveAcknowledged (
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked) or an
// InactiveUnacknowledged (#657: "Activated" + "Cleared" only if it was acked, "Cleared" if it was active).
// InactiveUnacknowledged ("Activated" + "Cleared" only if it was acked, "Cleared" if it was active).
if (!serviceAlertsAsPrimary) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
@@ -2023,9 +2061,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
(string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva, IReadOnlyList<string> ReferencingEquipmentPaths) meta,
AlarmTransitionKind historyKind)
{
// #656: an ActiveAcknowledged expands to "Activated" + "Acknowledged". The operator comment/user belong to
// An ActiveAcknowledged expands to "Activated" + "Acknowledged". The operator comment/user belong to
// the acknowledgement, and the activation happened at the original raise time when the source knows it.
// #657: an InactiveUnacknowledged's "Activated" row is likewise the occurrence, at its raise time when known.
// An InactiveUnacknowledged's "Activated" row is likewise the occurrence, at its raise time when known.
var expandedActivation = (args.Kind == AlarmTransitionKind.ActiveAcknowledged
&& historyKind != AlarmTransitionKind.Acknowledge)
|| (args.Kind == AlarmTransitionKind.InactiveUnacknowledged && historyKind == AlarmTransitionKind.Raise);
@@ -2051,7 +2089,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
HistorizeToAveva: meta.HistorizeToAveva,
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
// The Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
// renders them as the equipment list (empty for a raw condition no equipment references yet).
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths);
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, alertEvent));
@@ -2071,9 +2109,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => "Activated",
AlarmTransitionKind.Clear => "Cleared",
AlarmTransitionKind.Acknowledge => "Acknowledged",
// #656: never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
// Never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
AlarmTransitionKind.ActiveAcknowledged => "Acknowledged",
// #657: likewise never reached — expanded into Raise + Clear (or Clear alone) rows.
// Likewise never reached — expanded into Raise + Clear (or Clear alone) rows.
AlarmTransitionKind.InactiveUnacknowledged => "Cleared",
_ => "Activated",
};
@@ -2215,7 +2253,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return;
}
// Gitea #641 review — the node is already acknowledged (the SDK applies the client's ack); tell the
// The node is already acknowledged (the SDK applies the client's ack); tell the
// projector too, or the next driver Clear projects (Active=false, Acked=prev=false) and un-acknowledges it.
// Most sources (MTConnect, FOCAS) send no Acknowledge transition back, and the projector now survives a
// deploy rather than resetting to its acked default.
@@ -2250,8 +2288,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
if (_driverMemberCountProvider is not null) return _driverMemberCountProvider();
try
{
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
// 'Driver' (rolling-upgrade window) must still count toward the R2-04 primary-gate member
// GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
// 'Driver' (rolling-upgrade window) must still count toward the primary-gate member
// count, or a genuine 2-node pair silently takes the single-node allow branch.
return Akka.Cluster.Cluster.Get(Context.System).State.Members
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up
@@ -2264,7 +2302,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
/// <summary>Whether <paramref name="nodeId"/> holds this node's replicated alarm queue: the PeerAddress host when
/// this node dials, else the passive-half predicate (review R6 MINOR-4). This node itself never counts.</summary>
/// this node dials, else the passive-half predicate. This node itself never counts.</summary>
private bool SharesAlarmQueue(CommonsNodeId nodeId) =>
nodeId != _localNode
&& (_replicationPeerHost is not null
@@ -2322,15 +2360,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// 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.
//
// The dialling half knows its peer by PeerAddress host. The passive half (review R6 MINOR-4) knows it
// The dialling half knows its peer by PeerAddress host. The passive half knows it
// only as its mesh partner — the predicate — and must be gated too, or both halves drain the same rows.
var peerIsPrimary = msg.Nodes.Any(n => n.Role == RedundancyRole.Primary && SharesAlarmQueue(n.NodeId));
_redundancyRoleView?.Publish(
PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary));
// Gitea #537: the continuous-historization CAPTURE verdict. Same role rule as the drain gate
// above, but a different peer question — see ComputeHistorizationVerdict. Cached (#672) so a
// The continuous-historization CAPTURE verdict. Same role rule as the drain gate
// above, but a different peer question — see ComputeHistorizationVerdict. Cached so a
// reachability change can re-derive it before the next snapshot arrives.
_lastRedundancySnapshotNodes = msg.Nodes;
if (_historizationRoleView is not null)
@@ -2342,13 +2380,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// The DATA-PLANE verdict, deliberately — the same one HandleRouteNodeWrite gates on, not the
// drain verdict published immediately above. A driver reading this claims an exclusive identity
// on a shared external system (Gitea #511: the Sparkplug host id), where two claimants is the
// on a shared external system (the Sparkplug host id), where two claimants is the
// harm, so an unknown role with a driver peer present must deny.
PublishPrimaryVerdict();
}
/// <summary>
/// The continuous-historization capture verdict for a snapshot (Gitea #537, #672).
/// The continuous-historization capture verdict for a snapshot.
/// </summary>
/// <remarks>
/// <para>Historization is not pair-local, it is fleet-wide by historian tag name, so the only node worth
@@ -2356,12 +2394,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// no way to establish that (a legacy node carrying no cluster-scoped role, or a test harness), the
/// predicate is null and this node keeps capturing. Duplicate samples are recoverable; a hole in the trend
/// is not.</para>
/// <para><b>#672 — an UNREACHABLE recording Primary does not count.</b> A crashed (SIGKILLed) Primary stays a
/// <para><b>An UNREACHABLE recording Primary does not count.</b> A crashed (SIGKILLed) Primary stays a
/// cluster member until auto-down, and the redundancy singleton usually died with it, so the cached snapshot
/// keeps naming it Primary for the whole failure-detector + auto-down window (~25 s) while nobody captures.
/// Excluding unreachable peers shrinks that hole to the failure-detector window alone. The cost — a short
/// duplicate window, and both nodes capturing for the duration of a genuine partition — is the same
/// asymmetric trade #537 already makes. Gated by <c>ContinuousHistorization:CaptureWhilePrimaryUnreachable</c>.</para>
/// asymmetric trade the capture gate already makes. Gated by <c>ContinuousHistorization:CaptureWhilePrimaryUnreachable</c>.</para>
/// </remarks>
private bool ComputeHistorizationVerdict(IReadOnlyList<NodeRedundancyState> nodes)
{
@@ -2434,7 +2472,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
// #601 — an apply that completed just before the host went Stale still owes its ack; the
// An apply that completed just before the host went Stale still owes its ack; the
// coordinator is not behind the ConfigDb, so release it exactly as in Steady.
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
@@ -2480,7 +2518,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private void HandleDispatchFromSteady(DispatchDeployment msg)
{
// #601: the short-circuit claims a CLEAN Applied, so it is only honest while the current revision's
// The short-circuit claims a CLEAN Applied, so it is only honest while the current revision's
// address space is known-good — not after a degraded apply, and not while its outcome is still
// pending. Either way the dispatch re-applies instead, which is also what repairs a degraded apply.
var currentRevisionUnconfirmed = _currentRevisionDegraded
@@ -2547,7 +2585,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_log.Debug("DriverHost {Node}: fetching artifact for {Id} (rev {Rev}) from central over gRPC",
_localNode, deploymentId, revision);
// A faulted fetch task maps to null bytes — the #485 apply-failure path, exactly like an
// A faulted fetch task maps to null bytes — the apply-failure path, exactly like an
// all-endpoints-down fetch — so a fault can never escape as an unhandled actor message.
_artifactFetcher.FetchAsync(deploymentId.ToString(), revision.Value, CancellationToken.None)
.PipeTo(
@@ -2559,7 +2597,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
/// <summary>
/// Perf roadmap Task 26 (<c>Direct</c>). Kicks off the central-SQL artifact read on a pool
/// Direct apply path. Kicks off the central-SQL artifact read on a pool
/// thread and pipes the bytes back to <see cref="Self"/> as an <see cref="ArtifactForApply"/>,
/// the same shape <see cref="BeginFetchAndCacheApply"/> has always had for its gRPC fetch. The
/// apply itself completes on the actor thread in <see cref="HandleArtifactForApply"/>.
@@ -2573,7 +2611,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// source ever should — everything downstream of "bytes in hand" is one continuation.
/// </para>
/// <para>
/// The bytes come back as a MESSAGE, never as an escaping fault (#491): the task closes over
/// The bytes come back as a MESSAGE, never as an escaping fault: the task closes over
/// <see cref="_dbFactory"/>, <see cref="_localNode"/> and <see cref="_log"/> and touches
/// neither <see cref="Self"/> nor <c>Context</c>, <see cref="LoadArtifactBlob"/> already
/// swallows its own DB failures into a null return, and the failure mapper turns anything it
@@ -2607,7 +2645,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <summary>
/// Completes an apply once the artifact bytes are in hand — the ONE continuation both modes
/// share, whether the bytes were read from the ConfigDb or fetched from central. Null bytes are
/// an apply FAILURE (keep last-known-good, do not advance the revision — #485/#486); non-null
/// an apply FAILURE (keep last-known-good, do not advance the revision); non-null
/// bytes are applied from hand and cached.
/// </summary>
private void HandleArtifactForApply(ArtifactForApply msg)
@@ -2631,7 +2669,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
ParsedDeploymentArtifact? parsed = null;
try
{
// #485/#486: null bytes = "no answer" — the ConfigDb was unreachable or the row is
// Null bytes = "no answer" — the ConfigDb was unreachable or the row is
// missing/empty (Direct), or every endpoint was unreachable / NotFound / SHA mismatch
// (FetchAndCache). Either way NOTHING was applied, so fail the apply and stay on the
// current revision: HandleDispatchFromSteady short-circuits on a revision match, so
@@ -2658,7 +2696,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_currentRevision = msg.Revision;
UpsertNodeDeploymentState(msg.DeploymentId, NodeDeploymentStatus.Applied, failureReason: null);
// Gitea #601: the Applied ack is NOT sent here any more. It used to go out right now, before the
// The Applied ack is NOT sent here any more. It used to go out right now, before the
// address-space apply below had even been dequeued, so a degraded apply (swallowed sink faults,
// _lastApplied not advanced) still sealed the deployment green fleet-wide. The ack is now held
// until OpcUaPublishActor replies RebuildCompleted (HandleRebuildCompleted) or the outcome
@@ -2700,7 +2738,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
catch (Exception ex)
{
// #601: a throw after the Applied ack was put on hold — the Failed ack below is this apply's one
// A throw after the Applied ack was put on hold — the Failed ack below is this apply's one
// answer, so the held Applied must never follow it when the address-space outcome lands.
DropPendingAck(msg.Correlation);
UpsertNodeDeploymentState(msg.DeploymentId, NodeDeploymentStatus.Failed, ex.Message);
@@ -2723,19 +2761,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
/// <summary>Gitea #601 — an Applied ack held until the address-space apply reports its outcome.</summary>
/// <summary>An Applied ack held until the address-space apply reports its outcome.</summary>
private sealed record PendingAck(DeploymentId DeploymentId, RevisionHash Revision, CorrelationId Correlation);
/// <summary>Gitea #601 — self-timer: the address-space outcome for this correlation did not arrive in time.</summary>
/// <summary>Self-timer: the address-space outcome for this correlation did not arrive in time.</summary>
private sealed record ApplyOutcomeTimedOut(CorrelationId Correlation);
private static string ApplyOutcomeTimerKey(CorrelationId correlation) => $"apply-outcome-{correlation}";
/// <summary>
/// Gitea #601 — holds the Applied ack for an apply whose revision has just advanced until the
/// Holds the Applied ack for an apply whose revision has just advanced until the
/// address-space apply outcome is known, arming <see cref="_applyOutcomeTimeout"/> as the backstop.
/// With no publish actor wired (tests, a node with no OPC UA data plane) there is no outcome to wait
/// for, so the ack is sent clean at once — byte-for-byte the pre-#601 behaviour.
/// for, so the ack is sent clean at once — byte-for-byte the earlier behaviour.
/// </summary>
private void AwaitAddressSpaceOutcomeThenAck(DeploymentId deploymentId, RevisionHash revision, CorrelationId correlation)
{
@@ -2754,7 +2792,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Timers.StartSingleTimer(ApplyOutcomeTimerKey(correlation), new ApplyOutcomeTimedOut(correlation), _applyOutcomeTimeout);
}
/// <summary>Gitea #601 — forgets a held ack (its apply failed after all, and acks Failed instead).</summary>
/// <summary>Forgets a held ack (its apply failed after all, and acks Failed instead).</summary>
private void DropPendingAck(CorrelationId correlation)
{
if (_pendingAcks.Remove(correlation))
@@ -2762,7 +2800,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
/// <summary>
/// Gitea #601 — the address-space apply reported its outcome: release the held ack, clean or
/// The address-space apply reported its outcome: release the held ack, clean or
/// degraded. Registered in every behaviour (Steady, Applying, Stale) because the reply can land in
/// any of them — the host returns to Steady before the publish actor has even dequeued the rebuild.
/// </summary>
@@ -2780,7 +2818,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
/// <summary>
/// Gitea #601 — the address-space outcome did not arrive within <see cref="_applyOutcomeTimeout"/>.
/// The address-space outcome did not arrive within <see cref="_applyOutcomeTimeout"/>.
/// The ack is sent anyway so the coordinator does not time the deployment out on a node that DID
/// advance its revision — but degraded, never clean: nobody has confirmed the address space.
/// </summary>
@@ -2801,7 +2839,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
if (degraded)
{
// #601 review — the apply wrote this node's own row Applied/null before the outcome was known. Central
// The apply wrote this node's own row Applied/null before the outcome was known. Central
// adds the reason from the ack only when it ACCEPTS the ack (a timed-out or superseded deployment's ack
// is discarded), so a node with a ConfigDb records it itself; a driver-only node no-ops here.
UpsertNodeDeploymentState(pending.DeploymentId, NodeDeploymentStatus.Applied,
@@ -2872,7 +2910,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
/// children. Spawn missing, respawn on config change, stop removed/disabled drivers.
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) the reconcile is
/// SKIPPED outright — see the issue #485 guard below — and when the configured
/// SKIPPED outright — see the empty-artifact guard below — and when the configured
/// <see cref="IDriverFactory"/> can't materialise any of the requested types this is a no-op.
/// </summary>
/// <returns>
@@ -2907,7 +2945,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <remarks>
/// Called on the actor thread by the PreStart restore path and on a POOL thread by
/// <see cref="BeginDirectApply"/>, so it must stay free of <see cref="Self"/> / <c>Context</c>
/// (#491). It touches only <see cref="_dbFactory"/>, <see cref="_localNode"/> and
/// It touches only <see cref="_dbFactory"/>, <see cref="_localNode"/> and
/// <see cref="_log"/>, all of which are safe off the actor thread — the logger publishes to the
/// EventStream and captures nothing from the cell.
/// </remarks>
@@ -2917,7 +2955,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
{
// Phase 4 belt-and-suspenders: this ConfigDb read is Direct-only (the FetchAndCache apply uses
// ReconcileDriversFromParsed with bytes in hand). A null factory can't read, so return null — the
// caller treats that as "artifact could not be read" (#486) and keeps last-known-good.
// caller treats that as "artifact could not be read" and keeps last-known-good.
if (_dbFactory is null)
{
_log.Warning("DriverHost {Node}: no ConfigDb to load artifact for {Id}; skipping reconcile", _localNode, deploymentId);
@@ -2948,7 +2986,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// below async, and the only caller-side token is actor lifetime — the timeout gives the same
// bound without touching the actor seam. A timeout surfaces as an exception through this
// method's existing catch-to-null, so the apply FAILS and the revision does not advance,
// keeping last-known-good served (the #485-family "no answer, never tear down" semantics —
// keeping last-known-good served (the "no answer, never tear down" semantics —
// already correct, unchanged here).
//
// Guarded on IsRelational: the timeout is a relational-only concept and the extension throws
@@ -2975,7 +3013,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <summary>
/// An apply's artifact: the bytes that were reconciled plus the ONE
/// <see cref="ParsedDeploymentArtifact"/> every later consumer of that apply reads. Both are
/// <see langword="null"/> when nothing was applied (#485/#486).
/// <see langword="null"/> when nothing was applied.
/// </summary>
/// <param name="Blob">The reconciled artifact bytes, or null when there was no usable artifact.</param>
/// <param name="Parsed">The parsed artifact, owned by the apply handler — dispose before it returns.</param>
@@ -2989,7 +3027,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// apply builds exactly one <c>JsonDocument</c> in this actor.
/// </summary>
/// <returns>The reconciled blob + its parsed artifact, or a default (both null) when the blob was
/// empty or absent (#485).</returns>
/// empty or absent.</returns>
/// <exception cref="System.Text.Json.JsonException">The artifact bytes are not valid JSON. Unlike the
/// lenient blob wrappers (malformed ⇒ no specs ⇒ every running child planned for StopChild) this
/// surfaces, and <see cref="HandleArtifactForApply"/>'s catch turns it into a Failed ACK with the
@@ -3027,7 +3065,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// "malformed ⇒ no specs" parse those paths have always had. The apply paths use
/// <see cref="ReconcileDriversFromParsed"/> instead, so they parse once and keep the parse.
/// </summary>
/// <returns>The reconciled blob, or <see langword="null"/> when it was empty (#485).</returns>
/// <returns>The reconciled blob, or <see langword="null"/> when it was empty.</returns>
private byte[]? ReconcileDriversFromBlob(DeploymentId deploymentId, byte[]? blob)
{
blob ??= Array.Empty<byte>();
@@ -3037,7 +3075,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return null;
}
// #601 (S-10): non-empty but malformed bytes are "no answer" too. The lenient static
// Non-empty but malformed bytes are "no answer" too. The lenient static
// ParseDriverInstances swallows the JsonException into an empty list, and an empty list plans every
// running child for StopChild. Open throws instead, so a corrupt cache row / restored blob leaves
// the running drivers alone, exactly like the zero-length case above.
@@ -3060,7 +3098,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
/// <summary>
/// Issue #485 — no bytes is no answer, not "a configuration with no drivers". Parsing zero bytes
/// No bytes is no answer, not "a configuration with no drivers". Parsing zero bytes
/// yields zero specs, and <c>DriverSpawnPlanner</c> then plans EVERY running child for StopChild: a
/// missing deployment row (or a half-written one) silently takes this node's entire field I/O down
/// while the apply still ACKs Applied. Nothing legitimate produces a zero-length blob — deleting the
@@ -3085,9 +3123,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
foreach (var id in plan.ToStop) StopChild(id);
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
// v3 (WP7): a dependency-consumer driver's declared dependency refs change when its tags/scripts are
// edited. That edit changes its DriverConfig, which respawns it (#516), and the respawned driver's
// adapter registers the NEW set in PreStart — no separate re-register is needed (#665).
// v3: a dependency-consumer driver's declared dependency refs change when its tags/scripts are
// edited. That edit changes its DriverConfig, which respawns it, and the respawned driver's
// adapter registers the NEW set in PreStart — no separate re-register is needed.
}
/// <summary>
@@ -3327,7 +3365,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </remarks>
private void PushDesiredSubscriptionsFromArtifact(DeploymentId deploymentId, byte[] blob)
{
// Issue #485 — the same "no bytes is no answer" rule as ReconcileDrivers. An empty artifact parses
// The same "no bytes is no answer" rule as ReconcileDrivers. An empty artifact parses
// to a composition with no raw tags, which hands every child an EMPTY desired set — and an empty set
// drops the live subscription handle rather than re-subscribing. Reachable independently of the
// reconcile guard: this method does its OWN ConfigDb read, so the row can go missing between the two.
@@ -3339,7 +3377,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return;
}
// #601 (S-10): Open, not the lenient static ParseComposition — that wrapper swallows malformed JSON
// Open, not the lenient static ParseComposition — that wrapper swallows malformed JSON
// into an EMPTY composition, which would hand every child an empty desired set and drop its live
// subscription. A malformed artifact is no answer: leave the subscriptions as they are.
AddressSpaceComposition composition;
@@ -3449,7 +3487,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_alarmNodeIdByDriverRef.Clear();
_driverRefByAlarmNodeId.Clear();
_alarmMetaByNodeId.Clear();
// NOT _nativeAlarmProjector.Clear() (Gitea #641): it is pruned to the still-deployed conditions after the
// NOT _nativeAlarmProjector.Clear(): it is pruned to the still-deployed conditions after the
// maps are rebuilt below, so a redeploy that keeps an alarm keeps its projected prior state.
// The per-driver secondary indexes are rebuilt WHOLE alongside their primaries below — same pass,
// same clear — so no incremental maintenance exists to get out of step with them.
@@ -3460,8 +3498,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
if (t.Alarm is not null)
{
// Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
// equipment notifier NodeIds; WP3 wires the single raw condition.
// Native alarm → a single Part 9 condition at the RawPath (Raw realm). The referencing
// equipment notifier NodeIds are added on top of the single raw condition.
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
{
_alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
@@ -3473,8 +3511,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
// Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
_driverRefByAlarmNodeId[t.NodeId] = key;
// Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
// equipment paths; WP3 keys the display off the RawPath. The referencing-equipment paths (the
// Per-condition metadata for the alerts fan-out. The /alerts identity is refined to the referencing
// equipment paths; the display is keyed off the RawPath. The referencing-equipment paths (the
// Area/Line/Equipment UNS folder paths carried on the RawTagPlan) ride onto every transition so
// the /alerts row lists which equipment reference this raw condition.
_alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva, t.ReferencingEquipmentPaths);
@@ -3504,7 +3542,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
// Gitea #641 — forget the projected prior state of conditions this composition no longer carries, and keep
// Forget the projected prior state of conditions this composition no longer carries, and keep
// the rest. Clearing it whole on every apply made the next driver Acknowledge of a still-active alarm
// project Active=false (the default prior), and the next Clear of an unacknowledged one project
// Acked=true — on any deploy, rebuild or not. The node manager carries the NODE's state across a rebuild,
@@ -3596,7 +3634,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private void SpawnChild(DriverInstanceSpec spec)
{
// #609: an instance id removed by an earlier deployment (StopChild ⇒ Evict, which also holds back late
// An instance id removed by an earlier deployment (StopChild ⇒ Evict, which also holds back late
// items) may come back; lift the hold BEFORE the child exists, since it publishes health as it starts.
// The resilience tracker's equivalent is lifted by _invokerFactory.Create below.
_telemetryHub?.Readmit(spec.DriverInstanceId);
@@ -3609,7 +3647,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
{
// A factory throw is a CONFIG error, not a connectivity one — TryCreate is pure parsing;
// device I/O happens later in InitializeAsync. Logged at Error because the node silently
// degrades to a stub that answers nothing, and since #516 routed DriverConfig changes
// degrades to a stub that answers nothing, and since DriverConfig changes are now routed
// through a respawn this path is now reachable by an ordinary operator edit rather than
// only by a brand-new driver. It still does NOT fail the deployment — making it do so is
// a deliberate follow-up, since it would let one malformed driver block a fleet deploy.
@@ -3673,12 +3711,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// supervision reprovision. Sending it HERE rather than at each call site is the point: a child that
// exists without its subscription set is a dead driver, so the two facts are established together.
// Before this, the only sender was the apply's bulk push, so an operator's Restart produced a child
// that connected and then read nothing until the next deployment — the #488 periodic reconcile cannot
// that connected and then read nothing until the next deployment — the periodic reconcile cannot
// help, because it early-returns on an empty desired set.
//
// ORDERING, and its one accepted cost: ApplyDriverSpecs (which spawns) runs BEFORE
// ApplyDesiredSubscriptions (which rebuilds _desiredByDriver), so on a deploy that RESPAWNS a child
// (#516 — the merged DriverConfig folds in the raw tags, so a tag-set edit changes the spec) the new
// (the merged DriverConfig folds in the raw tags, so a tag-set edit changes the spec) the new
// child is handed the PREVIOUS apply's set, and nothing at all on the first deploy. That transient is
// superseded a moment later by the bulk push, on the same sender→receiver pair, so Akka's per-pair
// ordering guarantees the child CONVERGES on the new set — but it is NOT guaranteed to be inert on
@@ -3692,7 +3730,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// Sent after InitializeRequested above, which keeps init-before-subscribe ordering.
SendDesiredSubscriptions(child, spec.DriverInstanceId);
// v3 (WP7): if the (real, non-stub) driver consumes other tags' live values, spawn a mux-adapter
// v3: if the (real, non-stub) driver consumes other tags' live values, spawn a mux-adapter
// child that registers its dependency refs on the per-node dependency mux and forwards changes
// into the driver. Requires a wired mux (dev/None deployments have none — then calc inputs simply
// can't flow, matching the VirtualTag dev path).
@@ -3724,7 +3762,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
StopDependencyAdapter(driverInstanceId);
Context.Stop(entry.Actor);
_children.Remove(driverInstanceId);
// #609: the instance left the plan (StopChild is only reached via plan.ToStop), so its cached
// The instance left the plan (StopChild is only reached via plan.ToStop), so its cached
// Health/Resilience snapshots must not replay to new telemetry subscribers. Release the shared
// resilience state FIRST: the resilience publisher re-emits the tracker's snapshots every tick, so
// evicting the hub alone was undone within one interval. Context.Stop is asynchronous, so the child can
@@ -3828,7 +3866,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// be in flight from an OLD incarnation after HandleRestartDriver stopped that child and spawned a
// replacement under the same DriverInstanceId. Delivered to the new child — which is in Connecting,
// where InitializeRequested IS handled — it would start a second, concurrent InitializeAsync,
// racing the dispose-before-overwrite that #527's teardown exists to serialise
// racing the dispose-before-overwrite that the teardown exists to serialise
// (ModbusDriver._transport, MqttDriver._connection each leak a transport on that race).
if (!Equals(child, entry.Actor))
{
@@ -3948,7 +3986,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
{
// Synchronous by design, and a recorded follow-up rather than an oversight: this is a
// bounded per-apply write of ONE row (at most three per deployment), not the multi-MB
// artifact read that Task 26 moved off the mailbox. It is also the apply's crash-boundary
// artifact read that moved off the mailbox. It is also the apply's crash-boundary
// marker, which is easiest to reason about while it is ordered on the actor thread.
using var db = _dbFactory.CreateDbContext();
var existing = db.NodeDeploymentStates.FirstOrDefault(