Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverBatchPumpTests.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

111 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
/// <summary>
/// lmxopcua#780 batch submit (design §4.5) in the Calculation pump: with an out-of-process runner the pump hands
/// everything it dequeued to ONE <see cref="IScriptRunner.RunBatch"/> and publishes in dequeue order. A tag queued
/// twice ends the batch, so every queued evaluation still runs, in order, against the snapshot that triggered it —
/// the published sequence is exactly the one-at-a-time pump's.
/// </summary>
public sealed class CalculationDriverBatchPumpTests
{
private const uint StatusGood = 0u;
/// <summary>
/// Answers <c>src/a × n</c> for tag <c>calc/tn</c>. The first batch is held until <see cref="Release"/>, so the
/// test can queue more triggers behind it.
/// </summary>
private sealed class GatedRunner : IScriptRunner
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _batches;
public ConcurrentQueue<string[]> Batches { get; } = new();
public int SingleRuns;
public void Release() => _gate.SetResult();
public Task<ScriptRunOutcome> RunAsync(ScriptInvocation invocation, CancellationToken ct = default)
{
Interlocked.Increment(ref SingleRuns);
return Task.FromResult(Answer(invocation));
}
public IReadOnlyList<Task<ScriptRunOutcome>> RunBatch(IReadOnlyList<ScriptInvocation> invocations, CancellationToken ct = default)
{
Batches.Enqueue(invocations.Select(i => i.Key).ToArray());
var held = Interlocked.Increment(ref _batches) == 1;
return invocations.Select(i => held ? Later(i) : Task.FromResult(Answer(i))).ToArray();
}
private async Task<ScriptRunOutcome> Later(ScriptInvocation invocation)
{
await _gate.Task.ConfigureAwait(false);
return Answer(invocation);
}
private static ScriptRunOutcome Answer(ScriptInvocation invocation)
=> ScriptRunOutcome.Ok((double)invocation.Inputs["src/a"].Value! * int.Parse(invocation.Key["calc/t".Length..]));
public void Reset(ScriptContextKind kind) { }
public IReadOnlyCollection<string> QuarantinedKeys(ScriptContextKind kind) => [];
}
private static RawTagEntry CalcTag(string rawPath, int factor)
{
var source = $"return (double)ctx.GetTag(\"src/a\").Value * {factor};";
var json = $"{{\"scriptId\":\"s{factor}\",\"changeTriggered\":true,\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
}
private static async Task Settle(CalculationDriver driver)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < deadline && !driver.IsEvaluationIdle) await Task.Delay(10);
driver.IsEvaluationIdle.ShouldBeTrue();
}
[Fact]
public async Task The_pump_submits_what_it_dequeued_as_one_batch_and_publishes_in_dequeue_order()
{
var runner = new GatedRunner();
var driver = new CalculationDriver(
new CalculationDriverOptions { RawTags = [CalcTag("calc/t1", 1), CalcTag("calc/t2", 2), CalcTag("calc/t3", 3)], ScriptRunner = runner },
"calc-drv", new ScriptRootLogger(new LoggerConfiguration().CreateLogger()));
var published = new ConcurrentQueue<(string Tag, object? Value, uint Status)>();
driver.OnDataChange += (_, e) => published.Enqueue((e.FullReference, e.Snapshot.Value, e.Snapshot.StatusCode));
var sizes = new ConcurrentQueue<int>();
driver.BatchSubmittedForTest = sizes.Enqueue;
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", 1.0, StatusGood, DateTime.UtcNow);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline && runner.Batches.IsEmpty) await Task.Delay(5, TestContext.Current.CancellationToken);
// Two more triggers queue behind the held batch: six evaluations, each tag twice.
driver.OnDependencyValue("src/a", 2.0, StatusGood, DateTime.UtcNow);
driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
runner.Release();
await Settle(driver);
runner.SingleRuns.ShouldBe(0);
runner.Batches.ToArray().ShouldBe(
[["calc/t1", "calc/t2", "calc/t3"], ["calc/t1", "calc/t2", "calc/t3"], ["calc/t1", "calc/t2", "calc/t3"]],
"a tag queued twice ends the batch; every queued evaluation still runs");
sizes.ToArray().ShouldBe([3, 3, 3]);
published.ToArray().ShouldBe(
[
("calc/t1", 1.0, StatusGood), ("calc/t2", 2.0, StatusGood), ("calc/t3", 3.0, StatusGood),
("calc/t1", 2.0, StatusGood), ("calc/t2", 4.0, StatusGood), ("calc/t3", 6.0, StatusGood),
("calc/t1", 3.0, StatusGood), ("calc/t2", 6.0, StatusGood), ("calc/t3", 9.0, StatusGood),
], "dequeue order, each against the snapshot that triggered it");
}
}