55ae230fa4
- v2-ci.yml: upload-artifact@v4 -> @v3 + continue-on-error (act_runner reports as GHES and v4 refuses GHES, failing every run); runner-capacity diagnostic step; AKKA_TEST_TIMEFACTOR=3 on the test step. - Runtime/ControlPlane harnesses: akka.test.timefactor from AKKA_TEST_TIMEFACTOR (TestTimeFactor), single-expect-default 15s; ControlPlane gains PresenceBudget. - Leaked spinner threads: the infinite-loop timeout tests (Calculation, Host.IntegrationTests RoslynVirtualTagEvaluator, 3x Core.Scripting) pinned a core for the rest of the test process (abandoned, never stopped). Now a bounded 5 s wall-clock spin; outer compile-inclusive bound 30 s. - Real test races fixed: ScriptedAlarmEngine push-during-drain (Inactive is also the start state + persist-before-update => wait for the Cleared save); DriverReconnectE2e (both nodes host the driver; Created[] is last-writer-wins and ForceReconnect is a no-op while Connecting); ControlPlaneStarterOrder (lost-race heal waited for a 60 s refresh tick). - Fixed sleeps -> poll-for-condition: ReconnectSupervisor LastError, Modbus deadband steps (wait for observed reads), Modbus dispose hygiene precondition, FOCAS probe-loop log. - Short presence budgets -> PresenceBudget in the Runtime/ControlPlane tests seen failing on Gitea; Calculation Settle 10 -> 30 s. No assertion removed, nothing skipped. - infra/README.md §10 CI; stale TreatWarningsAsErrors comment in Directory.Build.props. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
319 lines
15 KiB
C#
319 lines
15 KiB
C#
using System.Collections.Concurrent;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
|
|
|
/// <summary>
|
|
/// Archreview <b>05/STAB-19</b> — what the Calculation driver does with a dependency's <b>quality</b>.
|
|
///
|
|
/// <para><b>The defect.</b> The driver stored dependency <i>values</i> only and ignored the status code
|
|
/// entirely, so a comms-lost input arrived as <em>arrived, Good, null</em>: the warm-up gate was
|
|
/// satisfied by a value that never came, <c>(double)ctx.GetTag("x").Value</c> threw on every trigger
|
|
/// (a "script threw" storm), and a null-tolerant script published a <b>Good</b> output computed from a
|
|
/// dead input — indefinitely.</para>
|
|
///
|
|
/// <para><b>The policy pinned here</b> matches <c>ScriptedAlarmEngine</c>'s, which is the in-tree
|
|
/// precedent: <b>hold on Bad, propagate the worst input's status; evaluate through Uncertain and stamp
|
|
/// the output with it.</b> Holding (rather than evaluating and degrading) is what removes the NRE
|
|
/// storm; a Bad sub-code (rather than Uncertain) is what tells the operator the value may be
|
|
/// arbitrarily old rather than merely imprecise.</para>
|
|
///
|
|
/// <para>⚠️ <b>Vacuity rule:</b> the recovery case re-publishes an <b>identical</b> computed value.
|
|
/// A recovery whose value differs passes against a value-only dedup and proves nothing — it is the
|
|
/// "Bad forever" trap the v3 Batch 4 live gate found.</para>
|
|
/// </summary>
|
|
public sealed class CalculationDriverInputQualityTests
|
|
{
|
|
private const uint StatusGood = 0u;
|
|
private const uint StatusUncertain = 0x40000000u;
|
|
private const uint StatusBadNoCommunication = 0x80310000u;
|
|
private const uint StatusBadInternalError = 0x80020000u;
|
|
private const uint StatusBadWaitingForInitialData = 0x80320000u;
|
|
|
|
private const string SumTag = "calc/e/sum";
|
|
private const string SumScript = "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;";
|
|
|
|
private static RawTagEntry CalcTag(string rawPath, string source)
|
|
{
|
|
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":true," +
|
|
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
|
|
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
|
|
}
|
|
|
|
private static (CalculationDriver Driver, ConcurrentQueue<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs)
|
|
Build(params RawTagEntry[] tags)
|
|
{
|
|
var logs = new List<ScriptLogEntry>();
|
|
var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
|
|
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
|
|
var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot);
|
|
var published = new ConcurrentQueue<DataChangeEventArgs>();
|
|
driver.OnDataChange += (_, e) => published.Enqueue(e);
|
|
return (driver, published, logs);
|
|
}
|
|
|
|
/// <summary>Local copy of the script-log capture sink (the sibling suite's is private to it).</summary>
|
|
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
|
|
{
|
|
private readonly object _lock = new();
|
|
|
|
public void Publish(ScriptLogEntry entry)
|
|
{
|
|
lock (_lock) sink.Add(entry);
|
|
}
|
|
}
|
|
|
|
// 30 s ceiling: covers a cold Roslyn compile of the first evaluation on a loaded CI runner, where
|
|
// 10 s ran out (#606). A quiescence wait — it returns the instant the pump drains.
|
|
private static async Task Settle(CalculationDriver driver)
|
|
{
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (driver.IsEvaluationIdle) return;
|
|
await Task.Delay(10);
|
|
}
|
|
|
|
driver.IsEvaluationIdle.ShouldBeTrue("the calc driver's evaluation pump should have drained");
|
|
}
|
|
|
|
/// <summary>Drives both deps Good and drains the resulting publish, leaving a settled Good output.</summary>
|
|
private static async Task<CalculationDriver> WarmedUpAsync(ConcurrentQueue<DataChangeEventArgs> published, CalculationDriver driver)
|
|
{
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
|
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var first).ShouldBeTrue();
|
|
first!.Snapshot.Value.ShouldBe(30.0);
|
|
first.Snapshot.StatusCode.ShouldBe(StatusGood);
|
|
return driver;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Bad input holds the script and publishes the input's own sub-code, once, carrying the last
|
|
/// known value. Three things are asserted together because each is a separate way the old
|
|
/// behaviour was wrong: no evaluation (no NRE storm), the sub-code propagated (not a generic
|
|
/// <c>BadInternalError</c>, which would blame the script), and no script-log entry (an input
|
|
/// dying is not a script fault).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_bad_input_holds_evaluation_and_publishes_the_inputs_own_sub_code_once()
|
|
{
|
|
var (driver, published, logs) = Build(CalcTag(SumTag, SumScript));
|
|
await WarmedUpAsync(published, driver);
|
|
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var bad).ShouldBeTrue();
|
|
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
|
|
bad.Snapshot.StatusCode.ShouldNotBe(StatusBadInternalError, "the script did not fail — its input did");
|
|
bad.Snapshot.Value.ShouldBe(30.0, "the last known value rides out with the Bad status");
|
|
logs.ShouldBeEmpty("a dead input is not a script fault; the script log stays the per-failure diagnostic");
|
|
|
|
// Transition-once: a second Bad push of the same code changes nothing observable.
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
published.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A different Bad sub-code IS republished: the sub-code is the diagnostic, and masking
|
|
/// <c>BadNoCommunication</c> behind an earlier <c>BadWaitingForInitialData</c> (or vice versa)
|
|
/// hides which input died and why.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_different_bad_sub_code_is_republished()
|
|
{
|
|
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
|
|
await WarmedUpAsync(published, driver);
|
|
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
published.TryDequeue(out _).ShouldBeTrue();
|
|
|
|
driver.OnDependencyValue("src/a", null, StatusBadInternalError, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var second).ShouldBeTrue();
|
|
second!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>The vacuity-sensitive assertion.</b> The input recovers with the value it had before the
|
|
/// outage, so the computed output is <b>identical</b> to the pre-outage one — and it must still be
|
|
/// republished Good, because the node is currently Bad and only a publish can clear it. A
|
|
/// value-only dedup swallows exactly this case, which is why a recovery test with a changed value
|
|
/// is not the guard.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_identical_value_is_republished_when_recovering_from_a_bad_input()
|
|
{
|
|
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
|
|
await WarmedUpAsync(published, driver);
|
|
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
published.TryDequeue(out _).ShouldBeTrue();
|
|
|
|
// The device comes back holding the same reading it had before.
|
|
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var recovered).ShouldBeTrue();
|
|
recovered!.Snapshot.Value.ShouldBe(30.0, "the same value as before the outage — that is the point");
|
|
recovered.Snapshot.StatusCode.ShouldBe(StatusGood);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An Uncertain input is still computable, so the script runs and the output inherits the
|
|
/// Uncertain code — the alarm engine's rule (Uncertain is "ready"). Holding here would take a
|
|
/// perfectly good arithmetic result hostage.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_uncertain_input_is_evaluated_and_stamps_the_output_uncertain()
|
|
{
|
|
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
|
|
await WarmedUpAsync(published, driver);
|
|
|
|
driver.OnDependencyValue("src/a", 11.0, StatusUncertain, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var uncertain).ShouldBeTrue();
|
|
uncertain!.Snapshot.Value.ShouldBe(31.0, "the script DID run — Uncertain is usable");
|
|
uncertain.Snapshot.StatusCode.ShouldBe(StatusUncertain);
|
|
|
|
// …and returning to Good republishes, even though the computed value changes back to a value the
|
|
// tag has held before.
|
|
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
published.TryDequeue(out var good).ShouldBeTrue();
|
|
good!.Snapshot.StatusCode.ShouldBe(StatusGood);
|
|
good.Snapshot.Value.ShouldBe(30.0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cold start is unchanged: with one dep never pushed, a Bad arrival on the other publishes
|
|
/// nothing at all (the node stays at the materialiser's <c>BadWaitingForInitialData</c>). Only once
|
|
/// every dep has arrived does a Bad among them become reportable.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Nothing_is_published_while_a_dep_has_never_arrived()
|
|
{
|
|
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
published.ShouldBeEmpty("src/b has never arrived — this is warm-up, not a fault");
|
|
|
|
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var bad).ShouldBeTrue();
|
|
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A passthrough script over a Bad input must not publish <c>Ok(null)</c> Good — the worst case of
|
|
/// the laundering, since the passthrough fast path bypasses the read cache entirely.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_passthrough_over_a_bad_input_is_bad_not_good_null()
|
|
{
|
|
var (driver, published, _) = Build(CalcTag("calc/e/pass", "return ctx.GetTag(\"src/a\").Value;"));
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
driver.OnDependencyValue("src/a", 42.0, StatusGood, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
published.TryDequeue(out var good).ShouldBeTrue();
|
|
good!.Snapshot.Value.ShouldBe(42.0);
|
|
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var bad).ShouldBeTrue();
|
|
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
|
|
bad.Snapshot.StatusCode.ShouldNotBe(StatusGood);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ReadAsync</c> reports what the subscriber was told. A read that answered Good while the
|
|
/// subscription carried Bad would leave two clients of the same node disagreeing about it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_reports_the_same_status_the_subscriber_saw()
|
|
{
|
|
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
|
|
await WarmedUpAsync(published, driver);
|
|
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
published.TryDequeue(out var bad).ShouldBeTrue();
|
|
|
|
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
|
|
|
|
read[0].StatusCode.ShouldBe(bad!.Snapshot.StatusCode);
|
|
read[0].Value.ShouldBe(bad.Snapshot.Value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A tag whose ONLY publish was inputs-Bad — it never produced a value — must read back that Bad
|
|
/// status, not <c>BadWaitingForInitialData</c>. Guarding the read on "has a value" rather than
|
|
/// "has published" made a polled read say "never polled" while the subscriber on the same node had
|
|
/// been told <c>BadNoCommunication</c>: the two clients disagree, and the read is the one that
|
|
/// lies (something HAS been observed about this tag — that its input is dead).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_after_an_inputs_Bad_with_no_prior_value_reports_the_bad_status()
|
|
{
|
|
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
// The first thing that ever happens to this tag is a dead input — no successful evaluation, so
|
|
// there is no last value to carry.
|
|
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
|
|
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
|
|
await Settle(driver);
|
|
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var bad).ShouldBeTrue();
|
|
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
|
|
bad.Snapshot.Value.ShouldBeNull();
|
|
|
|
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
|
|
|
|
read[0].StatusCode.ShouldBe(StatusBadNoCommunication);
|
|
read[0].StatusCode.ShouldNotBe(StatusBadWaitingForInitialData,
|
|
"something HAS been observed about this tag — that its input is dead");
|
|
read[0].Value.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>A never-evaluated tag still reads BadWaitingForInitialData (regression).</summary>
|
|
[Fact]
|
|
public async Task An_unevaluated_tag_still_reads_BadWaitingForInitialData()
|
|
{
|
|
var (driver, _, _) = Build(CalcTag(SumTag, SumScript));
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
|
|
|
|
read[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
|
|
}
|
|
}
|