Merge b640-641: native alarm state survives rebuild; projector pruned; AbCip ConditionId=RawPath (#641)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:26:47 -04:00
23 changed files with 1060 additions and 50 deletions
@@ -104,6 +104,27 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
[Fact]
public void A_decorated_condition_id_is_dropped()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), AlarmRawPath, $"{AlarmRawPath}#active", "ALMD", "decorated", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Raise)));
// Positive control: the undecorated id routes, and is the first update.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), AlarmRawPath, AlarmRawPath, "ALMD", "plain", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Raise)));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Message.ShouldBe("plain");
}
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
@@ -409,6 +430,93 @@ 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");
}
/// <summary>
/// Gitea #641 review — a CLIENT acknowledgement is applied to the node by the SDK and only ROUTED here; the
/// projector must learn of it. Before #641 the projector was cleared on every deploy, so a Clear arriving
/// after one defaulted Acked to true and happened to be right for a client-acked alarm; now that the
/// projector survives the deploy, a projector that never heard the ack projects the Clear as UNacked and
/// un-acknowledges the alarm the operator acknowledged.
/// </summary>
[Fact]
public void A_clear_after_a_client_acknowledge_and_a_redeploy_stays_acknowledged()
{
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();
// The client's Acknowledge on the node, routed toward the driver (no transition comes back for it).
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "seen", "op"));
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.ShouldBeTrue("the operator acknowledged it before it cleared");
}
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 +547,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 +575,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()
{
@@ -61,6 +61,41 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>
/// Gitea #641 — every apply ends with <c>CompleteAddressSpaceApply</c>, AFTER the last materialise / announce
/// call: that is where the node manager reports the conditions a rebuild carried over (their notifier
/// folders are wired by then) and drops any captured state no materialise claimed.
/// </summary>
[Fact]
public void An_apply_completes_after_every_materialise_pass()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget);
sink.CompletedAfterCallCount.Single().ShouldBe(sink.Calls.Count, "nothing may follow the completion");
sink.Calls.ShouldContain("NA:line-1");
}
/// <summary>Gitea #641 — the raw-sink fallback rebuild (no applier) completes too, so the state it captured
/// is discarded rather than left for a later add to resurrect.</summary>
[Fact]
public void The_raw_sink_fallback_rebuild_completes_the_apply()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Tests that rebuild with no artifact is idempotent no-op.</summary>
[Fact]
public void Rebuild_with_no_artifact_is_idempotent_no_op()
@@ -1053,6 +1088,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
}
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Gets, per <see cref="CompleteAddressSpaceApply"/> call, how many <see cref="Calls"/> had been
/// recorded at that moment — so a test can prove the completion came after every pass (Gitea #641).
/// Kept out of <see cref="Calls"/> so existing call-count assertions are unaffected.</summary>
public ConcurrentQueue<int> CompletedAfterCallCount { get; } = new();
/// <summary>Records the end of an apply pass (Gitea #641).</summary>
public void CompleteAddressSpaceApply() => CompletedAfterCallCount.Enqueue(Calls.Count);
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");