fix(alarms): revert a native ack that reaches no driver; refuse native acks while the host is Stale (lmxopcua#765)
Residuals of #760: 1. An ack routed to a disabled / not-yet-spawned driver, or to a stubbed child (driver does not run on this node, or its factory rejected the config), was dropped and left the condition Acknowledged. DriverHostActor now reverts it through the same NativeAlarmProjector.TryRevertAcknowledge path as a refused ack. 2. A native ack arriving while the host is Stale was dropped before the projector recorded it, so it could never be reverted. Stale now marks the whole NativeAlarmAckPreflightView unavailable (SetHostUnavailable), so the node manager refuses every native ack synchronously; BecomeSteady clears it. 3. (keyed sessions without a Galaxy identity) is not addressed. Tests: DriverHostActorNativeAlarmTests' client-ack test and the FOCAS respawn theory now start the host's own driver (AckingAlarmDriver; a factory building a real FocasDriver per spawn over per-instance fake CNCs, with a real config-change respawn) - both fail against the fix in their old shape. New: ack to a disabled driver reverts, ack to a stubbed driver reverts, a Stale host refuses through the pre-check view - all three fail with the fix mutated out. Runtime.Tests 943/0/3. Docs: AlarmTracking.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -1528,6 +1528,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// Cancelled BEFORE the unstash so a re-stash by the very next apply starts a fresh window.
|
||||
Timers.Cancel(ApplyWedgeTimerKey);
|
||||
_deferredDispatches.Clear();
|
||||
// Leaving Stale (or never in it): native acks are routable again (lmxopcua#765).
|
||||
_ackPreflightView?.SetHostUnavailable(null);
|
||||
Become(Steady);
|
||||
Stash.UnstashAll();
|
||||
}
|
||||
@@ -2325,12 +2327,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// deploy rather than resetting to its acked default.
|
||||
_nativeAlarmProjector.MarkAcknowledged(msg.ConditionNodeId);
|
||||
|
||||
if (!_children.TryGetValue(target.DriverInstanceId, out var entry))
|
||||
if (!_children.TryGetValue(target.DriverInstanceId, out var entry) || entry.Stubbed)
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} not running for alarm ack of {Cond} — dropped",
|
||||
_localNode, target.DriverInstanceId, msg.ConditionNodeId);
|
||||
// Deliberately NOT reverted (lmxopcua#760 covers what a driver reports): a disabled or not-yet-spawned
|
||||
// driver produces no transitions either, so the local ack stands, as before.
|
||||
// A disabled / not-yet-spawned driver, or a stub (the driver does not run on this node, or its factory
|
||||
// rejected the config), can deliver the ack nowhere. The node manager already applied it, so revert it
|
||||
// like any other ack that never reached the alarm system (lmxopcua#765) — otherwise the node keeps
|
||||
// claiming an acknowledgement the upstream system never saw.
|
||||
var reason = entry is null ? "the owning driver is not running" : "the owning driver is stubbed on this node";
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} cannot take the alarm ack of {Cond} ({Reason}) — dropped",
|
||||
_localNode, target.DriverInstanceId, msg.ConditionNodeId, reason);
|
||||
RevertNativeAlarmAck(msg.ConditionNodeId, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2568,6 +2574,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
|
||||
private void Stale()
|
||||
{
|
||||
// lmxopcua#765: a native ack routed here while Stale is dropped before the projector records it, so it
|
||||
// could not be reverted and the node would show Acknowledged for an ack no driver saw. Refuse it
|
||||
// synchronously at the node manager instead (cleared by BecomeSteady, the only way out of Stale).
|
||||
_ackPreflightView?.SetHostUnavailable("the driver host is stale (configuration database unreachable)");
|
||||
Receive<DispatchDeployment>(_ =>
|
||||
{
|
||||
_log.Warning("DriverHost {Node}: ignoring DispatchDeployment while Stale (DB unreachable)", _localNode);
|
||||
@@ -2602,8 +2612,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<RouteNodeWrite>(_ =>
|
||||
Sender.Tell(new NodeWriteResult(false, "driver host stale (config DB unreachable)")));
|
||||
// An inbound native-condition ack can't be serviced while Stale either (the alarm inverse map is
|
||||
// empty until an apply runs). The ack is fire-and-forget (no reply), so just drop it with a log —
|
||||
// the local OPC UA condition state already committed on the Part 9 ack.
|
||||
// empty until an apply runs). The node manager's pre-check refuses it synchronously while Stale (see the
|
||||
// top of this method); one that raced that refusal is dropped here with a log.
|
||||
Receive<RouteNativeAlarmAck>(msg =>
|
||||
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
|
||||
_localNode, msg.ConditionNodeId));
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class NativeAlarmAckPreflightView
|
||||
private volatile ImmutableDictionary<string, IAlarmAcknowledgePreflight> _byConditionNodeId =
|
||||
ImmutableDictionary<string, IAlarmAcknowledgePreflight>.Empty.WithComparers(StringComparer.Ordinal);
|
||||
|
||||
private volatile string? _hostUnavailableReason;
|
||||
|
||||
/// <summary>
|
||||
/// Returns why the driver owning <paramref name="conditionNodeId"/> would refuse an acknowledge from a
|
||||
/// session authenticated with <paramref name="apiKeyId"/>, or null to proceed (unknown condition, a
|
||||
@@ -30,6 +32,8 @@ public sealed class NativeAlarmAckPreflightView
|
||||
public string? Check(string conditionNodeId, string? apiKeyId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(conditionNodeId)) return null;
|
||||
// A host that cannot route any native ack (Stale — lmxopcua#765) refuses every one, whatever its driver.
|
||||
if (_hostUnavailableReason is { } unavailable) return unavailable;
|
||||
if (!_byConditionNodeId.TryGetValue(conditionNodeId, out var preflight)) return null;
|
||||
try
|
||||
{
|
||||
@@ -43,6 +47,14 @@ public sealed class NativeAlarmAckPreflightView
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the whole driver host unable to route a native acknowledge (lmxopcua#765): while set,
|
||||
/// <see cref="Check"/> refuses every condition with <paramref name="reason"/>. Null clears it. Called by
|
||||
/// <see cref="DriverHostActor"/> only — set on entering <c>Stale</c>, cleared on returning to <c>Steady</c>.
|
||||
/// </summary>
|
||||
/// <param name="reason">Why no native ack can be routed, or null when acks are routable.</param>
|
||||
internal void SetHostUnavailable(string? reason) => _hostUnavailableReason = reason;
|
||||
|
||||
/// <summary>Replaces the snapshot. Called by <see cref="DriverHostActor"/> only.</summary>
|
||||
/// <param name="byConditionNodeId">Condition NodeId → the owning driver's pre-check.</param>
|
||||
internal void Publish(IEnumerable<KeyValuePair<string, IAlarmAcknowledgePreflight>> byConditionNodeId) =>
|
||||
|
||||
Reference in New Issue
Block a user