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
@@ -34,8 +34,22 @@ internal sealed class WorkerHost
private readonly int _parentPid;
private readonly ConcurrentDictionary<string, LoadedScript> _scripts = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<long, RunningEval> _running = new();
/// <summary>
/// Hand-off to an IDLE evaluation thread: an evaluation is put here only when a thread is known to be waiting
/// for it (<see cref="_idle"/>), so whatever is put here starts at once. Everything else waits in
/// <see cref="_backlog"/> and is reported to the host as deferred (lmxopcua#780 batch submit: per-item deadlines
/// stay exact, see the proto's <c>EvalResults</c>).
/// </summary>
private readonly BlockingCollection<EvalRequest> _queue = new(new ConcurrentQueue<EvalRequest>());
/// <summary>Evaluations that arrived while every evaluation thread was busy, in arrival order. Under <see cref="_schedLock"/>.</summary>
private readonly Queue<EvalRequest> _backlog = new();
private readonly object _schedLock = new();
/// <summary>Evaluation threads committed to taking from <see cref="_queue"/>, minus hand-offs not yet taken. Under <see cref="_schedLock"/>.</summary>
private int _idle;
/// <summary>
/// Loads waiting for a compile thread. Compiles are long, synchronous and CPU-bound, so they run on
/// <see cref="CompileThreads"/> dedicated threads, never on the thread pool: a burst of cold loads (a node start
@@ -225,8 +239,20 @@ internal sealed class WorkerHost
if (_scripts.TryRemove(key, out var gone)) gone.Dispose();
return null;
case Envelope.BodyOneofCase.Eval:
foreach (var item in frame.Eval.Items) Admit(item);
{
List<ulong>? deferred = null;
foreach (var item in frame.Eval.Items)
if (!Admit(item)) (deferred ??= []).Add(item.EvalId);
// One notice per incoming frame. It can reach the host after the deferred run's own start notice (an
// evaluation thread may pick the run up before this is queued); the host treats the two as unordered.
if (deferred is not null)
{
var notice = new EvalResults();
notice.DeferredEvalIds.AddRange(deferred);
Send(new Envelope { Results = notice });
}
return null;
}
case Envelope.BodyOneofCase.Cancel:
if (_running.TryGetValue((long)frame.Cancel.EvalId, out var toCancel))
{
@@ -288,38 +314,72 @@ internal sealed class WorkerHost
Send(new Envelope { LoadResult = result });
}
private void Admit(EvalRequest request)
/// <summary>
/// Schedules one evaluation: straight to an idle evaluation thread when there is one (it starts now, so the
/// host's clock, started at submit, is right), otherwise into the backlog.
/// </summary>
/// <param name="request">The evaluation.</param>
/// <returns>False when it was deferred to the backlog (the host must stop its clock until it starts).</returns>
private bool Admit(EvalRequest request)
{
if (_queue.Count >= _queueCapacity)
lock (_schedLock)
{
SendResult(new EvalResult
if (_backlog.Count + _queue.Count < _queueCapacity)
{
EvalId = request.EvalId,
Error = new ScriptError { Kind = ScriptError.Types.Kind.Busy, Message = "script worker busy" },
});
return;
if (_idle > 0 && _backlog.Count == 0)
{
_idle--;
_queue.Add(request);
return true;
}
_backlog.Enqueue(request);
return false;
}
}
_queue.Add(request);
// Full: answered at once, outside the lock (a send may write to the pipe on this thread).
SendResult(new EvalResult
{
EvalId = request.EvalId,
Error = new ScriptError { Kind = ScriptError.Types.Kind.Busy, Message = "script worker busy" },
});
return true;
}
private void EvalThreadLoop()
{
foreach (var request in _queue.GetConsumingEnumerable())
EvalResult? finished = null;
while (true)
{
EvalResult result;
// A thread that just finished a run takes the oldest deferred evaluation, and its start notice rides on
// the finished run's result frame. With nothing deferred it goes idle and waits for a hand-off.
EvalRequest? request = null;
lock (_schedLock)
{
if (finished is not null && _backlog.TryDequeue(out var next)) request = next;
else _idle++;
}
if (finished is not null)
{
var frame = new EvalResults();
frame.Items.Add(finished);
if (request is not null) frame.StartedEvalIds.Add(request.EvalId);
Send(new Envelope { Results = frame });
finished = null;
}
request ??= _queue.Take();
try
{
result = Evaluate(request);
finished = Evaluate(request);
}
catch (Exception ex)
{
result = new EvalResult
finished = new EvalResult
{
EvalId = request.EvalId,
Error = new ScriptError { Kind = ScriptError.Types.Kind.Threw, Message = ex.Message },
};
}
SendResult(result);
}
}
@@ -431,7 +491,7 @@ internal sealed class WorkerHost
Heartbeat = new Heartbeat
{
Inflight = (uint)_running.Count,
Queued = (uint)_queue.Count,
Queued = (uint)QueuedCount(),
HeapBytes = GC.GetTotalMemory(false),
Gen2Count = (uint)GC.CollectionCount(2),
},
@@ -489,6 +549,11 @@ internal sealed class WorkerHost
Environment.Exit(ScriptWorkerEnvironment.ExitPipeClosed);
}
private int QueuedCount()
{
lock (_schedLock) return _backlog.Count + _queue.Count;
}
private void SendResult(EvalResult result)
{
var results = new EvalResults();