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
+15 -4
View File
@@ -71,10 +71,10 @@ same condition is wired as an **event notifier of every referencing equipment fo
reconnect snapshot entries, OpcUaClient's first event per condition after a start or deploy respawn — flags
`AlarmEventArgs.IsRestatement` (optional, default `false`; FOCAS, MTConnect and live Galaxy transitions never
set it; AbCip ALMA sets it for the drop-out update below). The flag governs **history only, never node state**: the projector still projects, but a flagged `Retrigger`
on a condition already Active+unacked with the **same message AND severity**, or a flagged `Clear` on an
already-inactive condition, writes no `/alerts` row; so does a flagged `Unspecified` update on an already-active
condition, which moves the node's message/severity (one condition event, active/acked kept) — AbCip ALMA's
governing limit dropping out (HH→H), and OpcUaClient's first sight of an inactive condition after a respawn. Before this every gateway reconnect wrote a duplicate
on a condition already Active+unacked with the **same message AND severity**, a flagged `Clear` on an
already-inactive condition, or a flagged `Acknowledge` on an already-acknowledged one (#657), writes no `/alerts` row;
so does a flagged `Unspecified` update on an already-active condition, which moves the node's message/severity (one
condition event, active/acked kept) — AbCip ALMA's governing limit dropping out (HH→H). Before this every gateway reconnect wrote a duplicate
"Activated" (and "Cleared") row per alarm while clients saw no event. A **live** `Retrigger` is a genuine
re-occurrence and keeps its row; a changed severity (an MTConnect Warning→Fault that keeps its text) does too.
Residual: a Galaxy alarm that genuinely re-occurred during an outage and is still Active+unacked with identical
@@ -88,6 +88,17 @@ same condition is wired as an **event notifier of every referencing equipment fo
`Active = false` on a cold host or an inactive prior, so an alarm active in AVEVA projected **inactive** and was
invisible to clients. OpcUaClient emits it for inactive → (active, acked) instead of `Raise` + `Acknowledge`,
which showed clients an Active+**unacked** intermediate event.
- **`AlarmTransitionKind.InactiveUnacknowledged` (#657)** — its mirror: "inactive AND unacknowledged" (went active and
cleared unacked) in ONE projection, never `Raise`/`Retrigger` + `Clear` (which showed clients the condition Active
again for an instant). Rows only for what changed, flag or not: "Activated" (raise time when known) + "Cleared" when
the condition was acknowledged (an un-ack needs a new occurrence), "Cleared" alone when it was Active+unacked, none
when it already showed inactive+unacked. OpcUaClient emits it for every move into inactive+unacked except from
Active+unacked (a plain `Clear`). Its **first sight** of an inactive condition (after a start or deploy respawn, when
the host may still hold ANY state) is `InactiveUnacknowledged`, or `Clear` + `Acknowledge` for inactive+acked — both
flagged — so a host already showing that state writes nothing, and a host that retained the condition Active gets a
real "Cleared". Pre-#657 a respawn wrote a duplicate "Activated" + "Cleared" pair per inactive+unacked condition, and
an inactive+acked first sight went out as `Unspecified` (an "Activated" row for an inactive alarm; a retained-Active
condition was never cleared). E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`.
### Native condition state survives a full rebuild (Gitea #641)
+6 -3
View File
@@ -101,9 +101,12 @@ initialise (the AdminUI form drops them on the next save). Pinned by
> `Raise`/`Acknowledge`/`Clear`/`Retrigger` kinds the host projector consumes (without a kind no upstream alarm could ever
> show active); the kinds are emitted from the state **last told to the host** per RawPath, so an identical re-delivery
> emits nothing (no re-raise, which would un-ack). An inactive → active+acked move is ONE `ActiveAcknowledged` (#656; it
> used to be `Raise` + `Acknowledge`, a client-visible unacked intermediate). The first event per condition after a
> start or deploy respawn is flagged `IsRestatement`, so the host writes no duplicate "Activated" row when its
> condition already shows that state (see `docs/AlarmTracking.md`). Condition **branch** events (non-null `BranchId`) are ignored.
> used to be `Raise` + `Acknowledge`, a client-visible unacked intermediate); a move into inactive+unacked is ONE
> `InactiveUnacknowledged` (#657; it used to be `Raise`/`Retrigger` + `Clear`, an Active flicker). The first event per
> condition after a start or deploy respawn is flagged `IsRestatement` and states the upstream condition so it lands
> from whatever the host retained: an inactive+acked one goes out as `Clear` + `Acknowledge` (#657; it used to be an
> `Unspecified` the host wrote as "Activated", and a retained-Active host condition stayed Active). So the host writes no
> duplicate row when its condition already shows that state (see `docs/AlarmTracking.md`). Condition **branch** events (non-null `BranchId`) are ignored.
> **`ConditionRefresh`** is called after the event item is created, after every in-place reconnect, and on a
> `RefreshRequired` event, so a condition already active upstream is reported at subscribe; `RefreshStart`/`RefreshEnd`
> markers are dropped. A server without it (`BadNotSupported`/`BadMethodInvalid`/…) is logged once and tolerated.