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
@@ -107,11 +107,13 @@ public sealed record AlarmAcknowledgeRequest(
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition) — or
/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition; an
/// Acknowledge on an already-acknowledged condition) — or
/// when a flagged <see cref="AlarmTransitionKind.Unspecified"/> update moves the message/severity of an
/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the
/// flag for the last shape (the governing limit changed, no new occurrence); OpcUaClient's first sight of an
/// inactive condition after a respawn can reach it too. <c>false</c> (the default, every source that does not
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient (Gitea #657) re-states a
/// first-seen inactive+acked condition as a flagged Clear then a flagged Acknowledge, so a host already showing
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
/// opt in) keeps every transition's row.
/// </param>
public sealed record AlarmEventArgs(
@@ -147,6 +149,11 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
/// <see cref="ActiveAcknowledged"/> (Gitea #656) means "the condition is active AND acknowledged", applied by the
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
/// <see cref="InactiveUnacknowledged"/> (Gitea #657) is its mirror: "the condition is inactive AND unacknowledged"
/// (it went active and cleared unacknowledged), applied as ONE projection — never Raise-then-Clear, which showed
/// clients the condition Active again for an instant. The host writes rows only for what actually changed:
/// "Activated" + "Cleared" when the condition was acknowledged (a new occurrence must have happened), "Cleared"
/// alone when it was Active+unacked, none when it already showed inactive+unacked.
/// Members are append-only: their numeric values must not move.
/// </summary>
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged }
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged, InactiveUnacknowledged }
@@ -2069,7 +2069,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked,
/// ActiveAcknowledged ⇒ active+acked in one step (#656), Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
/// ActiveAcknowledged ⇒ active+acked in one step (#656), InactiveUnacknowledged ⇒ inactive+unacked in one step (#657), Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
/// upstream event can need two (an alarm that went active and was acknowledged between two notifications).
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
/// </summary>
@@ -2086,6 +2086,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
(bool Active, bool Acked) prior, (bool Active, bool Acked) target, bool priorKnown = true)
{
// Gitea #657: on first sight the host may hold ANY state (it retains a still-deployed condition across a
// respawn), so an inactive target is stated in kinds that land on it from every host state: inactive+unacked
// as ONE InactiveUnacknowledged; inactive+acked as Clear then Acknowledge (flagged by the caller, so a host
// already showing it writes no row, and a host retaining it Active gets a real "Cleared"). Pre-fix an
// inactive+acked first sight equalled the default prior and went out as Unspecified — an "Activated" row for
// an inactive alarm, and a retained-Active host condition was never cleared.
if (!priorKnown && !target.Active)
return target.Acked
? [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
: [AlarmTransitionKind.InactiveUnacknowledged];
if (prior == target) return [];
var kinds = Transitions(prior, target);
return priorKnown || target.Acked
@@ -2109,11 +2119,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
(false, true) => prior.Active
? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
: [AlarmTransitionKind.Acknowledge],
// Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh):
// the only kinds that un-ack are Raise/Retrigger, so pass through active, then Clear.
(false, false) => prior.Active
? prior.Acked ? [AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear]
: [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
// Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh): ONE
// atomic InactiveUnacknowledged (Gitea #657) — Raise/Retrigger-then-Clear showed clients the condition
// Active again for an instant. From Active+unacked a plain Clear already lands there.
(false, false) => prior is (true, false)
? [AlarmTransitionKind.Clear]
: [AlarmTransitionKind.InactiveUnacknowledged],
};
}
@@ -1885,7 +1885,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// decides WHICH rows the transition warrants: none for a re-statement (Gitea #647 review: a Raise on an
// already-active condition from a respawned driver; #656: a flagged snapshot/first-sight Retrigger or
// Clear that changes nothing the condition shows), and up to two for an ActiveAcknowledged (#656:
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked).
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked) or an
// InactiveUnacknowledged (#657: "Activated" + "Cleared" only if it was acked, "Cleared" if it was active).
if (!serviceAlertsAsPrimary) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
@@ -1911,8 +1912,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
{
// #656: an ActiveAcknowledged expands to "Activated" + "Acknowledged". The operator comment/user belong to
// the acknowledgement, and the activation happened at the original raise time when the source knows it.
var expandedActivation = args.Kind == AlarmTransitionKind.ActiveAcknowledged
&& historyKind != AlarmTransitionKind.Acknowledge;
// #657: an InactiveUnacknowledged's "Activated" row is likewise the occurrence, at its raise time when known.
var expandedActivation = (args.Kind == AlarmTransitionKind.ActiveAcknowledged
&& historyKind != AlarmTransitionKind.Acknowledge)
|| (args.Kind == AlarmTransitionKind.InactiveUnacknowledged && historyKind == AlarmTransitionKind.Raise);
var comment = expandedActivation ? null : args.OperatorComment;
var timestamp = expandedActivation ? args.OriginalRaiseTimestampUtc ?? args.SourceTimestampUtc : args.SourceTimestampUtc;
var alertEvent = new AlarmTransitionEvent(
@@ -1957,6 +1960,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
AlarmTransitionKind.Acknowledge => "Acknowledged",
// #656: never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
AlarmTransitionKind.ActiveAcknowledged => "Acknowledged",
// #657: likewise never reached — expanded into Raise + Clear (or Clear alone) rows.
AlarmTransitionKind.InactiveUnacknowledged => "Cleared",
_ => "Activated",
};
@@ -49,10 +49,13 @@ public sealed class NativeAlarmProjector
/// <item>Gitea #656 — <see cref="AlarmTransitionKind.ActiveAcknowledged"/> projects Active+acked in ONE
/// update (never an Active+unacked intermediate a client could see) and writes "Activated" only when the
/// condition was not already showing that alarm active, then "Acknowledged" only when it was not acked.</item>
/// <item>Gitea #657 — <see cref="AlarmTransitionKind.InactiveUnacknowledged"/> projects inactive+unacked in ONE
/// update and writes "Activated" + "Cleared" only when the condition was acknowledged (an un-ack needs a new
/// occurrence), "Cleared" alone when it was Active+unacked, nothing when it already showed exactly that.</item>
/// <item>Gitea #656 — a <see cref="AlarmEventArgs.IsRestatement"/> event (a source re-stating upstream's
/// current state: a reconnect snapshot, first sight after a respawn) writes no row when it changes nothing
/// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity, or
/// a Clear on an already-inactive condition; and a flagged Unspecified update of an already-active
/// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity,
/// a Clear on an already-inactive condition, or an Acknowledge on an already-acknowledged one (#657); and a flagged Unspecified update of an already-active
/// condition moves its message/severity (node + one condition event) with active/acked kept and no row
/// (AbCip ALMA's governing-limit change). Unflagged sources (FOCAS, MTConnect, live Galaxy transitions) are
/// unaffected — a live Retrigger is a genuine re-occurrence and keeps its row.</item>
@@ -99,6 +102,21 @@ public sealed class NativeAlarmProjector
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]
: !prev.Acked ? [AlarmTransitionKind.Acknowledge] : [];
break;
// #657: the mirror of ActiveAcknowledged — inactive AND unacked in ONE update (never Raise-then-Clear, which
// showed clients the condition Active again for an instant). Rows only for what changed: an acknowledged
// condition can only have become unacked through a new occurrence ("Activated" + "Cleared"); an
// Active+unacked one just cleared; one already inactive+unacked changed nothing.
case AlarmTransitionKind.InactiveUnacknowledged:
(active, acked) = (false, false);
historyKinds = prev.Acked
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]
: prev.Active ? [AlarmTransitionKind.Clear] : [];
break;
// #657: a source re-stating an acknowledgement the condition already shows (OpcUaClient's first sight of
// an inactive+acked condition is a flagged Clear + Acknowledge) changes nothing.
case AlarmTransitionKind.Acknowledge when e.IsRestatement && prev.Acked:
(active, acked, historyKinds) = (prev.Active, true, []);
break;
case AlarmTransitionKind.Acknowledge:
(active, acked, historyKinds) = (prev.Active, true, [AlarmTransitionKind.Acknowledge]);
break;
@@ -112,9 +130,8 @@ public sealed class NativeAlarmProjector
break;
// A message/severity-only update of an ALREADY-ACTIVE condition that the source flags as a re-statement
// (AbCip ALMA: the governing limit changed but no new occurrence started, e.g. HH dropped back to H).
// OpcUaClient can reach it too: first sight after a respawn of an upstream-inactive condition is told
// Unspecified+flagged; with the host still Active the node stays Active exactly as before (a pre-existing
// gap) and the false "Activated" row it used to write is now gone.
// OpcUaClient no longer reaches it (#657: its first sight of an inactive condition is a flagged Clear +
// Acknowledge, or an InactiveUnacknowledged — never a flagged Unspecified).
// The node's text/severity move; active/acked are kept; NO row (an "Activated" row would be false).
case AlarmTransitionKind.Unspecified when e.IsRestatement && prev.Active:
(active, acked, historyKinds) = (true, prev.Acked, []);