From 11f13f632613ac8af1398f30301aebf5f297d16c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:05:42 -0400 Subject: [PATCH] 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 --- .../Drivers/DriverHostActor.cs | 17 +++-- .../Drivers/NativeAlarmProjector.cs | 18 ++++- .../DriverHostActorNativeAlarmTests.cs | 69 ++++++++++++++++++- .../Drivers/NativeAlarmProjectorTests.cs | 15 ++++ 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 33eafca8..e62c8d2d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs index 4e52cf90..fb0f8075 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs @@ -40,9 +40,25 @@ public sealed class NativeAlarmProjector StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good); } - /// Clears all tracked per-node state (call on address-space rebuild). + /// Clears all tracked per-node state. public void Clear() => _prior.Clear(); + /// + /// Gitea #641 — drop the tracked state of every condition NOT in , keeping + /// the rest. Called on every apply instead of : 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 Active = false onto an active alarm and the next Clear does not + /// project Acked = true onto an unacknowledged one. A removed condition is forgotten, so a later + /// re-add starts from the default. + /// + /// The condition node ids the new composition still materialises. + public void RetainOnly(IReadOnlySet 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, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index 848ee47d..2a2fadaa 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -409,6 +409,66 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// + /// 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 + /// (prev.Active = false, Acked) and wrote an ACTIVE alarm inactive. + /// + [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(TimeSpan.FromSeconds(5)).State.Active.ShouldBeTrue(); + + Redeploy(db, actor, publish, coordinator); + + actor.Tell(Transition(AlarmTransitionKind.Acknowledge)); + var acked = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); + acked.State.Active.ShouldBeTrue("the alarm is still active at the device"); + acked.State.Acknowledged.ShouldBeTrue(); + } + + /// Gitea #641 — likewise a Clear after a redeploy must not silently acknowledge an unacknowledged + /// alarm (the cleared prior state defaulted Acked to true). + [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(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse(); + + Redeploy(db, actor, publish, coordinator); + + actor.Tell(Transition(AlarmTransitionKind.Clear)); + var cleared = publish.FishForMessage(_ => 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)); + + /// A second deployment carrying the SAME alarm (a new revision of an identical artifact). + private void Redeploy( + IDbContextFactory 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(a => a.DeploymentId == second, TimeSpan.FromSeconds(5)) + .Outcome.ShouldBe(ApplyAckOutcome.Applied); + publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); + } + /// Subscribe to the alerts DPS topic and wait for the ack. 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. private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId) + { + var (actor, publish, _) = SpawnHostAndApplyWithCoordinator(db, deploymentId); + return (actor, publish); + } + + private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApplyWithCoordinator( + IDbContextFactory db, DeploymentId deploymentId) { var coordinator = CreateTestProbe(); var publish = CreateTestProbe(); @@ -460,7 +527,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase // ExpectMsg assertions see only alarm updates. publish.ExpectMsg(TimeSpan.FromSeconds(5)); - return (actor, publish); + return (actor, publish, coordinator); } /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs index eaee1d77..e3a2c876 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs @@ -24,6 +24,21 @@ public class NativeAlarmProjectorTests snap.Message.ShouldBe("boom"); } + /// Gitea #641 — RetainOnly keeps a still-deployed condition's prior state and forgets a removed + /// one's (which then starts from the default inactive + acked). + [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(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() {