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()
{