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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user