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:
@@ -1151,7 +1151,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
to duplicate them); a flagged `Unspecified` on an already-active condition (AbCip ALMA governing-limit drop-out)
|
||||
moves the node's text/severity, keeps active/acked and writes no row. Active+acked is the atomic **`AlarmTransitionKind.ActiveAcknowledged`** (Galaxy snapshot
|
||||
`ActiveAcked`, OpcUaClient inactive→acked-active) — never Acknowledge (kept a cold host's `Active=false`: the alarm was
|
||||
invisible) nor Raise+Acknowledge (an unacked intermediate). `NativeAlarmProjector` returns the rows to write; unflagged
|
||||
invisible) nor Raise+Acknowledge (an unacked intermediate). Its mirror **`InactiveUnacknowledged`** (#657, OpcUaClient)
|
||||
replaces Raise/Retrigger+Clear (an Active flicker; rows only for what changed). OpcUaClient's first sight of an inactive
|
||||
condition is a flagged InactiveUnacknowledged, or flagged Clear+Acknowledge for inactive+acked (a flagged Acknowledge on an
|
||||
acked condition writes no row) — never `Unspecified`, which wrote "Activated" for an inactive alarm and left a
|
||||
retained-Active condition Active. `NativeAlarmProjector` returns the rows to write; unflagged
|
||||
sources (FOCAS/MTConnect, live Galaxy) are unchanged. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`.
|
||||
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
|
||||
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection;
|
||||
|
||||
+15
-4
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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