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

153 lines
8.3 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.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>A minimal <see cref="ITagUpstreamSource"/>: set or push a value; push notifies subscribers synchronously.</summary>
internal sealed class TestUpstream : ITagUpstreamSource
{
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs = new(StringComparer.Ordinal);
public void Set(string path, object? value, uint status = 0u)
=> _values[path] = new DataValueSnapshot(value, status, DateTime.UtcNow, DateTime.UtcNow);
public void Push(string path, object? value, uint status = 0u)
{
Set(path, value, status);
if (!_subs.TryGetValue(path, out var list)) return;
Action<string, DataValueSnapshot>[] observers;
lock (list) observers = [.. list];
foreach (var observer in observers) observer(path, _values[path]);
}
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v) ? v : new DataValueSnapshot(null, 0x80320000u, null, DateTime.UtcNow);
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
{
var list = _subs.GetOrAdd(path, _ => []);
lock (list) list.Add(observer);
return new Unsubscribe(() => { lock (list) list.Remove(observer); });
}
private sealed class Unsubscribe(Action dispose) : IDisposable
{
public void Dispose() => dispose();
}
}
/// <summary>
/// lmxopcua#780 — the scripted-alarm seam over the REAL worker (design §9, alarm column): a runaway predicate is killed
/// at T + G and quarantined, its condition frozen with a <c>BadInternalError</c> annotation and never transitioned; a
/// sibling alarm on the same shard keeps evaluating; and a worker that dies under an in-flight predicate freezes its
/// condition <c>BadResourceUnavailable</c> — it never clears it — and the alarm is re-evaluated by itself on return.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class AlarmSeamTests
{
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadResourceUnavailable = 0x80040000u;
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static ScriptedAlarmDefinition Alarm(string id, string predicate) =>
new(AlarmId: id, EquipmentPath: "Plant/Line1/Reactor", AlarmName: id, Kind: AlarmKind.AlarmCondition,
Severity: AlarmSeverity.High, MessageTemplate: "condition", PredicateScriptSource: predicate);
private static ScriptedAlarmEngine Engine(TestUpstream up, IScriptRunner runner, TimeSpan budget)
{
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger,
scriptTimeout: budget, scriptRunner: runner);
}
[Fact]
public async Task A_runaway_predicate_is_killed_and_quarantined_its_alarm_frozen_Bad_and_a_sibling_keeps_evaluating()
{
var budget = TimeSpan.FromMilliseconds(300);
var log = new CapturingSink();
await using var runner = Scripts.Runner(Scripts.Options(hardGrace: TimeSpan.FromMilliseconds(500), quarantineAfterKills: 2), log.CreateLogger());
var up = new TestUpstream();
up.Set("x", 0);
using var engine = Engine(up, runner, budget);
var events = new ConcurrentQueue<ScriptedAlarmEvent>();
engine.OnEvent += (_, e) => events.Enqueue(e);
await engine.LoadAsync(
[
Alarm("runaway", "if ((int)ctx.GetTag(\"x\").Value > 5) { while (true) { } } return false;"),
Alarm("sibling", "return (int)ctx.GetTag(\"x\").Value > 1;"),
], Ct);
var first = runner.Shards[0].Current.ShouldNotBeNull();
up.Push("x", 10);
await Scripts.Eventually(() => events.Any(e => e is { AlarmId: "sibling", Emission: EmissionKind.Activated }), TimeSpan.FromSeconds(10), "the sibling's activation");
await Scripts.Eventually(() => events.Any(e => e is { AlarmId: "runaway", Emission: EmissionKind.QualityChanged }), TimeSpan.FromSeconds(10), "the runaway's annotation");
events.Single(e => e.AlarmId == "runaway").WorstInputStatusCode.ShouldBe(StatusBadInternalError);
(await first.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct)).CulpritKey
.ShouldBe(InProcessScriptRunner.ScopedKey(ScriptContextKind.AlarmPredicate, "runaway"));
// The second kill quarantines it; the sibling (same shard, worker restarted twice) keeps evaluating. Re-pushed:
// Exited completes before the dead worker's evaluations are released, so the first push after it can find the
// runaway's slot still held and be refused (not a strike, exactly as in process) — the next push runs it.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
for (var x = 11; !engine.QuarantinedAlarmIds.Contains("runaway"); x++)
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("no kill quarantine; events: " +
string.Join(", ", events.Select(e => $"{e.AlarmId}:{e.Emission}:{e.WorstInputStatusCode:X8}")) +
Environment.NewLine + string.Join(Environment.NewLine, log.Rendered));
up.Push("x", x);
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
}
engine.QuarantinedAlarmIds.ShouldBe(["runaway"]);
up.Push("x", 0);
await Scripts.Eventually(() => events.Any(e => e is { AlarmId: "sibling", Emission: EmissionKind.Cleared }), TimeSpan.FromSeconds(60), "the sibling's clear");
engine.GetState("runaway")!.Active.ShouldBe(AlarmActiveState.Inactive, "a predicate that never returned never transitions");
events.Where(e => e.AlarmId == "runaway").ShouldAllBe(e => e.Emission == EmissionKind.QualityChanged);
// A redeploy (reload) lifts the quarantine, as in process.
await engine.LoadAsync([Alarm("runaway", "return (int)ctx.GetTag(\"x\").Value > 5;"), Alarm("sibling", "return (int)ctx.GetTag(\"x\").Value > 1;")], Ct);
engine.QuarantinedAlarmIds.ShouldBeEmpty();
}
[Fact]
public async Task A_worker_that_dies_under_a_predicate_freezes_it_BadResourceUnavailable_never_clears_it_and_it_is_re_evaluated_on_return()
{
await using var runner = Scripts.Runner(Scripts.Options(probation: 1));
var up = new TestUpstream();
up.Set("ms", 1);
using var engine = Engine(up, runner, TimeSpan.FromSeconds(5));
var events = new ConcurrentQueue<ScriptedAlarmEvent>();
engine.OnEvent += (_, e) => events.Enqueue(e);
await engine.LoadAsync([Alarm("slow",
"var ms = (int)ctx.GetTag(\"ms\").Value; var end = ctx.Now.AddMilliseconds(ms); while (ctx.Now < end) { } return ms > 0;")], Ct);
engine.GetState("slow")!.Active.ShouldBe(AlarmActiveState.Active);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
events.Clear();
up.Push("ms", 1500);
await Task.Delay(300, Ct); // the predicate is spinning in the worker
Process.GetProcessById(worker.Pid).Kill();
await Scripts.Eventually(() => events.Any(e => e.Emission == EmissionKind.QualityChanged), TimeSpan.FromSeconds(10), "the runner-failure annotation");
events.First().WorstInputStatusCode.ShouldBe(StatusBadResourceUnavailable);
engine.GetState("slow")!.Active.ShouldBe(AlarmActiveState.Active, "a dead worker never clears an alarm");
// No further input change: the runner's recovery re-evaluates it (in the isolation worker) and the quality returns.
await Scripts.Eventually(() => events.Any(e => e is { Emission: EmissionKind.QualityChanged, WorstInputStatusCode: 0u }),
TimeSpan.FromSeconds(60), "the re-evaluation on return");
engine.GetState("slow")!.Active.ShouldBe(AlarmActiveState.Active);
events.ShouldNotContain(e => e.Emission == EmissionKind.Cleared);
}
}