feat(scripting): self-contained script worker publish, docker-dev drill, §8 perf harness (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run

- A self-contained Host publish (-r win-x64 --self-contained, the Windows installs) now publishes the
  ScriptWorker self-contained for the same RID into script-worker/. The launcher starts its apphost
  directly when runtimeconfig.json lists includedFrameworks. A framework-dependent worker (docker-dev)
  keeps running through the dotnet muxer.
- docker-dev/docker-compose.script-runner.yml: OutOfProcess for the central pair's VT + Calc seams.
  Live drill recorded in design §12.1 (runaway kill + quarantine, kill -9 recovery, open circuit
  with /health/ready still 200, metrics).
- ScriptRunnerPerfTests (Category=Perf, opt-in via OTOPCUA_SCRIPT_PERF=1): InProcess vs OutOfProcess
  at K=1/2/4. The accepted 5k evals/s/node gate passes on macOS and windev. The host-CPU, RSS and
  Windows latency budget misses are tracked as #789.
- Fix: the single-flight slot is released before the awaiting caller resumes. Before, a caller that
  started its next run right after the previous one returned could be refused with "previous
  evaluation still running", which the seams publish as Bad.
- Fix: the worker compiles on dedicated threads, not the thread pool. A burst of cold loads starved
  the heartbeat, and the host killed the shard (found on windev). Load-timeout queueing is #790.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 02:53:53 -05:00
parent f50b29abae
commit 1ead8f458a
13 changed files with 836 additions and 20 deletions
@@ -38,6 +38,17 @@ internal sealed class WorkerHost
private readonly ConcurrentDictionary<string, LoadedScript> _scripts = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<long, RunningEval> _running = new();
private readonly BlockingCollection<EvalRequest> _queue = new(new ConcurrentQueue<EvalRequest>());
/// <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
/// or a deploy with an empty PE cache) used to put one blocking pool item per load in front of the heartbeat
/// timer and the pipe writer, and the host killed the worker for silence (found on windev, lmxopcua#780 §8).
/// </summary>
private readonly BlockingCollection<LoadScripts> _loads = new(new ConcurrentQueue<LoadScripts>());
/// <summary>Dedicated compile threads: half the processors, at least 1, at most 8.</summary>
internal static int CompileThreads => Math.Clamp(Environment.ProcessorCount / 2, 1, 8);
private readonly ForwardingLogSink _logSink;
private readonly ILogger _scriptLogger;
private int _maxFrameBytes = ScriptWorkerFrameCodec.DefaultMaxFrameBytes;
@@ -116,6 +127,10 @@ internal sealed class WorkerHost
{
new Thread(EvalThreadLoop) { IsBackground = true, Name = $"ScriptWorker.Eval{i}" }.Start();
}
for (var i = 0; i < CompileThreads; i++)
{
new Thread(CompileThreadLoop) { IsBackground = true, Name = $"ScriptWorker.Compile{i}" }.Start();
}
new Thread(WatchdogLoop) { IsBackground = true, Name = "ScriptWorker.Watchdog" }.Start();
_ = Task.Run(HeartbeatLoopAsync);
_ = Task.Run(LogFlushLoopAsync);
@@ -146,9 +161,9 @@ internal sealed class WorkerHost
{
case Envelope.BodyOneofCase.Load:
{
// Compile off the reader, so evaluations for already-loaded keys keep flowing during a load.
var load = frame.Load;
_ = Task.Run(() => Load(load));
// Compile off the reader, so evaluations for already-loaded keys keep flowing during a load, and
// off the thread pool, so the heartbeat and the writer keep flowing during a burst of them.
_loads.Add(frame.Load);
break;
}
case Envelope.BodyOneofCase.Unload:
@@ -179,6 +194,11 @@ internal sealed class WorkerHost
}
}
private void CompileThreadLoop()
{
foreach (var load in _loads.GetConsumingEnumerable()) Load(load);
}
private void Load(LoadScripts load)
{
var result = new LoadResult { LoadId = load.LoadId };
@@ -5,9 +5,13 @@
the worker test project. The worker is its own app (own deps.json / runtimeconfig.json), so it lives in its own folder
rather than beside the host's assemblies.
Publish note: the copy is the worker's framework-dependent BUILD output, run through the dotnet muxer. That suits the
docker-dev image (aspnet:10.0 runtime). A self-contained host publish (the Windows deploys) needs a self-contained
worker publish too; that is part of the installer work tracked on lmxopcua#780.
Publish: a framework-dependent host publish (docker-dev, aspnet:10.0 runtime) copies the worker's
framework-dependent BUILD output, which the launcher runs through the dotnet muxer. A self-contained host publish
(a self-contained `-r win-x64` publish: the Windows installs, which have no .NET runtime) instead PUBLISHES the worker
self-contained for the same RuntimeIdentifier into $(PublishDir)script-worker/, and the launcher starts its
apphost directly (OutOfProcessScriptRunner.ResolveWorkerEntry reads runtimeconfig.json `includedFrameworks`).
The worker then carries its own copy of the runtime (~70 MB); it is not trimmed or single-file, because Roslyn
compiles against the runtime's reference set on disk.
-->
<PropertyGroup>
<_OtScriptWorkerProject>$(MSBuildThisFileDirectory)../ZB.MOM.WW.OtOpcUa.ScriptWorker.csproj</_OtScriptWorkerProject>
@@ -18,7 +22,12 @@
</ItemGroup>
<Target Name="_OtResolveScriptWorkerOutput">
<MSBuild Projects="$(_OtScriptWorkerProject)" Targets="GetTargetPath" Properties="Configuration=$(Configuration)">
<PropertyGroup>
<_OtScriptWorkerResolveProps>Configuration=$(Configuration)</_OtScriptWorkerResolveProps>
<!-- A RID-specific host build builds the referenced worker for that RID too (bin/<cfg>/<tfm>/<rid>/). -->
<_OtScriptWorkerResolveProps Condition="'$(RuntimeIdentifier)' != ''">$(_OtScriptWorkerResolveProps);RuntimeIdentifier=$(RuntimeIdentifier)</_OtScriptWorkerResolveProps>
</PropertyGroup>
<MSBuild Projects="$(_OtScriptWorkerProject)" Targets="GetTargetPath" Properties="$(_OtScriptWorkerResolveProps)">
<Output TaskParameter="TargetOutputs" ItemName="_OtScriptWorkerTargetPath" />
</MSBuild>
<PropertyGroup>
@@ -35,9 +44,23 @@
SkipUnchangedFiles="true" />
</Target>
<Target Name="_OtCopyScriptWorkerAfterPublish" AfterTargets="Publish" DependsOnTargets="_OtResolveScriptWorkerOutput">
<Target Name="_OtCopyScriptWorkerAfterPublish" AfterTargets="Publish" DependsOnTargets="_OtResolveScriptWorkerOutput"
Condition="'$(SelfContained)' != 'true'">
<Copy SourceFiles="@(_OtScriptWorkerFiles)"
DestinationFiles="@(_OtScriptWorkerFiles->'$(PublishDir)script-worker/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
<!-- Self-contained host publish: publish the worker self-contained for the same RID. PublishDir is made absolute so
the nested publish does not resolve it against the worker's project directory. -->
<Target Name="_OtPublishSelfContainedScriptWorker" AfterTargets="Publish"
Condition="'$(SelfContained)' == 'true'">
<Error Condition="'$(RuntimeIdentifier)' == ''"
Text="lmxopcua#780: a self-contained publish needs a RuntimeIdentifier to publish the script worker for." />
<PropertyGroup>
<_OtScriptWorkerPublishDir>$([System.IO.Path]::GetFullPath('$(PublishDir)script-worker/'))</_OtScriptWorkerPublishDir>
</PropertyGroup>
<MSBuild Projects="$(_OtScriptWorkerProject)" Targets="Publish"
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=true;PublishSingleFile=false;PublishTrimmed=false;PublishDir=$(_OtScriptWorkerPublishDir)" />
</Target>
</Project>