feat(scripting): Scripting:Runner=Shadow — in-process results published, each compiled run mirrored to the ScriptWorker pool and compared (lmxopcua#780 P2)

- ShadowScriptRunner (Core.Scripting): IScriptShadow.Mirror copies the inputs and queues the shadow run on the
  pool; compares value (structural), CLR type, status and failure class (compile / budget / threw+message /
  unsupported_type); runner-fault shadow outcomes (Unavailable, worker busy) are not compared. Bounded:
  Scripting:Shadow:MaxInFlight (256) and one per script; drops counted (behind | key_busy).
- Metrics otopcua.script.shadow.{compared,mismatch{script},dropped,inflight}; one rate-limited Warning per
  script and reason with a diff summary (Scripting:Shadow:MismatchLogsPerMinute, re-armed at the seam's deploy).
- The VirtualTag, Calculation and scripted-alarm seams recognise the shadow and keep their in-process path
  byte-for-byte (no coalescer, one-at-a-time pump, in-process engine), mirroring after each compiled run.
- ScriptRunnerSelection builds the shadow over an OutOfProcessScriptRunner whose lines carry
  ScriptRunnerRole=Shadow; Status() is null under Shadow so readiness never sees the shadow pool; the
  script-runner health entry stays Healthy and reports the shadow counters as data.
- docker-dev/docker-compose.script-runner-shadow.yml; docs/VirtualTags.md "Shadow mode".
- Tests: comparison table, per-script metric + once-per-reason log, rate limit, hung/blocking/throwing shadow
  never reaches the caller, MaxInFlight drop accounting, input copy; real-worker divergent + unsupported-type
  scripts counted against exactly those scripts, a worker-only runaway killed/quarantined there with every
  published value intact, alarm transitions identical to in process across a kill -9 of the shadow worker.
  Mutation-proved (16 mutants, 15 red; the one survivor added an assertion).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 07:18:40 -05:00
parent 0796928504
commit 03686eb5d7
17 changed files with 1477 additions and 52 deletions
@@ -89,6 +89,9 @@ public sealed class ScriptingOptions
/// <summary>The out-of-process worker pool's knobs (<c>Scripting:Worker</c>).</summary>
public ScriptWorkerOptions Worker { get; set; } = new();
/// <summary>The shadow comparison's knobs (<c>Scripting:Shadow</c>), for <see cref="ScriptRunnerMode.Shadow"/>.</summary>
public ScriptShadowOptions Shadow { get; set; } = new();
/// <summary>Whether <paramref name="seam"/> is selected for out-of-process execution by these options.</summary>
/// <param name="seam">The seam.</param>
/// <returns><c>true</c> when <see cref="Runner"/> is <see cref="ScriptRunnerMode.OutOfProcess"/> and the seam is in
@@ -185,6 +188,8 @@ public sealed class ScriptingOptions
var worker = (Worker ?? new ScriptWorkerOptions()).Sanitized(out var workerNotes);
notes.AddRange(workerNotes);
var shadow = (Shadow ?? new ScriptShadowOptions()).Sanitized(out var shadowNotes);
notes.AddRange(shadowNotes);
adjustments = notes;
return new ScriptingOptions
@@ -196,6 +201,7 @@ public sealed class ScriptingOptions
Runner = Runner,
RunnerSeams = RunnerSeams is null ? null : [.. RunnerSeams],
Worker = worker,
Shadow = shadow,
};
}
}