Files
Joseph Doherty 0796928504
v2-ci / unit-tests (push) Waiting to run
feat(scripting): batch submit, exact per-item deadlines, scripted-alarm seam on the out-of-process runner (lmxopcua#780)
- IScriptRunner.RunBatch: one EvalBatch frame per worker for every evaluation ready to run; per-item, streamed results.
  The default (InProcessScriptRunner) is RunAsync per item. Used by the Calculation pump (dequeue all, split at a
  repeated tag, publish in dequeue order), the VirtualTag seam (ScriptBatchCoalescer, no timer) and the scripted-alarm
  engine (whole dirty set). In process the pump and the engine stay one at a time, unchanged.
- Exact per-item deadlines: the worker hands work to an idle evaluation thread or defers it (EvalResults
  deferred_eval_ids); the host stops that run's clock and restarts T then G when it starts (started_eval_ids). A run
  queued behind a runaway is neither timed out nor struck and is a victim, not the culprit, at the kill.
- Scripted-alarm seam behind Scripting:Runner:Seams; unset Seams now means VirtualTag + Calculation, so alarms run out of
  process only when listed. Load still compiles each predicate (to PE bytes only) so a broken one fails the load; passes
  batch and apply in the serial order under the gate. Script faults freeze the condition BadInternalError; runner faults
  freeze it BadResourceUnavailable and re-evaluate on recovery; a dead worker never clears an alarm. IScriptRunnerProvider
  carries the runner to DriverHostActor.
- Perf harness: batch throughput (RunBatch vs RunAsync x n) and alarm-pass latency (in process / batched / serial);
  design §12.3 records macOS + windev. On windev the serial out-of-process alarm pass misses 250 ms from ~500 alarms;
  batched p99 <= 53 ms at 1,000.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 06:37:36 -05:00

173 lines
10 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#780 batch submit (design §4.5) against the real worker: <see cref="IScriptRunner.RunBatch"/> sends one frame
/// per worker and answers per item, and every item keeps its own deadlines and blame — in particular an item that has to
/// wait in the worker behind a runaway sibling is neither timed out, nor struck, nor blamed for the kill.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class BatchSubmitTests
{
/// <summary>Runs away when input <c>spin</c> is true, otherwise returns <c>n + 1</c>.</summary>
private const string MaybeRunaway = "if ((bool)ctx.GetTag(\"spin\").Value) { while (true) { } } return (int)ctx.GetTag(\"n\").Value + 1;";
/// <summary>Spins for input <c>ms</c> milliseconds, then returns it.</summary>
private const string Spin = "var ms = (int)ctx.GetTag(\"ms\").Value; var end = ctx.Now.AddMilliseconds(ms); while (ctx.Now < end) { } return ms;";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static IReadOnlyDictionary<string, DataValueSnapshot> Inputs(params (string Path, object Value)[] values)
{
var map = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var (path, value) in values) map[path] = new DataValueSnapshot(value, 0, DateTime.UtcNow, DateTime.UtcNow);
return map;
}
private static ScriptInvocation Item(string key, bool spin, int n, TimeSpan? timeout = null)
=> Scripts.Vt(key, MaybeRunaway, timeout, Inputs(("spin", spin), ("n", n)));
private static OutOfProcessScriptRunner Runner(ScriptingOptions options, int evalThreads)
=> new(options, new LoggerConfiguration().CreateLogger(), null,
heartbeatInterval: TimeSpan.FromSeconds(1), loadTimeout: TimeSpan.FromSeconds(60), evalThreads: evalThreads);
[Fact]
public async Task A_batch_of_loaded_scripts_goes_out_as_one_frame_and_every_item_gets_its_own_result()
{
await using var runner = Runner(Scripts.Options(), evalThreads: 4);
// First evaluations load (compile) each key; those take the full path one by one.
var warm = await Task.WhenAll(runner.RunBatch(Enumerable.Range(0, 40).Select(i => Item($"k{i}", false, i)).ToArray(), Ct));
warm.ShouldAllBe(o => o.Status == ScriptRunStatus.Ok);
var frames = new ConcurrentQueue<int>();
runner.BatchSubmittedForTest = frames.Enqueue;
var outcomes = await Task.WhenAll(runner.RunBatch(Enumerable.Range(0, 40).Select(i => Item($"k{i}", false, i * 10)).ToArray(), Ct));
frames.ToArray().ShouldBe([40], "every loaded item rides one EvalBatch frame");
for (var i = 0; i < 40; i++)
{
outcomes[i].Status.ShouldBe(ScriptRunStatus.Ok, outcomes[i].Reason);
outcomes[i].Value.ShouldBe(i * 10 + 1, $"item {i} got its own result");
}
}
[Fact]
public async Task A_batch_answers_exactly_what_the_same_evaluations_answer_one_by_one()
{
// Parity: Ok, a throw, a compile error, a duplicate key (refused) and a fast item, batched vs RunAsync.
ScriptInvocation[] Items() =>
[
Scripts.Vt("ok", "return (int)ctx.GetTag(\"n\").Value * 2;", inputs: Inputs(("n", 21))),
Scripts.Vt("throws", "throw new System.InvalidOperationException(\"boom\");"),
Scripts.Vt("broken", "return undefinedThing;"),
Scripts.Vt("fast", "return 5;"),
];
await using var runner = Runner(Scripts.Options(), evalThreads: 2);
var single = new List<ScriptRunOutcome>();
foreach (var item in Items()) single.Add(await runner.RunAsync(item, Ct));
var batched = await Task.WhenAll(runner.RunBatch(Items(), Ct));
batched.Select(o => (o.Status, o.Value, o.Reason)).ShouldBe(single.Select(o => (o.Status, o.Value, o.Reason)));
single[0].Value.ShouldBe(42);
single[1].Reason!.ShouldStartWith("script threw: ");
single[2].Reason!.ShouldStartWith("compile error: ");
// In process, the default RunBatch is RunAsync per item: same answers.
using var inProcess = new InProcessScriptRunner();
var inProcessBatched = await Task.WhenAll(((IScriptRunner)inProcess).RunBatch(Items(), Ct));
inProcessBatched.Select(o => (o.Status, o.Value)).ShouldBe(single.Select(o => (o.Status, o.Value)));
// A key twice in one batch: the second is refused, exactly like two concurrent RunAsync calls.
var twice = await Task.WhenAll(runner.RunBatch([Scripts.Vt("dup", Spin, inputs: Inputs(("ms", 200))), Scripts.Vt("dup", Spin, inputs: Inputs(("ms", 1)))], Ct));
twice.Count(o => o.Reason == ScriptRunGuard.PreviousRunStillRunningReason).ShouldBe(1);
}
/// <summary>
/// The blame rule (design §5.3.1) under batching: with ONE evaluation thread, a runaway at the head of a batch holds
/// the thread and its siblings wait in the worker. The siblings are the kill's victims — Unavailable "worker
/// restarted", no timeout, no strike — and only the runaway is killed and quarantined. Mutation check: with the
/// host ignoring the worker's deferral notice, the siblings' clocks run from submit, they time out at T with the
/// runaway, and at T + G any of the four can be the one blamed.
/// </summary>
[Fact]
public async Task A_runaway_in_a_batch_is_blamed_alone_and_siblings_queued_behind_it_are_not_timed_out_or_struck()
{
var softT = TimeSpan.FromMilliseconds(400);
var grace = TimeSpan.FromMilliseconds(500);
await using var runner = Runner(Scripts.Options(hardGrace: grace, quarantineAfterKills: 1), evalThreads: 1);
var keys = new[] { "runaway", "sib1", "sib2", "sib3" };
foreach (var key in keys) (await runner.RunAsync(Item(key, false, 1, softT), Ct)).Value.ShouldBe(2);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
var frames = new ConcurrentQueue<int>();
runner.BatchSubmittedForTest = frames.Enqueue;
var sw = Stopwatch.StartNew();
var tasks = runner.RunBatch([Item("runaway", true, 0, softT), Item("sib1", false, 10, softT), Item("sib2", false, 20, softT), Item("sib3", false, 30, softT)], Ct);
frames.ToArray().ShouldBe([4]);
var runaway = await tasks[0];
runaway.Status.ShouldBe(ScriptRunStatus.Failed);
runaway.Reason!.ShouldStartWith("script timed out after ");
// Nothing has answered the queued siblings at the runaway's soft deadline.
tasks.Skip(1).ShouldAllBe(t => !t.IsCompleted, "a run waiting behind a busy thread is not timed out at its submit-relative T");
var exit = await worker.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct);
exit.Reason.ShouldBe(WorkerExitReason.KilledDeadline);
exit.CulpritKey.ShouldBe(InProcessScriptRunner.ScopedKey(ScriptContextKind.VirtualTag, "runaway"));
sw.Elapsed.ShouldBeGreaterThanOrEqualTo(softT + grace - TimeSpan.FromMilliseconds(50));
var siblings = await Task.WhenAll(tasks.Skip(1));
foreach (var sibling in siblings)
{
sibling.Status.ShouldBe(ScriptRunStatus.Unavailable, "a victim of another key's kill, not a script failure");
sibling.Reason.ShouldBe(ScriptRunOutcome.WorkerRestartedReason);
}
await Scripts.Eventually(() => runner.QuarantinedKeys(ScriptContextKind.VirtualTag).Count > 0, TimeSpan.FromSeconds(5), "the kill quarantine");
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBe(["runaway"], "the siblings took no strike");
// The siblings run again on the restarted worker, with their own inputs.
var again = await Task.WhenAll(runner.RunBatch([Item("sib1", false, 10, softT), Item("sib2", false, 20, softT), Item("sib3", false, 30, softT)], Ct));
again.Select(o => o.Value).ShouldBe([11, 21, 31]);
}
/// <summary>
/// The positive control for the deferral: a run that waited behind a sibling still gets its deadlines — the full T,
/// then G — counted from when it STARTED. Mutation check: a start notice that does not re-arm the clock leaves the
/// deferred runaway with no deadline at all; it is never answered or killed.
/// </summary>
[Fact]
public async Task A_deferred_run_is_timed_and_killed_from_when_it_starts()
{
var softT = TimeSpan.FromMilliseconds(400);
var grace = TimeSpan.FromMilliseconds(500);
var head = TimeSpan.FromMilliseconds(300);
await using var runner = Runner(Scripts.Options(hardGrace: grace, quarantineAfterKills: 5), evalThreads: 1);
(await runner.RunAsync(Scripts.Vt("head", Spin, softT, Inputs(("ms", 1))), Ct)).Value.ShouldBe(1);
(await runner.RunAsync(Item("tail", false, 1, softT), Ct)).Value.ShouldBe(2);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
var sw = Stopwatch.StartNew();
var tasks = runner.RunBatch([Scripts.Vt("head", Spin, softT, Inputs(("ms", (int)head.TotalMilliseconds))), Item("tail", true, 0, softT)], Ct);
(await tasks[0]).Value.ShouldBe((int)head.TotalMilliseconds, "the head ran inside its budget");
var tail = await tasks[1];
var answered = sw.Elapsed;
tail.Reason!.ShouldStartWith("script timed out after ");
answered.ShouldBeGreaterThanOrEqualTo(head + softT - TimeSpan.FromMilliseconds(50), "T counts from the run's start, after the head");
var exit = await worker.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct);
exit.CulpritKey.ShouldBe(InProcessScriptRunner.ScopedKey(ScriptContextKind.VirtualTag, "tail"));
sw.Elapsed.ShouldBeGreaterThanOrEqualTo(head + softT + grace - TimeSpan.FromMilliseconds(50), "G counts from the run's start too");
}
}