413 lines
30 KiB
Markdown
413 lines
30 KiB
Markdown
# Script Pool Split — Design Memo (WP3.1 stage a)
|
||
|
||
**Date:** 2026-08-15 · **Status:** APPROVED FOR IMPLEMENTATION (WP3.1 stage b)
|
||
**Source:** `docs/plans/2026-08-14-arch-review-remediation-plan.md` §5 WP3.1 — findings #4 (High:
|
||
trigger evals starve behind blocking scripts on one fixed 8-thread pool) + actor-per-execution
|
||
overhead and the `SiteScriptCompileCache` wholesale-Clear cliff (Med).
|
||
**Scope:** design only. Implementation, tests, and the `Component-SiteRuntime.md` /
|
||
CLAUDE.md doc updates land in stage (b).
|
||
|
||
---
|
||
|
||
## 0. Current machinery (evidence baseline)
|
||
|
||
All line numbers verified 2026-08-15 on `arch-review-remediation` (post-Phase-2, tip `a5882753`).
|
||
|
||
| Mechanism | Where |
|
||
|---|---|
|
||
| One fixed pool for everything: `ScriptExecutionScheduler`, N dedicated threads, unbounded FIFO `BlockingCollection` | `SiteRuntime/Scripts/ScriptExecutionScheduler.cs:21-84` |
|
||
| Pool size: `SiteRuntimeOptions.ScriptExecutionThreadCount`, static default 8, never scales | `SiteRuntime/SiteRuntimeOptions.cs:46` |
|
||
| Script bodies run on the pool via short-lived `ScriptExecutionActor` (spawn-execute-die, ctor kicks the task) | `Actors/ScriptExecutionActor.cs:79-87,130,339` |
|
||
| Alarm on-trigger bodies: same pattern, `AlarmExecutionActor` | `Actors/AlarmExecutionActor.cs:100-165` |
|
||
| ScriptActor Expression-trigger eval: `Task.Factory.StartNew(..., scheduler)` on the SAME pool, hardcoded 2 s CTS created **inside** the body (timeout excludes queue wait) | `Actors/ScriptActor.cs:297-331` (2 s at :314) |
|
||
| AlarmActor Expression-trigger eval: identical, plus `SourceExecutionId` snapshot capture | `Actors/AlarmActor.cs:578-619` (2 s at :600, capture at :591) |
|
||
| Per-trigger coalescing (one in flight + one pending) already bounds eval fan-out per actor | `ScriptActor.cs:75-76,300`; `AlarmActor.cs:102-103,581` |
|
||
| Script timeout CTS created inside the pooled body ⇒ the 30 s script budget also starts at **dequeue**, not enqueue | `ScriptExecutionActor.cs:136`; `AlarmExecutionActor.cs:102` |
|
||
| Stuck-script watchdog: names the script, logs + site event — **observability only, the thread stays lost** | `ScriptExecutionActor.cs:147-161`; grace `SiteRuntimeOptions.cs:79` |
|
||
| Pool gauges (queue depth / busy / oldest-busy age) → health report | `ScriptExecutionScheduler.cs:94-127`; `Scripts/ScriptSchedulerStatsReporter.cs:75-91` |
|
||
| No per-script concurrency bound: every trigger spawns another child actor | `ScriptActor.cs:463-501`; `AlarmActor.cs:725-758` |
|
||
| Compile cache: 1024 entries, **wholesale `Clear()` on overflow** | `Scripts/SiteScriptCompileCache.cs:37,71-72` |
|
||
| Compile call sites on actor threads: deploy gate (deliberately synchronous, S3) and InstanceActor start | `Actors/DeploymentManagerActor.cs:522-556` (Validate at :540); `Actors/InstanceActor.cs:1631,1682,1795` |
|
||
| In-repo off-dispatcher-compile reference: shared-script load = `Task.Run` + `PipeTo(Self)` | `DeploymentManagerActor.cs:1540-1563` |
|
||
| Akka HOCON has no script dispatcher — the pool lives entirely outside Akka (only `audit-telemetry-dispatcher` is custom) | `Host/Actors/AkkaHostedService.cs:296-302` |
|
||
|
||
The failure mode (finding #4): 8 scripts blocked in synchronous I/O ⇒ every Expression trigger
|
||
eval on the node (scripts **and alarms**) queues behind them. An alarm that should raise in
|
||
milliseconds waits until a thread frees — unbounded. The 2 s eval CTS doesn't even start until
|
||
the eval is dequeued, so the operator sees neither a timeout nor a raise.
|
||
|
||
---
|
||
|
||
## 1. (a) Trigger/expression evals leave the blocking pool
|
||
|
||
**Decision (confirms the plan default): evals run as plain async on the .NET thread pool
|
||
(`Task.Run`), gated by a process-wide `SemaphoreSlim`. No second dedicated pool.**
|
||
|
||
Justification: trigger expressions are non-blocking **by construction** —
|
||
`TriggerExpressionGlobals` exposes only reads over an in-memory snapshot dictionary ("no I/O,
|
||
no actor Ask, no side-effecting APIs", `Scripts/TriggerExpressionGlobals.cs:5-13,42-50`), and
|
||
the script trust gate has already denied I/O/network/threading before the expression ever
|
||
deploys. They are short CPU-bound work — exactly what the shared thread pool is for. A second
|
||
dedicated pool would add threads, gauges, and a second starvation surface for no isolation gain;
|
||
the gate alone prevents an eval storm from swamping the pool.
|
||
|
||
Mechanics (both `ScriptActor.StartExpressionEvaluation` and `AlarmActor.StartExpressionEvaluation`):
|
||
|
||
```csharp
|
||
var cts = new CancellationTokenSource(evalTimeout); // clock starts AT ENQUEUE
|
||
Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await gate.WaitAsync(cts.Token); // queue wait burns the same budget
|
||
try
|
||
{
|
||
var state = await expression.RunAsync(new TriggerExpressionGlobals(snapshot), cts.Token);
|
||
return state.ReturnValue is bool b && b;
|
||
}
|
||
finally { gate.Release(); }
|
||
}
|
||
catch (Exception ex) { /* existing treated-as-false path, incl. OCE */ }
|
||
finally { cts.Dispose(); }
|
||
}).PipeTo(self, success: r => new ExpressionEvalResult(r, ...), failure: ex => new ExpressionEvalFailed(ex, ...));
|
||
```
|
||
|
||
- **Queue-inclusive timeout falls out of CTS placement**: today the CTS is constructed inside
|
||
the pooled body (`ScriptActor.cs:314`, `AlarmActor.cs:600`), so its 2 s starts at dequeue.
|
||
Moving construction to the actor thread before `Task.Run` makes gate-wait time part of the
|
||
budget; a cancellation during `WaitAsync` flows through the existing catch → `false` → the
|
||
existing error/edge handling (`HandleExpressionEvalResult` / `HandleExpressionEvalFailed`,
|
||
which already survive faults per N2). No new message types.
|
||
- **Gate**: one process-wide `SemaphoreSlim` (mirroring the `ScriptExecutionScheduler.Shared`
|
||
lazy-singleton + injectable-seam pattern, `ScriptExecutionScheduler.cs:48-60` / the #18 ctor
|
||
seams). Size: **`TriggerEvalMaxConcurrency`, default `max(2, Environment.ProcessorCount)`**.
|
||
Per-trigger coalescing (unchanged) already caps waiters at ≤ one per Expression trigger, so
|
||
the gate queue is bounded by trigger count.
|
||
- **Timeout becomes configurable**: `TriggerEvalTimeoutSeconds`, default 2 (today hardcoded in
|
||
two places). Validated > 0.
|
||
- The tag-cascade capture is untouched: `sourceExecutionId` is still captured on the actor
|
||
thread with the snapshot (`AlarmActor.cs:584-591`) and echoed on the result records
|
||
(`AlarmActor.cs:888,899`) — only the execution context under the lambda changes.
|
||
- `ScriptExecutionScheduler` keeps its `TryExecuteTaskInline` name-prefix guard and thread
|
||
naming; nothing about the blocking pool's identity changes in this section.
|
||
|
||
Net effect: the starvation scenario is dead by construction — evals never share a queue or a
|
||
thread with blocking script bodies.
|
||
|
||
---
|
||
|
||
## 2. (b) Blocking-pool sizing, config, watchdog thread replacement
|
||
|
||
### Sizing
|
||
|
||
**Decision (confirms plan default): `threads = clamp(max(ScriptExecutionThreadCount, ceil(enabledInstances / 8)), 1, ScriptExecutionMaxThreadCount)`.**
|
||
|
||
- `ScriptExecutionThreadCount` (existing, default 8, `SiteRuntimeOptions.cs:46`) keeps its name
|
||
and becomes the **floor** — existing configs keep exactly today's behavior at ≤ 64 instances.
|
||
- New `ScriptExecutionMaxThreadCount`, default **32** — a 1 MB-stack thread is cheap; 32 covers
|
||
256 instances at the /8 ratio, and beyond that the per-script cap (§3) is the real regulator.
|
||
Validated ≥ `ScriptExecutionThreadCount`.
|
||
- The `/8` divisor is a named constant (`InstancesPerScriptThread = 8`), not another option —
|
||
no evidence any deployment needs to tune the ratio independently of floor and ceiling.
|
||
- **Grow-only.** `ScriptExecutionScheduler` gains `EnsureCapacity(int target)`: starts
|
||
additional workers up to `target` (idempotent, lock-guarded, widens `_busySinceTicks`
|
||
bookkeeping); it never shrinks — undeploying instances leaves idle threads, which cost
|
||
nothing measurable and avoid drain/steal complexity.
|
||
- **Call site**: `DeploymentManagerActor.UpdateInstanceCounts()` (`DeploymentManagerActor.cs:2095`,
|
||
already invoked on every deploy/undeploy/enable/disable and per startup batch at :439, :485,
|
||
:578, :645, :666, and `:485` per staggered batch) additionally calls
|
||
`scheduler.EnsureCapacity(ComputeTargetThreads(enabled, options))`. `ComputeTargetThreads`
|
||
is a pure static — unit-testable without threads.
|
||
|
||
### Watchdog replaces lost threads
|
||
|
||
Today the watchdog (CTS-register + grace delay, `ScriptExecutionActor.cs:147-161`) only *names*
|
||
the wedged script; the pool permanently loses the thread. New mechanics:
|
||
|
||
1. **Slot capture at body start.** `WorkerLoop` (`ScriptExecutionScheduler.cs:129-150`) exposes
|
||
`[ThreadStatic] internal static int? CurrentWorkerSlot`. The first synchronous segment of the
|
||
script body records `(slot, busySinceTicks)` into the run's state. A truly wedged (blocking)
|
||
script never leaves that thread, so the pair identifies its worker exactly; an async script
|
||
that hopped threads will fail the guard below and cause no detach (correct — it holds no
|
||
worker).
|
||
2. **Detach on watchdog fire.** When the existing watchdog declares the script stuck (timeout +
|
||
`StuckScriptGraceMs`), it additionally calls
|
||
`scheduler.TryDetachWorker(slot, observedBusySince)`: atomically, if the slot's current
|
||
`_busySinceTicks` still equals the recorded value (same task still running), mark the slot
|
||
**detached** and start one replacement worker thread. The detached worker, on eventually
|
||
finishing its task, sees its flag and exits instead of pulling more work — capacity never
|
||
silently doubles-and-drains.
|
||
3. **Bound.** Live detached threads are capped at the current pool size (worst case the process
|
||
briefly holds 2× threads, all but N wedged). At the cap: no replacement, log Error + site
|
||
event `script`/`Error` ("script-execution pool has N detached stuck threads; not replacing")
|
||
— bounded starvation is preferable to unbounded thread growth when scripts wedge en masse.
|
||
4. **Observability.** New gauge `DetachedScriptThreads` alongside the existing
|
||
queue/busy/oldest-age stats through `ISiteHealthCollector.SetScriptSchedulerStats`
|
||
(`ScriptSchedulerStatsReporter.cs:80-83` extends by one argument; additive on the health
|
||
report DTO).
|
||
|
||
The watchdog itself (grace, message text, site event) is otherwise unchanged, and it keeps
|
||
running on the shared thread pool, never on the possibly-saturated script pool
|
||
(`ScriptExecutionActor.cs:146` comment holds).
|
||
|
||
### Script-body deadline captured at enqueue
|
||
|
||
Same CTS relocation as §1: the run's deadline CTS (30 s default / per-script override,
|
||
`ScriptExecutionActor.cs:113-116,136`) is created **before** the body is queued to the
|
||
scheduler, so queue wait consumes the script's own budget. Additionally, the launcher records
|
||
`deadline = enqueueUtc + timeout`; if the body dequeues with the deadline already passed it
|
||
**skips execution entirely** and takes the existing timeout path (site event + `IncrementScriptError`
|
||
+ error reply + `ScriptExecutionCompleted(false)`) — a saturated pool now sheds stale work at
|
||
dequeue instead of running it late. The watchdog registers on the same token, so its grace
|
||
window is deadline-anchored too; a run cancelled while still queued flips `completed` before the
|
||
grace check and is correctly not reported as a stuck thread.
|
||
|
||
---
|
||
|
||
## 3. (c) Per-script in-flight cap + shed policy
|
||
|
||
**Decision (confirms plan default): cap = 4 concurrent runs per script; overflow sheds the
|
||
NEWEST (the incoming trigger), with a counter always and a rate-limited site event.**
|
||
Justification stands as the plan gave it: a trigger-driven run that cannot even start before
|
||
its own deadline is stale; keeping the 4 oldest (already queued/running, closest to their
|
||
deadlines… and already charged against them) and refusing the newcomer is the policy that never
|
||
reorders runs and needs no queue at all — the "bounded queue" in the plan collapses to a
|
||
counter, because the scheduler's own FIFO *is* the queue and in-flight = enqueued-or-running.
|
||
|
||
Mechanics (identical in `ScriptActor` and `AlarmActor`):
|
||
|
||
- **Counter**: private `_runsInFlight` on the owning actor, incremented at launch
|
||
(`SpawnExecution` / `SpawnAlarmExecution`), decremented on the **existing completion
|
||
messages** — `ScriptActor.ScriptExecutionCompleted` (`ScriptActor.cs:648`, handled at :503)
|
||
and `AlarmActor.AlarmExecutionCompleted` (`AlarmActor.cs:878`, handled at :221). Both are
|
||
emitted on every path (success/timeout/failure) today and remain so under §4; the launch-path
|
||
try/catch (§4, behavior 2) guarantees a completion message even when the launch itself
|
||
throws, so the counter cannot leak.
|
||
- **Config**: `MaxConcurrentRunsPerScript`, default 4, validated ≥ 1. One knob for both script
|
||
and alarm on-trigger runs.
|
||
- **Shed action** when `_runsInFlight >= cap`:
|
||
- increment new `ISiteHealthCollector.IncrementScriptRunShed()` — surfaced on the site health
|
||
report as a raw per-interval count like the existing script/alarm error counters
|
||
(`ISiteHealthCollector.cs:16,21` pattern);
|
||
- site event: channel `script`, severity `Warning`, source `ScriptActor:<name>` /
|
||
`AlarmActor:<name>`, message naming script, instance, and the in-flight count — **rate-limited
|
||
to one event per script per minute** (actor-local last-emit timestamp; the counter still
|
||
counts every shed) so a hot trigger cannot flood `site_events` (WP3.2's concern);
|
||
- **Ask callers are never silently dropped**: a shed `ScriptCallRequest`
|
||
(`ScriptActor.cs:223-242`) replies
|
||
`ScriptCallResult(correlationId, false, null, "shed: N runs already in flight")` — a nested
|
||
`CallScript` or inbound-API route fails fast instead of hanging to Ask-timeout;
|
||
- trigger-driven spawns (interval/value-change/conditional/expression, alarm on-trigger) are
|
||
simply not launched.
|
||
- `MinTimeBetweenRuns` (`ScriptActor.cs:443-453`) remains the first gate; the cap is the second.
|
||
|
||
---
|
||
|
||
## 4. (d) `ScriptExecutionActor` / `AlarmExecutionActor` elimination
|
||
|
||
**Decision: ELIMINATE both actors.** The pre-work for this memo shows the risk is materially
|
||
lower than the plan feared, because the actors are already almost inert shells:
|
||
|
||
- **Neither actor has a single `Receive` handler** — they execute from the constructor
|
||
(`ScriptExecutionActor.cs:79-87`; `AlarmExecutionActor.cs:61-68`) and the entire lifecycle
|
||
(timeout, DI scope, telemetry, replies) lives inside a detached `Task` the actor does not
|
||
observe.
|
||
- **The execution actor's `IActorRef` is never a message target.** `ScriptRuntimeContext._self`
|
||
is stored (`ScriptRuntimeContext.cs:43,243`) and passed to child shared-script contexts
|
||
(`:305`) but never used in a `Tell`/`Ask` anywhere in the class. Asks to the instance actor
|
||
use temporary ask-actors. Nothing routes through the shell.
|
||
- **No `PostStop`, no state, no stash** — cleanup is the task's own `finally`
|
||
(`ScriptExecutionActor.cs:329-338`), which merely poisons the shell.
|
||
|
||
What remains is per-run actor-cell/mailbox/name-registration overhead plus a per-spawn
|
||
expression-tree `Props.Create` (`ScriptActor.cs:476-498`) — pure cost, no semantics.
|
||
|
||
### Replacement shape
|
||
|
||
New `Scripts/ScriptRunLauncher` (static class, same project): the current
|
||
`ScriptExecutionActor.ExecuteScript` and `AlarmExecutionActor.ExecuteAlarmScript` bodies move
|
||
there essentially verbatim (they are already `static`), unified behind two entry points
|
||
(`LaunchScript`, `LaunchAlarmScript`) that take `completionTarget` (the owning coordinator's
|
||
`Self`) instead of `self`/`parent`. `ScriptActor.SpawnExecution` and
|
||
`AlarmActor.SpawnAlarmExecution` call it directly — no `Props`, no child. This also deletes
|
||
~150 lines of duplicated body between the two actors.
|
||
|
||
### Behavior map — every current behavior → its replacement
|
||
|
||
| # | Current behavior (provider) | Replacement under direct launch |
|
||
|---|---|---|
|
||
| 1 | Execution starts immediately on spawn (ctor, `ScriptExecutionActor.cs:79-87`) | `ScriptRunLauncher.LaunchScript(...)` called inline in `SpawnExecution` — same thread, same ordering |
|
||
| 2 | **Supervision Stop-on-failure** (`ScriptActor.cs:205-217`, `AlarmActor.cs:242-254`). Reachable today ONLY via ctor throw → `ActorInitializationException` (e.g. `StartNew` on a disposed scheduler; the body's exceptions never escape its try/catch). Effect: warn-log, child stopped, coordinator unaffected; the Ask caller **hangs** (no reply is sent on this path today). | `try/catch` around the launch call: warn-log with the same message shape, `replyTo.Tell(ScriptCallResult(false, error))` (an intentional improvement — the caller no longer hangs), `Self.Tell(ScriptExecutionCompleted(false, error))` for the §3 counter. The now-childless `SupervisorStrategy()` overrides are **deleted**; ScriptActor/AlarmActor remain supervised by InstanceActor (Resume) exactly as before — a launch-path throw must not reach InstanceActor, and the catch guarantees it. |
|
||
| 3 | Script-body exception/timeout containment (in-lambda try/catch, `ScriptExecutionActor.cs:295-328`) | Moves verbatim into the launcher — unchanged |
|
||
| 4 | **DI scope per run**: `CreateScope` at `:198`, disposed in `finally :335` | Unchanged — scope creation/disposal stays inside the launched task, one scope per run, disposed on every path |
|
||
| 5 | **Telemetry**: `IncrementScriptError` (`:297,:314`), started/completed/error site events (`:275-320`), stuck-script watchdog (`:147-161`) | Unchanged — all live inside the launcher body. (Started/Completed Info events become sampled under WP3.2; not this WP's concern.) |
|
||
| 6 | **ExecutionId / ParentExecutionId**: `ScriptRuntimeContext` mints its own `ExecutionId`; `parentExecutionId` threaded at `:247` (`AlarmExecutionActor.cs:121`); shared-script child contexts chain via `CreateChildContextForSharedScript` (`ScriptRuntimeContext.cs:302-330`) | Unchanged — the launcher builds the identical context. The dead `self` ctor argument is replaced with `ActorRefs.Nobody` in stage (b) and the parameter deleted (it is stored-and-passed but never messaged). |
|
||
| 7 | Ask-caller reply from the task (`:284,:307,:324`) — `IActorRef.Tell` off-thread | Unchanged (`Tell` is thread-safe) |
|
||
| 8 | Completion notification `parent.Tell(ScriptExecutionCompleted/AlarmExecutionCompleted)` (`:293,:310,:327`; `AlarmExecutionActor.cs:144,151,159`) | `completionTarget.Tell(...)` with the coordinator's captured `Self` — identical delivery, and now doubles as the §3 in-flight decrement |
|
||
| 9 | `PoisonPill` self-stop in `finally` (`:337`) | Nothing to stop — dropped. The per-run actor teardown (cell, mailbox, name unregistration) simply disappears. |
|
||
| 10 | Per-run actor name `{script}-exec-{n}` (`ScriptActor.cs:470,500`) — visible in Akka logs | `_executionCounter` survives as a run-id inside the launcher's log messages; the actor *path* is gone. Accepted cosmetic loss; log message content is preserved. |
|
||
| 11 | **Dead letters / stop-during-redeploy**: `Context.Stop` on the instance subtree stops the shells but does NOT cancel the detached task; its later `parent.Tell` dead-letters (dead letters are a health metric). | Parity by construction: an in-flight run still runs to completion after its ScriptActor stops, and its `completionTarget.Tell` dead-letters identically. **Explicit decision: ScriptActor/AlarmActor `PostStop` does NOT cancel in-flight CTSs** — redeploy/undeploy semantics (let running scripts finish) are unchanged. Cancelling-on-stop is a separate behavior change; if wanted later it gets its own decision record. |
|
||
| 12 | Exec-actor mailbox on the default dispatcher | Never received a message — no replacement needed |
|
||
| 13 | `Terminated`-based redeploy sequencing (`DeploymentManagerActor.cs:508-556` waits for the instance subtree to fully stop) | Unaffected: exec shells were stopped instantly anyway (no children of their own); subtree termination time is dominated by Instance/Script/Alarm actors, which all remain |
|
||
|
||
### Parity pins stage (b) MUST implement as tests
|
||
|
||
1. Throwing script body ⇒ ScriptActor stays alive (no restart — assert no `PreRestart`), error
|
||
site event + `IncrementScriptError`, Ask caller gets `ScriptCallResult(false)`,
|
||
`ScriptExecutionCompleted(false)` arrives, in-flight counter returns to 0.
|
||
2. Launch-path throw (inject a disposed `ScriptExecutionScheduler` via the #18 seam) ⇒
|
||
ScriptActor neither dies nor restarts, caller gets an error reply (improved over today's
|
||
hang — pin the reply), counter returns to 0.
|
||
3. DI scope disposed exactly once per run on success, failure, AND timeout paths (spy provider).
|
||
4. Audit threading: existing tag-cascade tests (`LastOnTriggerParentExecutionId`,
|
||
`AlarmActor.cs:135`; `SeedAttributesReference` isolation contracts) pass unmodified; a
|
||
routed `ScriptCallRequest.ParentExecutionId` still reaches the context.
|
||
5. Timeout parity: per-script override and global fallback (`perScript ?? global`, ≤ 0 ⇒
|
||
global) produce the same OperationCanceled path, site event, and watchdog behavior.
|
||
6. Stop-during-run parity: stop the ScriptActor mid-run; the run completes, no crash, its
|
||
completion message dead-letters (assert via dead-letter subscription), CTS not cancelled.
|
||
7. Alarm side: on-trigger run gets `Alarm` globals (name/level/priority/message) and
|
||
`AlarmExecutionCompleted` still reaches the AlarmActor — existing `ExecutionActorTests`
|
||
reworked to launcher-level tests rather than deleted.
|
||
|
||
Fallback stance: if stage (b) uncovers a real consumer of the exec-actor `IActorRef` this memo
|
||
missed, the cheap variant is pooled Props reuse — but the `_self` grep evidence above makes
|
||
that contingency unlikely, and no design effort is spent on it here.
|
||
|
||
---
|
||
|
||
## 5. (e) `SiteScriptCompileCache` LRU + off-dispatcher cache-miss compiles
|
||
|
||
### LRU eviction (kills the overflow cliff)
|
||
|
||
The wholesale `Cache.Clear()` at 1024 entries (`SiteScriptCompileCache.cs:71-72`) is a latency
|
||
cliff — the uncommitted `Component-SiteRuntime.md` WIP documents it precisely (instance scripts
|
||
*and* trigger expressions share the cache; crossing the bound triggers a full recompile storm on
|
||
actor threads). **This design resolves it; the WIP doc paragraph gets its "remains one" clause
|
||
rewritten in stage (b).**
|
||
|
||
**Decision: approximate LRU via access-stamped entries + batch eviction; no wholesale clear.**
|
||
|
||
- Entry becomes `(ScriptCompilationResult Result, long LastAccess)` where `LastAccess` is a
|
||
`Volatile`-written stamp from a process-wide `Interlocked` access sequence (not a clock —
|
||
deterministic for tests, immune to clock steps).
|
||
- Hits update the stamp lock-free; no lock, no linked list on the hot path.
|
||
- On insert with `Count >= MaxEntries`: evict the oldest **⅛ (128) entries** by one O(n) scan
|
||
under a small eviction lock (double-checked so concurrent inserts don't stampede). Overflow
|
||
now costs one 1024-element scan instead of 1023 future recompiles.
|
||
- `MaxEntries` stays 1024; `Hits`/`Count`/`Clear()` keep their signatures (tests use them,
|
||
`SiteScriptCompileCacheTests.cs`).
|
||
|
||
### Cache-miss compiles move off the actor thread
|
||
|
||
Two on-actor-thread compile sites (`Task.Run`+`PipeTo` shared-script loader at
|
||
`DeploymentManagerActor.cs:1540-1563` is the pattern reference):
|
||
|
||
1. **Deploy gate** (`HandleDeploy`, `DeploymentManagerActor.cs:522-556`). The S3 comment's
|
||
ordering constraint (mailbox-FIFO between deploy/delete/disable) is real — so the design is
|
||
**warm-then-gate**: `HandleDeploy` first runs `_deployCompileValidator.Validate` in a
|
||
`Task.Run`, piping back `DeployCompileWarmed(command, replyTo)`; on receipt it re-runs the
|
||
existing synchronous gate (now all cache hits — memoised at `ScriptCompilationService.cs:148`)
|
||
and proceeds down the unchanged `ProceedWithDeploy` path. Ordering is preserved with a
|
||
**per-instance in-flight guard**: while a warm is in flight for instance X, other mutating
|
||
commands for X are buffered last-write-wins with a `Failed`-superseded reply to the displaced
|
||
sender — the exact machinery the redeploy path already uses
|
||
(`_pendingRedeploys`/`_terminatingActorsByName`, `DeploymentManagerActor.cs:569-601`).
|
||
Commands for other instances flow freely (an improvement over today's whole-mailbox stall
|
||
during a long compile, and safe: cross-instance ordering was never guaranteed to callers).
|
||
2. **Staggered startup / InstanceActor PreStart** (`InstanceActor.cs:1631,1682,1795`). Each
|
||
startup batch (`HandleStartNextBatch`, `DeploymentManagerActor.cs:467-500`) gains a pre-warm
|
||
step: before creating the batch's Instance Actors, compile the batch's distinct script bodies
|
||
and trigger expressions in a `Task.Run`, then pipe `BatchCompileWarmed` and create the actors
|
||
— PreStart compiles become cache hits. This closes the long-deferred "per-instance
|
||
compilation during staggered startup" item the WIP `Component-SiteRuntime.md` carries
|
||
(failover time-to-recover), at the cost of one extra message per batch.
|
||
InstanceActor's own compile calls stay where they are (they are the correctness backstop and
|
||
are hits in every warmed path).
|
||
|
||
No change to `ScriptCompilationService` semantics: results (success AND failure) stay memoised,
|
||
error text stays name-free, the process-static `ScriptOptions` + `CachingScriptMetadataResolver`
|
||
invariants (`ScriptCompilationService.cs:104-111`) are untouched.
|
||
|
||
---
|
||
|
||
## 6. (f) Test plan (stage b)
|
||
|
||
All in `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests` unless noted; the injectable scheduler
|
||
seam (#18) and injectable eval gate make every scenario deterministic without the process-wide
|
||
singletons.
|
||
|
||
1. **Starvation regression (the finding-#4 pin):** injected 8-thread scheduler; 8 scripts
|
||
blocked on a `ManualResetEventSlim`; fire an Expression-trigger alarm eval ⇒
|
||
`AlarmStateChanged` (or `ExpressionEvalResult`) observed **< 2 s** while all 8 threads stay
|
||
blocked. Companion negative: same setup on the *old* wiring is the currently-failing shape
|
||
(documented in the test comment, not committed failing).
|
||
2. **Queue-inclusive eval timeout:** eval gate of 1, occupy it > `TriggerEvalTimeoutSeconds`
|
||
with a slow eval; second eval resolves as false within ~the timeout **measured from
|
||
enqueue** (assert elapsed-from-StartExpressionEvaluation, not from gate acquisition), and
|
||
the trigger is not parked (`_evalPending` drains).
|
||
3. **Queue-inclusive script deadline:** 1-thread scheduler, first script blocks past the second
|
||
script's full timeout; second script's failure path fires without its body ever executing
|
||
(side-effect flag never set), error reply carries the timeout message, watchdog does NOT
|
||
flag it as a stuck thread.
|
||
4. **Watchdog thread replacement:** 1-thread scheduler, wedge a script past timeout+grace ⇒
|
||
`TryDetachWorker` fires, a subsequent script executes (replacement thread live),
|
||
`DetachedScriptThreads` gauge = 1; unwedge ⇒ detached worker exits, gauge returns to 0.
|
||
Cap test: detached-at-cap ⇒ no new thread, Error site event emitted.
|
||
5. **Pool sizing:** `ComputeTargetThreads` pure-function table (floor 8, /8 growth, ceiling,
|
||
override precedence); `EnsureCapacity` grows once, is idempotent, never shrinks; gauges
|
||
reflect the widened pool.
|
||
6. **Shed policy:** cap 4 via options; block 4 runs; 5th trigger ⇒ shed counter +1, Warning
|
||
site event (and second shed within the minute ⇒ counter +1, NO second event), Ask caller
|
||
gets error reply; one completion ⇒ next trigger launches. Alarm on-trigger variant.
|
||
7. **Supervision-parity set:** the seven pins of §4, replacing/reworking
|
||
`Actors/ExecutionActorTests.cs`.
|
||
8. **Compile cache LRU:** fill to `MaxEntries`, touch entry #1, insert one more ⇒ entry #1
|
||
survives, an untouched old entry is evicted, `Count ≤ MaxEntries`, no wholesale clear
|
||
(`Hits` preserved); concurrent GetOrAdd storm keeps the bound.
|
||
9. **Warm-then-gate ordering:** Deploy(X) immediately followed by Delete(X) ⇒ delete is
|
||
buffered, deploy applies first, terminal states correct; Deploy(X) then Deploy(X') ⇒ first
|
||
sender gets superseded reply; Deploy(X) + Deploy(Y) interleave freely. Batch pre-warm:
|
||
staggered startup with a compile-heavy config ⇒ InstanceActor PreStart compiles are cache
|
||
hits (assert via `SiteScriptCompileCache.Hits`).
|
||
|
||
Suite gate: full `dotnet test ZB.MOM.WW.ScadaBridge.slnx` green; rig rebuild + failover drill
|
||
per the Phase-3 gate (the drill guards the timeout/actor changes, plan §6).
|
||
|
||
---
|
||
|
||
## 7. Configuration summary (all under `ScadaBridge:SiteRuntime`, validated in `SiteRuntimeOptionsValidator`)
|
||
|
||
| Option | Default | New? | Meaning |
|
||
|---|---|---|---|
|
||
| `ScriptExecutionThreadCount` | 8 | existing | Blocking-pool floor (was: fixed size) |
|
||
| `ScriptExecutionMaxThreadCount` | 32 | new | Blocking-pool ceiling for instance-scaled growth |
|
||
| `TriggerEvalMaxConcurrency` | `max(2, ProcessorCount)` | new | Thread-pool gate for Expression evals |
|
||
| `TriggerEvalTimeoutSeconds` | 2 | new | Was hardcoded; now queue-inclusive |
|
||
| `MaxConcurrentRunsPerScript` | 4 | new | Per-script/per-alarm in-flight cap; overflow sheds newest |
|
||
| `ScriptExecutionTimeoutSeconds` / per-script override | 30 / null | existing | Unchanged semantics; deadline now anchored at enqueue |
|
||
| `StuckScriptGraceMs` | 30000 | existing | Unchanged; now also triggers thread detach/replace |
|
||
|
||
---
|
||
|
||
## 8. Notes for WP3.1 stage (b) — scope & risk deltas vs. the plan
|
||
|
||
- **Risk DOWN on actor elimination:** the shells have no Receive handlers, no PostStop, and
|
||
their `IActorRef` is provably unused as a message target (`ScriptRuntimeContext.cs:43,243,305`
|
||
are the only `_self` references). Elimination is a refactor of dead structure, not a
|
||
semantics change — the parity surface is the launcher body, which moves verbatim.
|
||
- **Scope +:** `AlarmExecutionActor` is eliminated together with `ScriptExecutionActor`
|
||
(same pattern, shared launcher); the plan text named only the latter.
|
||
- **Scope +:** batch pre-warm of startup compiles closes the deferred "per-instance compilation
|
||
during staggered startup" item; the uncommitted `Component-SiteRuntime.md` WIP (compile-cache
|
||
overflow-cliff paragraph) should be finalized in stage (b) to record the cliff as resolved by
|
||
LRU eviction. Do not touch that file in stage (a) — it is user WIP.
|
||
- **Scope note:** one intentional behavior improvement is folded in (launch-path failure now
|
||
replies to the Ask caller instead of letting it hang, §4 map row 2) — pinned by test 2 of the
|
||
parity set so it is explicit, not accidental.
|
||
- **Ordering with WP3.2:** the shed site event is deliberately rate-limited (1/script/minute)
|
||
so this WP does not add a new `site_events` volume source while WP3.2 is deciding volume
|
||
policy; merge order (3.1 before 3.2) per the plan's conflict matrix is unaffected.
|
||
- **Not in scope:** cancelling in-flight runs on actor stop (explicitly kept at parity, §4 row
|
||
11); eval-side dedicated pool (rejected, §1); pool shrink (rejected, §2).
|