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
168 lines
9.4 KiB
C#
168 lines
9.4 KiB
C#
using System.Collections.Concurrent;
|
|
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// WP7 register-AND-consume proof: dependency values actually FLOW end-to-end through the REAL
|
|
/// <see cref="DependencyMuxActor"/> → <see cref="DependencyConsumerMuxAdapter"/> →
|
|
/// <see cref="CalculationDriver"/> path (not merely that <see cref="IDependencyConsumer"/> exists).
|
|
/// A source value published to the mux is fed into the calc driver, which computes and publishes a
|
|
/// value; and a calc tag reading ANOTHER calc tag's output (calc-of-calc) recomputes when the first
|
|
/// tag's output re-enters the mux — the exact re-entry the host's <c>ForwardBatchToMux</c> performs, mirrored
|
|
/// here by looping the driver's <see cref="ISubscribable.OnDataChange"/> back into the mux.
|
|
/// </summary>
|
|
public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly DateTime Ts = new(2026, 7, 16, 10, 0, 0, DateTimeKind.Utc);
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Build the merged DriverConfig JSON the ApplyDelta path re-binds from (mirrors the deploy
|
|
/// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig).</summary>
|
|
private static string ConfigJson(params RawTagEntry[] tags) =>
|
|
System.Text.Json.JsonSerializer.Serialize(new { rawTags = tags });
|
|
|
|
[Fact]
|
|
public async Task Source_value_flows_through_mux_and_adapter_into_the_calc_driver_and_propagates_calc_of_calc()
|
|
{
|
|
// A: out = src/a * 2. B: b = A.out + 1 (calc-of-calc; B depends on A's RawPath).
|
|
var driver = new CalculationDriver(
|
|
new CalculationDriverOptions
|
|
{
|
|
RawTags =
|
|
[
|
|
CalcTag("calc/engine/out", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"),
|
|
CalcTag("calc/engine/b", "return (double)ctx.GetTag(\"calc/engine/out\").Value + 1.0;"),
|
|
],
|
|
},
|
|
"calc-drv");
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// The driver declares interest in both the external source AND the first calc tag's output.
|
|
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "calc/engine/out", "src/a" });
|
|
|
|
var published = new ConcurrentQueue<DataChangeEventArgs>();
|
|
var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux");
|
|
|
|
// Loop the driver's outputs back into the mux exactly as DriverHostActor.ForwardBatchToMux does — this
|
|
// is what makes calc-of-calc chains work.
|
|
driver.OnDataChange += (_, e) =>
|
|
{
|
|
published.Enqueue(e);
|
|
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
|
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
|
"calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
|
|
};
|
|
|
|
// The REAL adapter registers the driver's dependency refs on the mux + forwards changes into it.
|
|
Sys.ActorOf(DependencyConsumerMuxAdapter.Props(driver, mux), "dep-adapter");
|
|
|
|
// Publish the external source into the mux. AwaitAssert re-Tells (idempotent after the first
|
|
// compute dedups) until the computed value has flowed all the way through.
|
|
AwaitAssert(() =>
|
|
{
|
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
|
"src-drv", "src/a", 21.0, OpcUaStatus.Good, Ts));
|
|
|
|
// Leg 1 — dep flow: src/a=21 → A computes 42 and publishes on calc/engine/out.
|
|
published.ShouldContain(e => e.FullReference == "calc/engine/out" && Equals(e.Snapshot.Value, 42.0));
|
|
// Leg 2 — calc-of-calc: A's 42 re-entered the mux → B computes 43 on calc/engine/b.
|
|
published.ShouldContain(e => e.FullReference == "calc/engine/b" && Equals(e.Snapshot.Value, 43.0));
|
|
}, PresenceBudget, TimeSpan.FromMilliseconds(100));
|
|
}
|
|
|
|
/// <summary>
|
|
/// REDEPLOY proof (review HIGH): a pure DriverConfig change (calc tags added / removed, a script
|
|
/// edited) routes to an in-place <see cref="CalculationDriver.ReinitializeAsync"/> — NOT a respawn. The
|
|
/// driver must rebuild its tag table + dependency-ref set so the new authored set actually takes effect,
|
|
/// and the mux adapter must re-register the NEW refs AFTER reinit. This asserts all four failure modes
|
|
/// the pre-fix code exhibited: an added tag reading a NEW dep computes; a removed tag stops publishing;
|
|
/// an edited script serves the NEW result; and the changed dependency-ref set re-registers on the mux.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Redeploy_rebuilds_calc_tag_state_and_reregisters_new_dependency_refs()
|
|
{
|
|
// Initial authored set: "keep" = src/a*10 (script gets EDITED), "remove" = src/a+5 (gets REMOVED).
|
|
var driver = new CalculationDriver(
|
|
new CalculationDriverOptions
|
|
{
|
|
RawTags =
|
|
[
|
|
CalcTag("calc/keep", "return (double)ctx.GetTag(\"src/a\").Value * 10.0;"),
|
|
CalcTag("calc/remove", "return (double)ctx.GetTag(\"src/a\").Value + 5.0;"),
|
|
],
|
|
},
|
|
"calc-drv");
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Initially the driver only cares about src/a.
|
|
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a" });
|
|
|
|
var published = new ConcurrentQueue<DataChangeEventArgs>();
|
|
var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux2");
|
|
driver.OnDataChange += (_, e) =>
|
|
{
|
|
published.Enqueue(e);
|
|
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
|
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished("calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
|
|
};
|
|
var adapter = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(driver, mux), "dep-adapter2");
|
|
|
|
// Leg 1 — before redeploy: src/a=2 → keep=20, remove=7.
|
|
AwaitAssert(() =>
|
|
{
|
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 2.0, OpcUaStatus.Good, Ts));
|
|
published.ShouldContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 20.0));
|
|
published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
|
|
}, PresenceBudget, TimeSpan.FromMilliseconds(100));
|
|
|
|
// ---- REDEPLOY (in-place ApplyDelta) ----
|
|
// New authored set: "keep" script EDITED to src/a*1000; "add" is a NEW tag reading a NEW dep src/b;
|
|
// "remove" is dropped entirely.
|
|
var newConfig = ConfigJson(
|
|
CalcTag("calc/keep", "return (double)ctx.GetTag(\"src/a\").Value * 1000.0;"),
|
|
CalcTag("calc/add", "return (double)ctx.GetTag(\"src/b\").Value + 100.0;"));
|
|
await driver.ReinitializeAsync(newConfig, CancellationToken.None);
|
|
|
|
// The rebuilt dependency-ref set reflects the NEW authored tags (src/a from keep, src/b from add).
|
|
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b" });
|
|
|
|
// Part 2 sequencing: the host re-registers the adapter AFTER reinit (DeltaApplied → HandleDeltaApplied).
|
|
// Mirror that here — re-register so the adapter adopts the NEW ref set (incl. the brand-new src/b).
|
|
adapter.Tell(new DependencyConsumerMuxAdapter.ReRegister());
|
|
|
|
// Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the
|
|
// new result; the REMOVED tag never publishes a value derived from the post-redeploy input.
|
|
AwaitAssert(() =>
|
|
{
|
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/b", 50.0, OpcUaStatus.Good, Ts));
|
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 3.0, OpcUaStatus.Good, Ts));
|
|
|
|
// (a) new dep B flows through the RE-REGISTERED interest → new tag computes: 50 + 100 = 150.
|
|
published.ShouldContain(e => e.FullReference == "calc/add" && Equals(e.Snapshot.Value, 150.0));
|
|
// (c) edited script produces the NEW result: 3 * 1000 = 3000 (NOT the pre-edit 3 * 10 = 30).
|
|
published.ShouldContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 3000.0));
|
|
}, PresenceBudget, TimeSpan.FromMilliseconds(100));
|
|
|
|
// (b) the removed tag never recomputes on the post-redeploy src/a=3 (it would be 3 + 5 = 8) and the
|
|
// edited "keep" never serves the stale *10 result (3 * 10 = 30). Give any errant compute a moment to land.
|
|
await Task.Delay(200);
|
|
published.ShouldNotContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 8.0));
|
|
published.ShouldNotContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 30.0));
|
|
}
|
|
}
|