fix(runtime): prune the native-alarm projector on deploy instead of clearing it (#641)

DriverHostActor cleared NativeAlarmProjector's per-condition prior state on
EVERY apply, so after any deploy a driver Acknowledge of a still-active alarm
projected Active=false (writing it inactive) and a Clear of an unacknowledged
alarm projected Acked=true (silently acknowledging it). The projector is now
pruned to the conditions the new composition still carries (RetainOnly).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:05:42 -04:00
parent ddd7678269
commit 11f13f6326
4 changed files with 113 additions and 6 deletions
@@ -1793,9 +1793,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// #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
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
// snapshot: the projector's prior state is cleared on every apply, so a projected "keep the state"
// after a deploy would default to inactive+acked and silently acknowledge a retained, unacked
// condition — and would rewrite its message and severity. Ungated by role (a Secondary keeps quality
// 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
// the node — silently acknowledging a retained, unacked condition or rewriting its message and
// severity. Ungated by role (a Secondary keeps quality
// warm, as for connectivity) and no alerts row.
if (msg.Args is { IsQualityOnly: true, QualityStatusCode: { } quality })
{
@@ -3078,7 +3079,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_alarmNodeIdByDriverRef.Clear();
_driverRefByAlarmNodeId.Clear();
_alarmMetaByNodeId.Clear();
_nativeAlarmProjector.Clear();
// NOT _nativeAlarmProjector.Clear() (Gitea #641): 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.
_valueRefsByDriver.Clear();
@@ -3132,6 +3134,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
// Gitea #641 — 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,
// so the two stay in step. Keyed by the bare condition NodeId, exactly as ForwardNativeAlarm projects.
_nativeAlarmProjector.RetainOnly(_driverRefByAlarmNodeId.Keys.ToHashSet(StringComparer.Ordinal));
// Cache the per-driver payload as THE authority, in the same clear-and-repopulate pass as the routing
// maps above so a rename/removal can never leave a stale entry behind. Keyed over the UNION of the two
// ref maps AND the running children: a child with no authored tags must get an explicit EMPTY set (which