Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 549c656489 | |||
| 28c2866710 |
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
|
||||
|
||||
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
|
||||
|
||||
### Post-(re)load condition-node re-assert (#487)
|
||||
|
||||
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
|
||||
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
|
||||
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
|
||||
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
|
||||
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
|
||||
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
|
||||
never come.
|
||||
|
||||
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
|
||||
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
|
||||
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
|
||||
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
|
||||
|
||||
Four properties are load-bearing:
|
||||
|
||||
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
|
||||
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
|
||||
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
|
||||
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
|
||||
machinery — or a future refactor — to mistake for a transition.
|
||||
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
|
||||
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
|
||||
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
|
||||
alarm's display name — and writes nothing at all on the common all-normal deploy.
|
||||
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
|
||||
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
|
||||
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
|
||||
these writes.
|
||||
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
|
||||
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
|
||||
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
|
||||
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
|
||||
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
|
||||
|
||||
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
|
||||
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
|
||||
|
||||
## Source integration
|
||||
|
||||
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
|
||||
|
||||
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
|
||||
|
||||
## Documented follow-ups (non-blocking)
|
||||
|
||||
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.**
|
||||
- **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
|
||||
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
|
||||
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
|
||||
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
|
||||
(a projection also carries the definition's severity, the resolved message template, and the last-observed
|
||||
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
|
||||
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
|
||||
nodes at all. The original deferral text follows for context.
|
||||
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
|
||||
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
|
||||
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
|
||||
|
||||
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
|
||||
/// severity, the rendered message template and the last-observed input quality, i.e. everything
|
||||
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
|
||||
/// the next transition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
|
||||
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
|
||||
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
|
||||
/// transition to report), so the transition stream cannot carry the current state — that is
|
||||
/// exactly why the caller needs this. Routing these through the emission path instead would
|
||||
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
|
||||
/// intentionally one the emission machinery cannot consume.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
|
||||
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
|
||||
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
|
||||
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
|
||||
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
|
||||
/// read publishes its own transition through the normal path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
|
||||
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
|
||||
{
|
||||
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
|
||||
foreach (var (alarmId, state) in _alarms)
|
||||
{
|
||||
projections.Add(new ScriptedAlarmProjection(
|
||||
AlarmId: alarmId,
|
||||
Severity: state.Definition.Severity,
|
||||
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
|
||||
Condition: state.Condition,
|
||||
WorstInputStatusCode: LastWorstStatus(alarmId)));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the acknowledgment.</param>
|
||||
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
|
||||
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
|
||||
uint WorstInputStatusCode = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
|
||||
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
|
||||
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
|
||||
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
|
||||
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
|
||||
/// for a future refactor to mistake for a transition.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
|
||||
/// <param name="Severity">The definition's severity bucket.</param>
|
||||
/// <param name="Message">The message template resolved against the current value cache.</param>
|
||||
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
|
||||
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
|
||||
public sealed record ScriptedAlarmProjection(
|
||||
string AlarmId,
|
||||
AlarmSeverity Severity,
|
||||
string Message,
|
||||
AlarmConditionState Condition,
|
||||
uint WorstInputStatusCode);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
/// engine's so Stream G can compose them behind one driver bridge.
|
||||
|
||||
@@ -288,8 +288,101 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
|
||||
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
|
||||
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
|
||||
|
||||
ReassertConditionNodes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #487 — re-project every loaded alarm that is NOT in the Part 9 "no-event" position back onto
|
||||
/// its condition node, right after a (re)load.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The bug this closes.</b> A deploy that triggers a FULL address-space rebuild clears
|
||||
/// <c>_alarmConditions</c> and re-materialises every condition node fresh — inactive, acked,
|
||||
/// confirmed, unshelved. The engine, meanwhile, restores each alarm's persisted state and
|
||||
/// re-derives Active from the predicate, so a still-active alarm ends the reload
|
||||
/// <i>correctly Active in the engine</i> but produces <see cref="EmissionKind.None"/> (there
|
||||
/// is no transition to report) — and <see cref="OnEngineEmission"/> drops None. Nothing then
|
||||
/// writes the node, so an active alarm with static dependencies reads NORMAL to every OPC UA
|
||||
/// client until its next real transition, which may never come. Same shape as the VirtualTag
|
||||
/// redeploy-reset the <c>ReassertValue</c> fix closed.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Node-only, by construction.</b> This sends <see cref="OpcUaPublishActor.AlarmStateUpdate"/>
|
||||
/// and nothing else — it never publishes to the <c>alerts</c> topic and never touches the
|
||||
/// telemetry hub. That is the whole reason it reads <see cref="ScriptedAlarmEngine.GetProjections"/>
|
||||
/// (a plain read returning <see cref="ScriptedAlarmProjection"/>) rather than re-emitting: an
|
||||
/// <c>alerts</c> row per alarm per deploy would append a duplicate historian / AVEVA record
|
||||
/// every time anyone deploys — the alarm analogue of the VirtualTag M1 historian issue.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Ordering.</b> <c>DriverHostActor</c> Tells <c>RebuildAddressSpace</c> to the publish
|
||||
/// actor BEFORE it Tells <see cref="ApplyScriptedAlarms"/> here, and this runs later still —
|
||||
/// after an <c>await</c>ed <see cref="ScriptedAlarmEngine.LoadAsync"/> pipes back. Both Tells
|
||||
/// enqueue synchronously into the publish actor's local mailbox, so the re-materialise is
|
||||
/// already queued ahead of these writes: the node exists by the time they are handled.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why only non-normal alarms.</b> An alarm sitting in the no-event position already
|
||||
/// matches what the materialise just built, so writing it would be a pure no-op on state —
|
||||
/// but it would still overwrite the node's Message (the materialise seeds it with the alarm's
|
||||
/// display name; a projection carries the resolved template). Skipping them keeps a
|
||||
/// never-fired alarm byte-identical to a deploy without this pass, and keeps the write count
|
||||
/// at zero on the overwhelmingly common all-normal deploy.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Duplicate events.</b> None. <c>WriteAlarmCondition</c>'s delta-gate compares the
|
||||
/// incoming snapshot against the node's CURRENT state, so a re-assert onto a freshly
|
||||
/// materialised (normal) node is a genuine delta and fires one Part 9 event — correct, since
|
||||
/// the rebuild reset what clients see — while a re-assert onto a surgically-preserved node
|
||||
/// that already holds the same state is suppressed. This write is ungated by redundancy role,
|
||||
/// like every other node write here: the Secondary keeps its address space warm for failover.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ReassertConditionNodes()
|
||||
{
|
||||
var reasserted = 0;
|
||||
foreach (var projection in _engine.GetProjections())
|
||||
{
|
||||
if (IsInNoEventPosition(projection.Condition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||
AlarmNodeId: projection.AlarmId,
|
||||
State: ToSnapshot(projection),
|
||||
// The instant the state we are re-asserting actually came about — NOT the deploy time.
|
||||
// A client reading Time/ReceiveTime after a rebuild should still see when the alarm went
|
||||
// active, not when the address space happened to be rebuilt.
|
||||
TimestampUtc: projection.Condition.LastTransitionUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
reasserted++;
|
||||
}
|
||||
|
||||
if (reasserted > 0)
|
||||
{
|
||||
_log.Info("ScriptedAlarmHost: re-asserted {Count} non-normal condition node(s) after (re)load", reasserted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="condition"/> sits in the Part 9 "no-event" position — exactly the
|
||||
/// state <see cref="AlarmConditionState.Fresh"/> produces and a freshly materialised condition node
|
||||
/// already carries. Anything else (active, unacknowledged, unconfirmed, disabled, or shelved) is state
|
||||
/// a rebuild would silently lose, so it is what <see cref="ReassertConditionNodes"/> re-projects.</summary>
|
||||
/// <param name="condition">The engine's current condition state.</param>
|
||||
/// <returns><c>true</c> when the condition carries nothing a rebuild could lose.</returns>
|
||||
private static bool IsInNoEventPosition(AlarmConditionState condition)
|
||||
=> condition.Enabled == AlarmEnabledState.Enabled
|
||||
&& condition.Active == AlarmActiveState.Inactive
|
||||
&& condition.Acked == AlarmAckedState.Acknowledged
|
||||
&& condition.Confirmed == AlarmConfirmedState.Confirmed
|
||||
&& condition.Shelving.Kind == ShelvingKind.Unshelved;
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
@@ -594,18 +687,38 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
|
||||
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
|
||||
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new(
|
||||
Active: e.Condition.Active == AlarmActiveState.Active,
|
||||
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
|
||||
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed,
|
||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(e.Severity),
|
||||
Message: e.Message,
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e)
|
||||
=> ToSnapshot(e.Condition, e.Severity, e.Message, e.WorstInputStatusCode);
|
||||
|
||||
/// <summary>#487 — the same projection for a <see cref="ScriptedAlarmProjection"/>: a state READ used
|
||||
/// to restore a condition node after a rebuild, never an emission. Shares
|
||||
/// <see cref="ToSnapshot(AlarmConditionState, AlarmSeverity, string, uint)"/> with the transition path
|
||||
/// so a re-asserted node is byte-identical to what the alarm's own transition would have written.</summary>
|
||||
/// <param name="p">The engine projection to map.</param>
|
||||
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmProjection p)
|
||||
=> ToSnapshot(p.Condition, p.Severity, p.Message, p.WorstInputStatusCode);
|
||||
|
||||
/// <summary>The single Core → Commons condition projection, shared by the emission and re-assert
|
||||
/// paths.</summary>
|
||||
/// <param name="condition">The Part 9 condition state.</param>
|
||||
/// <param name="severity">The engine's coarse severity bucket.</param>
|
||||
/// <param name="message">The resolved condition message.</param>
|
||||
/// <param name="worstInputStatusCode">The worst OPC UA StatusCode across the script's input tags.</param>
|
||||
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
|
||||
private static AlarmConditionSnapshot ToSnapshot(
|
||||
AlarmConditionState condition, AlarmSeverity severity, string message, uint worstInputStatusCode) => new(
|
||||
Active: condition.Active == AlarmActiveState.Active,
|
||||
Acknowledged: condition.Acked == AlarmAckedState.Acknowledged,
|
||||
Confirmed: condition.Confirmed == AlarmConfirmedState.Confirmed,
|
||||
Enabled: condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(severity),
|
||||
Message: message,
|
||||
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode));
|
||||
Quality: QualityFromStatus(worstInputStatusCode));
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
|
||||
/// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
|
||||
/// transition (which for a still-active alarm with static dependencies may never come).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
|
||||
/// state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
|
||||
/// so the transition stream cannot carry its state. These tests pin both halves — the projection does
|
||||
/// report the state, and reading it does not fire an emission.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmProjectionTests
|
||||
{
|
||||
private static ScriptedAlarmDefinition Alarm(
|
||||
string id = "HighTemp",
|
||||
string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
|
||||
string msg = "Temp is {Temp}",
|
||||
AlarmSeverity sev = AlarmSeverity.High) =>
|
||||
new(AlarmId: id,
|
||||
EquipmentPath: "Plant/Line1/Reactor",
|
||||
AlarmName: id,
|
||||
Kind: AlarmKind.AlarmCondition,
|
||||
Severity: sev,
|
||||
MessageTemplate: msg,
|
||||
PredicateScriptSource: predicate);
|
||||
|
||||
/// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
|
||||
private static AlarmConditionState PersistedActive(
|
||||
string alarmId = "HighTemp",
|
||||
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
|
||||
DateTime? lastTransitionUtc = null) =>
|
||||
new(alarmId,
|
||||
AlarmEnabledState.Enabled,
|
||||
AlarmActiveState.Active,
|
||||
acked,
|
||||
AlarmConfirmedState.Confirmed,
|
||||
ShelvingState.Unshelved,
|
||||
lastTransitionUtc ?? DateTime.UtcNow,
|
||||
LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
|
||||
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
|
||||
Comments: []);
|
||||
|
||||
private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
|
||||
{
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
|
||||
}
|
||||
|
||||
/// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
|
||||
/// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
|
||||
[Fact]
|
||||
public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150); // predicate still true — no transition to report
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
var emissions = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load
|
||||
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
// The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
|
||||
// in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
|
||||
lock (emissions)
|
||||
{
|
||||
emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
|
||||
}
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.AlarmId.ShouldBe("HighTemp");
|
||||
projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
|
||||
projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
}
|
||||
|
||||
/// <summary>The projection carries the definition's severity and the message template resolved against
|
||||
/// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
|
||||
[Fact]
|
||||
public async Task Projection_carries_definition_severity_and_a_resolved_message()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
|
||||
}
|
||||
|
||||
/// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
|
||||
/// or every deploy would append a duplicate historian / alerts row.</summary>
|
||||
[Fact]
|
||||
public async Task Reading_projections_never_fires_an_emission()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
var emissions = 0;
|
||||
eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);
|
||||
|
||||
for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();
|
||||
|
||||
Volatile.Read(ref emissions).ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
|
||||
/// "nothing to restore" from "restore this".</summary>
|
||||
[Fact]
|
||||
public async Task A_normal_alarm_projects_the_no_event_position()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50); // predicate false
|
||||
using var eng = Build(up, new InMemoryAlarmStateStore());
|
||||
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
|
||||
projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
}
|
||||
|
||||
/// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
|
||||
/// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
|
||||
[Fact]
|
||||
public async Task Projection_carries_the_last_observed_worst_input_quality()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");
|
||||
|
||||
up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated
|
||||
|
||||
// The push re-evaluates on a background worker; poll for the tracked bucket to move.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
(eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
|
||||
}
|
||||
|
||||
/// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
|
||||
[Fact]
|
||||
public void Projections_are_empty_before_the_first_load()
|
||||
{
|
||||
using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
|
||||
eng.GetProjections().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+164
-4
@@ -86,20 +86,22 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
Retain: false,
|
||||
Enabled: true);
|
||||
|
||||
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream)
|
||||
private static ScriptedAlarmEngine BuildEngine(
|
||||
DependencyMuxTagUpstreamSource upstream, IAlarmStateStore? store = null)
|
||||
{
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
|
||||
return new ScriptedAlarmEngine(
|
||||
upstream, store ?? new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
|
||||
}
|
||||
|
||||
/// <summary>The local node id used by the redundancy-gating tests.</summary>
|
||||
private static readonly NodeId LocalNode = new("node-A");
|
||||
|
||||
private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn(
|
||||
TestProbe publish, TestProbe mux, NodeId? localNode = null)
|
||||
TestProbe publish, TestProbe mux, NodeId? localNode = null, IAlarmStateStore? store = null)
|
||||
{
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var engine = BuildEngine(upstream);
|
||||
var engine = BuildEngine(upstream, store);
|
||||
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine, localNode));
|
||||
return (host, upstream);
|
||||
}
|
||||
@@ -794,4 +796,162 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
evt.AlarmId.ShouldBe("alm-1");
|
||||
evt.TransitionKind.ShouldBe("Activated");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// #487 — post-(re)load condition-node re-assert.
|
||||
//
|
||||
// A deploy that triggers a FULL address-space rebuild re-materialises every condition node fresh
|
||||
// (inactive/acked/confirmed/unshelved). The engine reloads its persisted state and re-derives Active
|
||||
// from the predicate, so it ends up CORRECT — but a still-active alarm has no transition to report,
|
||||
// yielding EmissionKind.None, which OnEngineEmission drops. Without the re-assert the node reads
|
||||
// NORMAL to every OPC UA client until the alarm's next real transition — which, for an alarm with
|
||||
// static dependencies, may never come.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>A persisted condition state as it survives a restart / redeploy, seeded into the store the
|
||||
/// host's engine loads from.</summary>
|
||||
private static AlarmConditionState Persisted(
|
||||
string alarmId = "alm-1",
|
||||
AlarmEnabledState enabled = AlarmEnabledState.Enabled,
|
||||
AlarmActiveState active = AlarmActiveState.Active,
|
||||
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
|
||||
AlarmConfirmedState confirmed = AlarmConfirmedState.Confirmed,
|
||||
ShelvingKind shelving = ShelvingKind.Unshelved,
|
||||
DateTime? lastTransitionUtc = null) =>
|
||||
new(alarmId,
|
||||
enabled,
|
||||
active,
|
||||
acked,
|
||||
confirmed,
|
||||
shelving == ShelvingKind.Unshelved ? ShelvingState.Unshelved : new ShelvingState(shelving, null),
|
||||
lastTransitionUtc ?? DateTime.UtcNow,
|
||||
LastActiveUtc: active == AlarmActiveState.Active ? lastTransitionUtc ?? DateTime.UtcNow : null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
|
||||
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
|
||||
Comments: []);
|
||||
|
||||
/// <summary>Seed <paramref name="state"/> into a fresh store and hand it back for
|
||||
/// <see cref="Spawn"/>. The engine reads it during LoadAsync, exactly as it would after a restart.</summary>
|
||||
private static IAlarmStateStore StoreWith(AlarmConditionState state)
|
||||
{
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
store.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult();
|
||||
return store;
|
||||
}
|
||||
|
||||
/// <summary>#487, the regression: an alarm whose persisted state is ACTIVE emits nothing on reload
|
||||
/// (no transition to report), but the host must still write its state onto the freshly materialised
|
||||
/// condition node — otherwise an active alarm silently reads normal after a full-rebuild deploy.</summary>
|
||||
[Fact]
|
||||
public void Reload_reasserts_an_active_alarm_onto_its_condition_node()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) }));
|
||||
|
||||
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
|
||||
state.AlarmNodeId.ShouldBe("alm-1");
|
||||
state.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
state.State.Active.ShouldBeTrue("the persisted state was Active — the rebuilt node must say so");
|
||||
state.State.Acknowledged.ShouldBeFalse();
|
||||
state.State.Enabled.ShouldBeTrue();
|
||||
// The projection carries the definition's severity + resolved message, so a re-asserted node is
|
||||
// indistinguishable from one the alarm's own transition would have written.
|
||||
state.State.Severity.ShouldBe((ushort)1000); // 800 → Critical bucket → 1000
|
||||
state.State.Message.ShouldBe("condition");
|
||||
}
|
||||
|
||||
/// <summary>The re-assert is node-ONLY: it must never reach the <c>alerts</c> topic. That topic is the
|
||||
/// historization path, so an alerts row per alarm per deploy would append a duplicate historian /
|
||||
/// AVEVA record every time anyone deploys.</summary>
|
||||
[Fact]
|
||||
public void Reassert_never_publishes_to_the_alerts_topic()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
|
||||
|
||||
// Wait for the re-assert to actually happen FIRST — otherwise the absence window below could
|
||||
// elapse before the load even completed and would prove nothing.
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
|
||||
|
||||
// Absence assertion: the elapsed time IS the assertion, so this stays deliberately short.
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>An alarm sitting in the Part 9 no-event position already matches what the materialise
|
||||
/// built, so it is NOT re-asserted — a deploy where nothing is in alarm writes no condition nodes at
|
||||
/// all, and a never-fired alarm keeps the display name the materialise seeded as its Message.</summary>
|
||||
[Fact]
|
||||
public void A_normal_alarm_is_not_reasserted()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux); // empty store ⇒ Fresh ⇒ no-event position
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
|
||||
|
||||
// Sequence the absence: the re-assert (if any) happens right after the mux registration, so
|
||||
// waiting for that first makes the window below cover the moment the write would have landed.
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Not just Active: every field a rebuild would silently lose is restored. A shelved (but
|
||||
/// inactive) alarm is re-asserted too — a rebuilt node would otherwise come back unshelved, so an
|
||||
/// operator's suppression would evaporate on the next deploy.</summary>
|
||||
[Fact]
|
||||
public void Reload_reasserts_a_shelved_but_inactive_alarm()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted(
|
||||
active: AlarmActiveState.Inactive,
|
||||
acked: AlarmAckedState.Acknowledged,
|
||||
shelving: ShelvingKind.OneShot)));
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
|
||||
|
||||
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
|
||||
state.State.Active.ShouldBeFalse();
|
||||
state.State.Shelving.ShouldBe(AlarmShelvingKind.OneShot);
|
||||
}
|
||||
|
||||
/// <summary>The re-asserted timestamp is when the state actually came about, NOT the deploy instant —
|
||||
/// a client reading Time/ReceiveTime after a rebuild should still see when the alarm went active.</summary>
|
||||
[Fact]
|
||||
public void Reassert_carries_the_conditions_own_transition_timestamp()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var wentActiveAt = DateTime.UtcNow.AddHours(-3);
|
||||
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted(lastTransitionUtc: wentActiveAt)));
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
|
||||
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout)
|
||||
.TimestampUtc.ShouldBe(wentActiveAt);
|
||||
}
|
||||
|
||||
/// <summary>A disabled PLAN exposes no condition node at all (MaterialiseScriptedAlarms skips it) and
|
||||
/// is not loaded into the engine — so it must not be re-asserted either, or the write would target a
|
||||
/// node that does not exist.</summary>
|
||||
[Fact]
|
||||
public void A_disabled_plan_is_not_reasserted()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(enabled: false) }));
|
||||
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user