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:
Joseph Doherty
2026-10-09 06:08:39 -04:00
parent 98c00904e7
commit 48a80b5962
10 changed files with 354 additions and 27 deletions
@@ -227,6 +227,10 @@ public sealed class OpcUaClientAlarmTests
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
// Gitea #657: inactive+unacked is ONE atomic InactiveUnacknowledged (never an Active flicker), except from
// Active+unacked, where a plain Clear already lands there.
[InlineData(false, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
[InlineData(true, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
public void AlarmTransitions_move_prior_to_target(
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
{
@@ -242,13 +246,34 @@ public sealed class OpcUaClientAlarmTests
/// </summary>
[Theory]
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
[InlineData(false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
[InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
[InlineData(false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
{
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
}
/// <summary>
/// Gitea #657 — on first sight the HOST may hold any state (it retains a still-deployed condition across a
/// respawn), so the first-sight kinds must land on the upstream's state from EVERY host state. Pre-fix an
/// inactive+acked target emitted nothing state-bearing, leaving a retained-Active host condition Active.
/// </summary>
[Fact]
public void First_sight_kinds_reach_the_target_from_every_host_state()
{
bool[] b = [false, true];
foreach (var ha in b) foreach (var hk in b) foreach (var ta in b) foreach (var tk in b)
{
var kinds = OpcUaClientDriver.AlarmTransitions((false, true), (ta, tk), priorKnown: false);
var s = (ha, hk);
foreach (var k in kinds) s = Simulate(s, k);
s.ShouldBe((ta, tk), $"host ({ha},{hk}) -> upstream ({ta},{tk}) via [{string.Join(",", kinds)}]");
kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
kinds.ShouldNotContain(AlarmTransitionKind.Raise, "a first sight must not keep a stale host ack");
}
}
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
@@ -256,6 +281,7 @@ public sealed class OpcUaClientAlarmTests
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.ActiveAcknowledged => (true, true),
AlarmTransitionKind.InactiveUnacknowledged => (false, false),
AlarmTransitionKind.Acknowledge => (s.Active, true),
AlarmTransitionKind.Clear => (false, s.Acked),
_ => s,
@@ -414,6 +440,32 @@ public sealed class OpcUaClientAlarmTests
]);
}
/// <summary>
/// Gitea #657 — first sight of an INACTIVE upstream condition: inactive+unacked is ONE flagged
/// InactiveUnacknowledged (pre-fix a Retrigger then a Clear — clients saw it Active again); inactive+acked is
/// a flagged Clear then a flagged Acknowledge (pre-fix an Unspecified the host wrote as "Activated").
/// </summary>
[Fact]
public void First_sight_of_an_inactive_condition_is_restated_atomically()
{
using var unacked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657a");
var seenA = Record(unacked);
unacked.RouteAlarmEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle());
seenA.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.InactiveUnacknowledged, true)]);
using var acked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657b");
var seenB = Record(acked);
acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
seenB.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
(AlarmTransitionKind.Clear, true),
(AlarmTransitionKind.Acknowledge, true),
]);
// Positive control: once known, the identical refresh copy emits nothing.
acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
seenB.Count.ShouldBe(2);
}
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
[Fact]
public void The_exact_upstream_severity_is_carried()
@@ -478,6 +478,58 @@ public class NativeAlarmProjectorTests
legacy.ShouldBe([AlarmTransitionKind.Unspecified]);
}
/// <summary>
/// Gitea #657 — InactiveUnacknowledged projects inactive+unacked in ONE update from every prior, and writes rows
/// only for what changed: "Activated"+"Cleared" from an acknowledged prior (an un-ack needs a new occurrence),
/// "Cleared" from Active+unacked, nothing when the condition already shows inactive+unacked. The flag does not
/// change it — the rows already describe only real changes.
/// </summary>
[Theory]
[InlineData(false, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
[InlineData(true, false, new[] { AlarmTransitionKind.Clear })]
[InlineData(false, false, new AlarmTransitionKind[0])]
public void InactiveUnacknowledged_projects_inactive_unacked_with_rows_only_for_what_changed(
bool priorActive, bool priorAcked, AlarmTransitionKind[] expected)
{
foreach (var restated in new[] { false, true })
{
var sut = new NativeAlarmProjector();
if (priorActive) sut.Project("n1", Evt(AlarmTransitionKind.Raise));
if (priorActive && priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
if (!priorActive && !priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged));
var snap = sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged, restated: restated),
out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeFalse();
snap.Acknowledged.ShouldBeFalse();
rows.ShouldBe(expected);
}
}
/// <summary>
/// Gitea #657 — a flagged Acknowledge on an already-acknowledged condition (OpcUaClient's first-sight Clear +
/// Acknowledge of an inactive+acked condition) writes no row; the unflagged control and a flagged Acknowledge
/// of an UNACKED condition both write one.
/// </summary>
[Fact]
public void A_restated_Acknowledge_on_an_acked_condition_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList<AlarmTransitionKind> cold);
cold.ShouldBeEmpty();
sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList<AlarmTransitionKind> unflagged);
unflagged.ShouldBe([AlarmTransitionKind.Acknowledge]);
sut.Project("n2", Evt(AlarmTransitionKind.Raise));
var snap = sut.Project("n2", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList<AlarmTransitionKind> real);
real.ShouldBe([AlarmTransitionKind.Acknowledge]);
snap.Acknowledged.ShouldBeTrue();
snap.Active.ShouldBeTrue();
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
@@ -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)