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
@@ -40,9 +40,25 @@ public sealed class NativeAlarmProjector
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
}
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
/// <summary>Clears all tracked per-node state.</summary>
public void Clear() => _prior.Clear();
/// <summary>
/// Gitea #641 — drop the tracked state of every condition NOT in <paramref name="deployedNodeIds"/>, keeping
/// the rest. Called on every apply instead of <see cref="Clear"/>: a condition that is still deployed keeps
/// its state across the deploy (the node manager carries the node's state across a rebuild too), so the
/// next Acknowledge does not project <c>Active = false</c> onto an active alarm and the next Clear does not
/// project <c>Acked = true</c> onto an unacknowledged one. A removed condition is forgotten, so a later
/// re-add starts from the default.
/// </summary>
/// <param name="deployedNodeIds">The condition node ids the new composition still materialises.</param>
public void RetainOnly(IReadOnlySet<string> deployedNodeIds)
{
ArgumentNullException.ThrowIfNull(deployedNodeIds);
foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList())
_prior.Remove(nodeId);
}
private static ushort MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 200,
@@ -409,6 +409,66 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>
/// Gitea #641 — a redeploy that keeps the alarm must not reset the projector's prior state for it. It used
/// to be cleared on EVERY apply, so a driver Acknowledge arriving after any deploy projected
/// <c>(prev.Active = false, Acked)</c> and wrote an ACTIVE alarm inactive.
/// </summary>
[Fact]
public void An_acknowledge_after_a_redeploy_keeps_the_alarm_active()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
actor.Tell(Transition(AlarmTransitionKind.Raise));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Active.ShouldBeTrue();
Redeploy(db, actor, publish, coordinator);
actor.Tell(Transition(AlarmTransitionKind.Acknowledge));
var acked = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
acked.State.Active.ShouldBeTrue("the alarm is still active at the device");
acked.State.Acknowledged.ShouldBeTrue();
}
/// <summary>Gitea #641 — likewise a Clear after a redeploy must not silently acknowledge an unacknowledged
/// alarm (the cleared prior state defaulted Acked to true).</summary>
[Fact]
public void A_clear_after_a_redeploy_keeps_the_alarm_unacknowledged()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
actor.Tell(Transition(AlarmTransitionKind.Raise));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
Redeploy(db, actor, publish, coordinator);
actor.Tell(Transition(AlarmTransitionKind.Clear));
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
cleared.State.Active.ShouldBeFalse();
cleared.State.Acknowledged.ShouldBeFalse("nobody acknowledged it");
}
private static DriverInstanceActor.AttributeAlarmPublished Transition(AlarmTransitionKind kind) =>
new("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), "Temp", AlarmRawPath, "OffNormalAlarm", kind.ToString(), AlarmSeverity.High, Ts,
Kind: kind));
/// <summary>A second deployment carrying the SAME alarm (a new revision of an identical artifact).</summary>
private void Redeploy(
IDbContextFactory<OtOpcUaConfigDbContext> db, IActorRef actor, TestProbe publish, TestProbe coordinator)
{
var revB = RevisionHash.Parse(new string('b', 64));
var second = SeedV3AlarmDeployment(db, revB, Driver: "drv-1", Tag: "temp_hi");
actor.Tell(new DispatchDeployment(second, revB, CorrelationId.NewId()));
coordinator.FishForMessage<ApplyAck>(a => a.DeploymentId == second, TimeSpan.FromSeconds(5))
.Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, TimeSpan.FromSeconds(5));
}
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
private void SubscribeToAlerts(TestProbe probe)
{
@@ -439,6 +499,13 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
/// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var (actor, publish, _) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
return (actor, publish);
}
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApplyWithCoordinator(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
@@ -460,7 +527,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
// ExpectMsg<AlarmStateUpdate> assertions see only alarm updates.
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish);
return (actor, publish, coordinator);
}
/// <summary>
@@ -24,6 +24,21 @@ public class NativeAlarmProjectorTests
snap.Message.ShouldBe("boom");
}
/// <summary>Gitea #641 — <c>RetainOnly</c> keeps a still-deployed condition's prior state and forgets a removed
/// one's (which then starts from the default inactive + acked).</summary>
[Fact]
public void RetainOnly_keeps_deployed_conditions_and_forgets_removed_ones()
{
var sut = new NativeAlarmProjector();
sut.Project("kept", Evt(AlarmTransitionKind.Raise));
sut.Project("removed", Evt(AlarmTransitionKind.Raise));
sut.RetainOnly(new HashSet<string>(StringComparer.Ordinal) { "kept" });
sut.Project("kept", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeTrue();
sut.Project("removed", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeFalse();
}
[Fact]
public void Retrigger_projects_active_unacked()
{