fix(alarms): atomic InactiveUnacknowledged + flagged Clear/Acknowledge first sight for OpcUaClient (#657)
After an OpcUaClient respawn an upstream inactive+unacked condition went out as Retrigger+Clear (duplicate Activated+Cleared rows, Active flicker), and a first-sight inactive+acked one as Unspecified (an Activated row for an inactive alarm; a retained-Active host condition never cleared). - New AlarmTransitionKind.InactiveUnacknowledged: one projection, rows only for what changed (Activated+Cleared from acked, Cleared from active+unacked, none when already inactive+unacked). - OpcUaClient uses it for moves into inactive+unacked; first sight of an inactive condition is InactiveUnacknowledged or Clear+Acknowledge, flagged. - Projector: a flagged Acknowledge on an already-acked condition writes no row. - E2E tests through DriverHostActor.ForwardNativeAlarm (red-proven). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+165
@@ -330,6 +330,171 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 1 — the respawned instance's ConditionRefresh re-reads an upstream condition that went active and
|
||||
/// cleared UNACKNOWLEDGED, which the host already shows inactive+unacked. Pre-fix the first sight went out as
|
||||
/// Retrigger then Clear: two condition updates (clients saw it Active again) and a duplicate "Activated" +
|
||||
/// "Cleared" pair. Now it is ONE InactiveUnacknowledged: one update, already inactive, and no row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_restating_an_inactive_unacked_alarm_does_not_flicker_or_write_rows()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
a.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle);
|
||||
var cleared = NextAlarmUpdate(publish);
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
cleared.State.Acknowledged.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle); // ConditionRefresh copy
|
||||
var restated = NextAlarmUpdate(publish);
|
||||
restated.State.Active.ShouldBeFalse("no Active flicker may reach clients");
|
||||
restated.State.Acknowledged.ShouldBeFalse("upstream says unacknowledged");
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: the upstream operator acknowledges — the NEXT update is that ack (no leftover second
|
||||
// update of the re-statement), and it writes its row.
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
var acked = NextAlarmUpdate(publish);
|
||||
acked.State.Active.ShouldBeFalse();
|
||||
acked.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 1, retained-Active variant — the host retained the condition Active+unacked across the respawn and
|
||||
/// upstream has since cleared it (still unacked). That is ONE real "Cleared" — one update straight to
|
||||
/// inactive+unacked (pre-fix the first update re-stated it Active, then a second cleared it) and no
|
||||
/// "Activated" row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_clearing_a_retained_active_unacked_alarm_is_one_update_and_one_Cleared_row()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle);
|
||||
var cleared = NextAlarmUpdate(publish);
|
||||
cleared.State.Active.ShouldBeFalse("the first update must already be the clear");
|
||||
cleared.State.Acknowledged.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
publish.ReceiveWhile<object>(Quiet, m => m).OfType<OpcUaPublishActor.AlarmStateUpdate>()
|
||||
.ShouldBeEmpty("one update, not a re-statement followed by the clear");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 2 (#658) — upstream cleared AND acknowledged a condition while the driver was down, and the host
|
||||
/// retained it Active+acked across the respawn. Pre-fix the first sight was a flagged Unspecified: the host
|
||||
/// kept it ACTIVE (nothing cleared it) and wrote nothing. Now it is a flagged Clear + Acknowledge: the
|
||||
/// condition goes inactive+acked with exactly one real "Cleared" row (the ack changed nothing — no row).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_seeing_inactive_acked_clears_a_retained_active_condition()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
var cleared = NextAlarmUpdate(publish);
|
||||
cleared.State.Active.ShouldBeFalse("a retained Active condition must be cleared");
|
||||
cleared.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
var reAcked = NextAlarmUpdate(publish);
|
||||
reAcked.State.Active.ShouldBeFalse();
|
||||
reAcked.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #657 item 2 (#658) — a host that already shows the condition inactive+acked re-reads it on a respawn. Pre-fix
|
||||
/// the Unspecified first sight wrote an "Activated" row for an inactive alarm. Now: no row, the node stays
|
||||
/// inactive+acked; a later genuine raise still writes its row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_respawn_restating_an_inactive_acked_alarm_writes_no_Activated_row()
|
||||
{
|
||||
var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish);
|
||||
a.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
NextAlarmUpdate(publish).State.Active.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
|
||||
Respawn(host, publish, coordinator, db, blob);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
var restated = NextAlarmUpdate(publish);
|
||||
restated.State.Active.ShouldBeFalse();
|
||||
restated.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: a genuine re-activation MUST write its row.
|
||||
b.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(u => u.State.Active, Wait);
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
}
|
||||
|
||||
private (IActorRef Host, TestProbe Publish, TestProbe Coordinator, TestProbe Alerts,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> Db, byte[] Blob) StartOpcUaHost()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var blob = BuildOpcUaArtifact();
|
||||
var alerts = SubscribeAlerts();
|
||||
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
|
||||
return (host, publish, coordinator, alerts, db, blob);
|
||||
}
|
||||
|
||||
/// <summary>A config-changing deploy: the host keeps the still-deployed condition's state, the driver respawns.</summary>
|
||||
private static void Respawn(
|
||||
IActorRef host, TestProbe publish, TestProbe coordinator, IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob)
|
||||
{
|
||||
var revB = RevisionHash.Parse(new string('b', 64));
|
||||
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
|
||||
coordinator.FishForMessage<ApplyAck>(x => x.Outcome == ApplyAckOutcome.Applied, Wait);
|
||||
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, Wait);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> SourceIndex = new() { [UpstreamSource] = OpcRawPath };
|
||||
|
||||
private static OpcUaClientDriver NewOpcUaDriver(IActorRef host, string suffix)
|
||||
|
||||
Reference in New Issue
Block a user