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
+2
View File
@@ -49,6 +49,8 @@ new ScriptedAlarmDefinition(
Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator<AlarmPredicateContext, bool>` compiles the source, `ScriptRunGuard` runs it with the node's `Scripting:RunTimeout` budget (default 2 s — the same budget virtual tags get; before lmxopcua#779 the host built the engine without a budget and every predicate silently ran on `TimedScriptEvaluator.DefaultTimeout`, 250 ms), and `DependencyExtractor` statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in [VirtualTags.md](VirtualTags.md); ScriptedAlarms does not redefine them. The known resource limits — unbounded script-side memory and the orphan-thread CPU-budget caveat — are documented in that file as well; per-publish assembly accretion was resolved by the Core.Scripting-008 collectible-`AssemblyLoadContext` rewrite and no longer requires periodic server restarts.
**Out of process (opt-in, lmxopcua#780).** With `Scripting:Runner=OutOfProcess` **and** `ScriptedAlarm` listed in `Scripting:Runner:Seams` (an unset list leaves alarms in process — they go last in the rollout), the predicates run in the pre-emptible `ScriptWorker` pool. `LoadAsync` still compiles every predicate, to PE bytes only, so a broken predicate fails the load exactly as before; the worker compiles and runs it. Each re-evaluation pass (and the startup-recovery pass) submits every dirty alarm's predicate in **one batch** and applies the results — Part 9 transition, persist-before-update, emissions — in the serial order under the engine's gate, so the transition sequence is the in-process one and a large engine pays one round trip per pass, not one per alarm. Failure semantics (design §9): a predicate that times out, is killed at its hard deadline or is quarantined freezes its condition with a `BadInternalError` quality annotation, as in process; one whose worker restarted under it, or that found no worker ready, freezes with **`BadResourceUnavailable`** (`0x80040000`) and is re-evaluated by itself when the worker is back. No outcome but a completed `bool` reaches the state machine, so a dead worker never clears an alarm. Quarantines (`ScriptedAlarmEngine.QuarantinedAlarmIds`) lift on a reload. See [VirtualTags.md](VirtualTags.md) §"Out-of-process runner".
`AlarmPredicateContext` (`AlarmPredicateContext.cs`) is the script's `ScriptContext` subclass:
- `GetTag(path)` returns a `DataValueSnapshot` from the engine-maintained read cache. Missing path → `DataValueSnapshot(null, 0x80340000u, null, now)` (`BadNodeIdUnknown`). An empty path returns the same.