feat(scripting): batch submit, exact per-item deadlines, scripted-alarm seam on the out-of-process runner (lmxopcua#780)
v2-ci / unit-tests (push) Waiting to run

- 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
This commit is contained in:
Joseph Doherty
2026-11-10 06:37:36 -05:00
parent 11a5c1d4e2
commit 0796928504
33 changed files with 2168 additions and 117 deletions
@@ -522,6 +522,14 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// false timeouts under pool starvation).</summary>
private async Task RunEvaluationPumpAsync()
{
// lmxopcua#780 (design §4.5): with an out-of-process runner a run costs a pipe round trip, so the pump submits
// everything queued as one batch and publishes in dequeue order. In process it stays one at a time, unchanged.
if (_evaluator.SubmitsBatches)
{
await RunEvaluationBatchPumpAsync().ConfigureAwait(false);
return;
}
while (true)
{
PendingEvaluation next;
@@ -557,6 +565,73 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
}
}
/// <summary>
/// The pump on the out-of-process path (lmxopcua#780, design §4.5): dequeue everything pending — each entry's
/// dependency snapshot was captured at enqueue — submit it as ONE batch, and publish in dequeue order. A calc tag
/// queued more than once (a burst of changes) ends the batch at its second entry, so every queued evaluation still
/// runs, in order, against the snapshot that triggered it, exactly as the one-at-a-time pump would run them.
/// </summary>
private async Task RunEvaluationBatchPumpAsync()
{
while (true)
{
List<PendingEvaluation> batch;
lock (_lock)
{
if (!_running) _pending.Clear();
if (_pending.Count == 0)
{
_pumpScheduled = false;
return;
}
batch = [];
var inBatch = new HashSet<string>(StringComparer.Ordinal);
while (_pending.Count > 0 && inBatch.Add(_pending.Peek().Definition.RawPath)) batch.Add(_pending.Dequeue());
}
if (!await TryEnterEvalGateAsync().ConfigureAwait(false)) return; // disposed
try
{
await RunEvaluationBatchAsync(batch).ConfigureAwait(false);
}
catch (Exception ex)
{
// Defect path only (the evaluator never throws for script faults); must not strand the queue.
_logger.LogError(ex, "Calculation {Driver}: batch evaluation of {Count} tag(s) failed", _driverInstanceId, batch.Count);
}
finally
{
_evalGate.Release();
}
}
}
/// <summary>Runs one batch and publishes every outcome in dequeue order. Under <see cref="_evalGate"/>, not <see cref="_lock"/>.</summary>
/// <param name="batch">The prepared evaluations; no calc tag twice.</param>
private async Task RunEvaluationBatchAsync(IReadOnlyList<PendingEvaluation> batch)
{
// Hold-on-Bad entries never reach the runner; the rest are submitted together.
var requests = new List<(string, string, IReadOnlyDictionary<string, DataValueSnapshot>)>(batch.Count);
var slot = new int[batch.Count];
for (var i = 0; i < batch.Count; i++)
{
if (!InputQuality.IsUsable(batch[i].WorstInputStatus)) { slot[i] = -1; continue; }
slot[i] = requests.Count;
requests.Add((batch[i].Definition.RawPath, batch[i].Definition.ScriptSource, batch[i].Dependencies));
}
var results = _evaluator.EvaluateBatch(requests, _evalCts.Token);
BatchSubmittedForTest?.Invoke(requests.Count);
for (var i = 0; i < batch.Count; i++)
{
if (slot[i] < 0) HoldOnBadInput(batch[i]);
else ApplyEvaluationResult(batch[i], await results[slot[i]].ConfigureAwait(false));
}
}
/// <summary>Test hook: the number of compiled evaluations each batch pass submitted.</summary>
internal Action<int>? BatchSubmittedForTest { get; set; }
/// <summary>Timer-trigger entry point — prepares and evaluates a single calc tag (the scheduler's
/// per-group in-flight guard is what bounds outstanding timer work; it stays set until this task
/// completes).</summary>
@@ -610,16 +685,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
// sub-code. Uncertain IS usable: the script runs and the output inherits the Uncertain code.
if (!InputQuality.IsUsable(pending.WorstInputStatus))
{
(object? Value, uint StatusCode)? badPublish;
lock (_lock)
{
if (!_stateByRawPath.TryGetValue(def.RawPath, out var badState)) return;
badPublish = MarkInputsBad(badState, pending.WorstInputStatus, pending.TimestampUtc);
}
// No script log: an input dying is not a script fault, and the script log is the per-failure
// SCRIPT diagnostic. Here the status code is the diagnostic.
if (badPublish is { } bad) Publish(def.RawPath, bad.Value, bad.StatusCode, pending.TimestampUtc);
HoldOnBadInput(pending);
return;
}
@@ -627,7 +693,32 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
// then the input's real ones rather than a fabricated Good stamped at evaluation time.
var result = await _evaluator.EvaluateAsync(def.RawPath, def.ScriptSource, pending.Dependencies, _evalCts.Token)
.ConfigureAwait(false);
ApplyEvaluationResult(pending, result);
}
/// <summary>A Bad input holds the script: publish the input's own status (once per transition), no script log.</summary>
/// <param name="pending">The prepared evaluation whose worst input is Bad.</param>
private void HoldOnBadInput(PendingEvaluation pending)
{
var def = pending.Definition;
(object? Value, uint StatusCode)? badPublish;
lock (_lock)
{
if (!_stateByRawPath.TryGetValue(def.RawPath, out var badState)) return;
badPublish = MarkInputsBad(badState, pending.WorstInputStatus, pending.TimestampUtc);
}
// No script log: an input dying is not a script fault, and the script log is the per-failure
// SCRIPT diagnostic. Here the status code is the diagnostic.
if (badPublish is { } bad) Publish(def.RawPath, bad.Value, bad.StatusCode, pending.TimestampUtc);
}
/// <summary>Record one evaluation's outcome and publish it. Under <see cref="_evalGate"/>, not <see cref="_lock"/>.</summary>
/// <param name="pending">The prepared evaluation.</param>
/// <param name="result">Its outcome.</param>
private void ApplyEvaluationResult(PendingEvaluation pending, VirtualTagEvalResult result)
{
var def = pending.Definition;
string? failureToLog = null;
(object? Value, uint StatusCode)? toPublish;
lock (_lock)
@@ -283,6 +283,62 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IScriptQuarantineO
}
}
/// <summary>
/// Whether compiled runs go to an <see cref="IScriptRunner"/> (the out-of-process seam), so the driver's pump can
/// submit everything it has dequeued as one batch (lmxopcua#780, design §4.5). False on the in-process path, whose
/// pump stays one evaluation at a time, unchanged.
/// </summary>
public bool SubmitsBatches => _runner is not null && !_disposed;
/// <summary>
/// Evaluates several calc tags at once (design §4.5): the passthrough fast path and the trivial failures are answered
/// inline exactly as <see cref="EvaluateAsync"/> answers them, and every compiled run is handed to the runner in ONE
/// <see cref="IScriptRunner.RunBatch"/>. Results are per item; each item keeps <see cref="EvaluateAsync"/>'s
/// outcome, budget and blame. Without a runner it is <see cref="EvaluateAsync"/> per item, in order.
/// </summary>
/// <param name="requests">The evaluations: calc-tag id, script source, dependency snapshots. A calc tag must not appear twice.</param>
/// <param name="ct">Caller cancellation (driver shutdown).</param>
/// <returns>One task per request, in order; none faults.</returns>
public IReadOnlyList<Task<VirtualTagEvalResult>> EvaluateBatch(
IReadOnlyList<(string CalcTagId, string ScriptSource, IReadOnlyDictionary<string, DataValueSnapshot> Dependencies)> requests,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(requests);
var results = new Task<VirtualTagEvalResult>[requests.Count];
if (_runner is null || _disposed)
{
for (var i = 0; i < requests.Count; i++)
results[i] = EvaluateAsync(requests[i].CalcTagId, requests[i].ScriptSource, requests[i].Dependencies, ct);
return results;
}
List<ScriptInvocation>? invocations = null;
List<int>? indexes = null;
for (var i = 0; i < requests.Count; i++)
{
var (id, source, deps) = requests[i];
// Exactly the fast answers EvaluateAsync gives before it reaches the runner.
if (string.IsNullOrWhiteSpace(source) || PassthroughScript.TryMatch(source, out _))
{
results[i] = EvaluateAsync(id, source, deps, ct);
continue;
}
var scriptLog = _scriptRoot
.ForContext(ScriptLoggerFactory.ScriptIdProperty, id)
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, id);
(invocations ??= []).Add(new ScriptInvocation(id, ScriptContextKind.Calculation, source, deps, _runTimeout, scriptLog));
(indexes ??= []).Add(i);
}
if (invocations is null) return results;
var outcomes = _runner.RunBatch(invocations, ct);
for (var j = 0; j < outcomes.Count; j++) results[indexes![j]] = ToResultAsync(outcomes[j]);
return results;
static async Task<VirtualTagEvalResult> ToResultAsync(Task<ScriptRunOutcome> outcome)
=> (await outcome.ConfigureAwait(false)).ToVirtualTagEvalResult();
}
/// <summary>Record a compile failure for <paramref name="sourceKey"/> and log it ONCE — the next trigger
/// of the same source returns the remembered reason without a Roslyn round-trip or a second Warning.</summary>
private VirtualTagEvalResult RememberCompileFailure(string sourceKey, string calcTagId, string reason, Exception ex, string what)