fix(scripted-alarms): ScriptedAlarmHostActor review fixes — load-gen guard, quiet cancel, parse guard (T9 review)

This commit is contained in:
Joseph Doherty
2026-06-10 15:08:54 -04:00
parent 3b418a54f1
commit dafaf2faec
2 changed files with 133 additions and 23 deletions
@@ -50,6 +50,23 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
Retain: true,
Enabled: enabled);
/// <summary>Plan whose predicate references an unknown identifier so it fails to compile — applying it
/// faults the engine's LoadAsync. Used to prove a faulted load doesn't crash the host.</summary>
private static EquipmentScriptedAlarmPlan BadPlan(string id = "bad-1") =>
new(
ScriptedAlarmId: id,
EquipmentId: "Plant/Line1/M",
Name: "Broken",
AlarmType: "AlarmCondition",
Severity: 500,
MessageTemplate: "broken",
PredicateScriptId: $"{id}-script",
PredicateSource: "return unknownIdentifier;", // uncompilable → LoadAsync throws
DependencyRefs: Array.Empty<string>(),
HistorizeToAveva: false,
Retain: false,
Enabled: true);
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream)
{
var logger = new LoggerConfiguration().CreateLogger();
@@ -170,4 +187,54 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
second.TagRefs.ShouldContain("M.Y");
second.TagRefs.ShouldNotContain("M.T");
}
/// <summary>Faulted load resilience: applying an alarm whose predicate doesn't compile faults the
/// engine's LoadAsync. The host logs a Warning (Status.Failure handler) and stays alive — it must
/// still process a subsequent valid apply, registering interest for that apply's dep refs.</summary>
[Fact]
public void Faulted_load_does_not_crash_host()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux);
// Apply a plan that fails to compile — LoadAsync faults, the host swallows it as a Warning and
// does NOT register interest (no dep refs to register, and the load never completed).
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { BadPlan() }));
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// Prove the actor is still responsive: a later valid apply loads + registers interest as normal.
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-ok", depRef: "M.T") }));
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
reg.TagRefs.ShouldContain("M.T");
}
/// <summary>Stale-load guard (fix A): two back-to-back applies with different dep-ref sets must end
/// with the mux holding the SECOND (latest-generation) set — a stale earlier completion must never
/// re-introduce the first set's refs.
///
/// <para>Limitation: forcing the two LoadAsync continuations to complete out of order is not
/// deterministic via real async timing (both loads are short + run on the thread pool). This test
/// therefore validates the guard's observable contract rather than the race itself: it fishes for
/// the RegisterInterest carrying the second apply's refs, then asserts no LATER RegisterInterest
/// re-introduces the first apply's refs. With the generation guard in place the latest apply always
/// wins; without it, an out-of-order stale completion could land a "M.A"-bearing RegisterInterest
/// after the "M.B" one — which this assertion would catch.</para></summary>
[Fact]
public void Stale_load_does_not_register_superseded_dep_refs()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux);
// Fire two applies in quick succession with disjoint dep refs; the second supersedes the first.
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-a", depRef: "M.A") }));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-b", depRef: "M.B") }));
// The latest generation must win: fish for the RegisterInterest reflecting the second apply.
mux.FishForMessage<DependencyMuxActor.RegisterInterest>(r => r.TagRefs.Contains("M.B"), Timeout);
// No LATER RegisterInterest may re-introduce the first (superseded) apply's "M.A" ref.
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
}