Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptBatchCoalescerTests.cs
T
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

104 lines
4.6 KiB
C#

using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// lmxopcua#780 (design §4.5) — the virtual-tag seam's opportunistic batching: an evaluation on an idle seam is
/// submitted at once by its own caller (no timer, no added latency), and evaluations submitted while a submission
/// is in progress go to the runner together, in arrival order, each caller getting its own outcome.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptBatchCoalescerTests
{
private static readonly IReadOnlyDictionary<string, DataValueSnapshot> NoInputs = new Dictionary<string, DataValueSnapshot>();
private static ScriptInvocation Vt(string key) => new(key, ScriptContextKind.VirtualTag, "return 1;", NoInputs, TimeSpan.FromSeconds(1));
/// <summary>Answers each key with itself; the first <see cref="RunBatch"/> blocks until released.</summary>
private sealed class BlockingRunner : IScriptRunner
{
private int _calls;
public ManualResetEventSlim Entered { get; } = new();
public ManualResetEventSlim Release { get; } = new();
public ConcurrentQueue<string[]> Batches { get; } = new();
public bool BlockFirst { get; init; } = true;
public Task<ScriptRunOutcome> RunAsync(ScriptInvocation invocation, CancellationToken ct = default)
=> throw new InvalidOperationException("the coalescer must submit through RunBatch");
public IReadOnlyList<Task<ScriptRunOutcome>> RunBatch(IReadOnlyList<ScriptInvocation> invocations, CancellationToken ct = default)
{
Batches.Enqueue(invocations.Select(i => i.Key).ToArray());
if (BlockFirst && Interlocked.Increment(ref _calls) == 1)
{
Entered.Set();
Release.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue();
}
return invocations.Select(i => Task.FromResult(ScriptRunOutcome.Ok(i.Key))).ToArray();
}
public void Reset(ScriptContextKind kind) { }
public IReadOnlyCollection<string> QuarantinedKeys(ScriptContextKind kind) => [];
}
[Fact]
public async Task An_evaluation_on_an_idle_seam_is_submitted_at_once_by_its_own_caller()
{
var runner = new BlockingRunner { BlockFirst = false };
var coalescer = new ScriptBatchCoalescer(runner);
var outcome = coalescer.RunAsync(Vt("only"));
outcome.IsCompletedSuccessfully.ShouldBeTrue("submitted synchronously, no timer");
(await outcome).Value.ShouldBe("only");
runner.Batches.ToArray().ShouldBe([["only"]]);
}
[Fact]
public async Task Evaluations_submitted_while_a_submission_is_in_progress_go_together_in_arrival_order()
{
var runner = new BlockingRunner();
var coalescer = new ScriptBatchCoalescer(runner);
var first = Task.Run(() => coalescer.RunAsync(Vt("first")), TestContext.Current.CancellationToken);
runner.Entered.Wait(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken).ShouldBeTrue();
var queued = Enumerable.Range(0, 10).Select(i => coalescer.RunAsync(Vt($"k{i}"))).ToArray();
queued.ShouldAllBe(t => !t.IsCompleted, "queued behind the submission in progress");
runner.Release.Set();
(await first).Value.ShouldBe("first");
var outcomes = await Task.WhenAll(queued);
outcomes.Select(o => o.Value).ShouldBe(Enumerable.Range(0, 10).Select(i => (object?)$"k{i}"));
runner.Batches.ToArray().ShouldBe([["first"], [.. Enumerable.Range(0, 10).Select(i => $"k{i}")]]);
}
[Fact]
public async Task A_runner_that_throws_does_not_strand_its_callers()
{
var coalescer = new ScriptBatchCoalescer(new ThrowingRunner());
var outcome = await coalescer.RunAsync(Vt("x"));
outcome.Status.ShouldBe(ScriptRunStatus.Failed);
outcome.Reason!.ShouldContain("boom");
(await coalescer.RunAsync(Vt("y"))).Status.ShouldBe(ScriptRunStatus.Failed, "the flush flag was released");
}
private sealed class ThrowingRunner : IScriptRunner
{
public Task<ScriptRunOutcome> RunAsync(ScriptInvocation invocation, CancellationToken ct = default) => throw new NotSupportedException();
public IReadOnlyList<Task<ScriptRunOutcome>> RunBatch(IReadOnlyList<ScriptInvocation> invocations, CancellationToken ct = default)
=> throw new InvalidOperationException("boom");
public void Reset(ScriptContextKind kind) { }
public IReadOnlyCollection<string> QuarantinedKeys(ScriptContextKind kind) => [];
}
}