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:
@@ -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, []);
|
||||
|
||||
Reference in New Issue
Block a user