Merge branch 'worktree-agent-a2b4268818a1b6201' into arch-review-remediation

This commit is contained in:
Joseph Doherty
2026-08-14 22:37:43 -04:00
42 changed files with 3673 additions and 1251 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
### Architecture & Runtime
- Instance modeled as Akka actor (Instance Actor) — single source of truth for runtime state.
- Site Runtime actor hierarchy: Deployment Manager singleton → Instance Actors → Script Actors + Alarm Actors.
- Script Actors spawn short-lived Script Execution Actors on a dedicated blocking I/O dispatcher.
- Script/Alarm Actors launch script runs directly onto a dedicated blocking-I/O thread pool (`ScriptExecutionScheduler`) — the short-lived `ScriptExecutionActor`/`AlarmExecutionActor` children were **deleted** by WP3.1 (they had no `Receive` handler, no `PostStop`, and their `IActorRef` was never a message target; `ScriptRunLauncher` carries the run body verbatim). Same pool, same telemetry, same audit `ExecutionId`/`ParentExecutionId` threading, same "a stop does NOT cancel an in-flight run" semantics — minus a per-run actor cell. WP3.1 also: **trigger-expression evaluation left that pool** for the shared .NET thread pool behind `TriggerEvalGate` (arch-review finding #4 — blocked script bodies used to stall every Expression trigger on the node indefinitely); script and eval deadlines are armed **at enqueue** so queue wait burns the budget (a run dequeuing past its deadline is shed unrun); the pool is instance-scaled grow-only `clamp(max(ScriptExecutionThreadCount, ceil(instances/8)), 1, ScriptExecutionMaxThreadCount)`; the stuck-script watchdog now **detaches and replaces** a wedged worker (capped at pool size, gauge `DetachedScriptThreads`); `MaxConcurrentRunsPerScript` (4) sheds the newest run with a counter + rate-limited site event + an explicit error reply to Ask callers; `SiteScriptCompileCache` evicts an oldest-⅛ batch by approximate LRU instead of clearing wholesale; and deploy/startup compiles are warmed off-thread before the (ordering-critical, now cache-hit-only) synchronous deploy gate. Design memo: `docs/plans/2026-08-15-script-pool-split-design.md`.
- Alarm Actors are separate peer subsystem from scripts (not inside Script Engine).
- Shared scripts execute inline as compiled code (no separate actors).
- Site-wide Akka stream for attribute value and alarm state changes with per-subscriber buffering.
+38 -35
View File
@@ -41,9 +41,9 @@ flowchart TD
AA2["Alarm Actor ('LowPressure')<br/>— coordinator (computed)"]
NAA1["Native Alarm Actor ('OpcUaServer1')<br/>— read-only mirror, peer to Alarm Actor"]
SEA1["Script Execution Actor<br/>— short-lived, per invocation"]
SEA2["Script Execution Actor<br/>— short-lived, per invocation"]
AEA1["Alarm Execution Actor<br/>— short-lived, per on-trigger invocation"]
SEA1["script run<br/>— launched task, not an actor"]
SEA2["script run<br/>— launched task, not an actor"]
AEA1["alarm on-trigger run<br/>— launched task, not an actor"]
IA2CHILD["… (Script / Alarm Actors)"]
@@ -57,9 +57,9 @@ flowchart TD
IA1 --> AA2
IA1 --> NAA1
SA1 --> SEA1
SA2 --> SEA2
AA1 --> AEA1
SA1 -.-> SEA1
SA2 -.-> SEA2
AA1 -.-> AEA1
IA2 -.-> IA2CHILD
@@ -96,9 +96,11 @@ flowchart TD
> **Startup config load is retried (S5)**: step 1's deployed-config read is best-effort against local SQLite, which can transiently fail (locked/busy). A failed load must **not** leave the site as a silent zero-instance node — it is re-attempted on a fixed interval until it succeeds, then the retry self-cancels.
> **Per-instance compilation during staggered startup (P6, follow-up)**: the Roslyn compile of each instance's scripts still runs inside Instance Actor start during staggered startup; moving it off-thread is a deferred optimization (it affects failover time-to-recover only, not correctness). As of the round-2 hardening, a process-wide compile cache dedupes identical script bodies within a node's process lifetime (the deploy gate's compile is reused by Instance Actor start), shrinking the recompile cost; the *first* compile after process start still runs inside Instance Actor start, so the deferral stands.
> **Per-instance compilation during staggered startup — CLOSED (WP3.1)**: this was a deferred optimization (P6) affecting failover time-to-recover; each Instance Actor Roslyn-compiled its own scripts inside its own start, serialising a site's whole recovery behind compilation. Each staggered startup batch now runs a **pre-warm step**: before the batch's Instance Actors are created, the Deployment Manager compiles that batch's distinct script bodies and trigger expressions on a background task and pipes `BatchCompileWarmed` back, so every `PreStart` compile is a cache hit. Instance Actor start still calls the compilation service — it remains the correctness backstop — but in every warmed path those calls are memoised lookups. Cost: one extra message per batch.
> **The Roslyn `ScriptOptions` are process-static, and must stay that way**: the reference set backing every site-side compile is built once per process, never per compile. `ScriptOptions.WithReferences(Assembly[])` resolves each assembly through `MetadataReference.CreateFromFile`, which does **not** cache — each call mints a fresh `MetadataReference` owning an `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock`, an unmanaged copy of the assembly metadata that nothing disposes. Building the options per compile leaks native memory permanently: no GC reclaims it, and it is invisible to gcdump and to the managed allocation counters, so the node's working set grows without the GC heap growing. This was a live defect (a Site node at 2,885 MB working set with 150 MB of live GC heap, ~2,469 MB of it on the default process heap across ~6,700 undisposed `AssemblyMetadata` instances). Note this is orthogonal to the compile cache above — the cache dedupes *identical* script bodies, so it bounds nothing when the bodies differ, and it clears wholesale on overflow, after which every script recompiles. Pinned by a reference-equality regression test rather than by watching memory.
> **The Roslyn `ScriptOptions` are process-static, and must stay that way**: the reference set backing every site-side compile is built once per process, never per compile. `ScriptOptions.WithReferences(Assembly[])` resolves each assembly through `MetadataReference.CreateFromFile`, which does **not** cache — each call mints a fresh `MetadataReference` owning an `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock`, an unmanaged copy of the assembly metadata that nothing disposes. Building the options per compile leaks native memory permanently: no GC reclaims it, and it is invisible to gcdump and to the managed allocation counters, so the node's working set grows without the GC heap growing. This was a live defect (a Site node at 2,885 MB working set with 150 MB of live GC heap, ~2,469 MB of it on the default process heap across ~6,700 undisposed `AssemblyMetadata` instances). Note this is orthogonal to the compile cache above — the cache dedupes *identical* script bodies, so it bounds nothing when the bodies differ. (It no longer clears wholesale on overflow: WP3.1 replaced that latency cliff with approximate-LRU batch eviction, see **Compile cache eviction** below.) Pinned by a reference-equality regression test rather than by watching memory.
> **Compile cache eviction is approximate LRU, not a wholesale clear (WP3.1)**: `SiteScriptCompileCache` is bounded at 1024 entries — deliberately smaller than the TemplateEngine verdict cache's 4096, because entries pin compiled assemblies rather than verdict strings. It used to `Clear()` **everything** on overflow. Instance scripts *and* trigger expressions share this cache, so on a site large enough to cross the bound every overflow discarded up to 1023 live compiled scripts and the next deploy or Instance-Actor start paid a full recompile storm **on actor threads**. Overflow now evicts only the oldest ⅛ (128 entries) by last-access stamp: one 1024-element scan instead of 1023 future recompiles, and hot entries survive. Recency is an `Interlocked` access sequence rather than a clock — deterministic for tests and immune to clock steps; hits update it lock-free, and only the (rare) eviction sweep takes a lock, double-checked so concurrent inserts do not stampede it. The sweep snapshots through `ConcurrentDictionary.ToArray()` and **not** LINQ over the dictionary: LINQ's `ToArray` picks the `ICollection.CopyTo` fast path, which throws when a concurrent insert lands between its Count read and its copy, and inserts are deliberately lock-free.
> **Static options were only half the fix — the compile must also use the shared caching metadata resolver**: the static `ScriptOptions` carry only the *direct* API-surface references (5 assemblies here), and each `script.Compile()` still binds their **transitive closure** (~105 assemblies on a site node). Every transitively-referenced assembly is resolved through the options' `MetadataReferenceResolver`, and Roslyn's default one has no cross-compilation cache — it calls `MetadataReference.CreateFromFile` afresh on **every compile**, minting the same undisposed `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock` triple per assembly per compiled script, pinned for the process lifetime by the compile cache. That per-compile *re-resolution* — not the per-compile options construction — was the dominant term (measured live: 21 site scripts holding 6,640 metadata objects, counts bit-identical before and after the static-options fix). `ScriptCompilationService` therefore also attaches the process-wide `CachingScriptMetadataResolver.Instance` (owned by the Script Analysis component, see `Component-ScriptAnalysis.md`) via `ScriptOptions.WithMetadataResolver`, so each distinct assembly is materialized once per process. The decorator returns exactly what the undecorated resolver returned — only object identity is de-duplicated — so compile results and the trust gate are unchanged.
@@ -179,10 +181,10 @@ The Instance Actor supervises all child Script and Alarm Actors with explicit st
| Child Actor | Exception Type | Strategy | Rationale |
|-------------|---------------|----------|-----------|
| Script Actor | Any exception | Resume | Script Actor is a coordinator — its state (trigger timers, last execution time) should survive child failures. Script Execution Actor failures are isolated. |
| Script Actor | Any exception | Resume | Script Actor is a coordinator — its state (trigger timers, last execution time) should survive run failures. |
| Alarm Actor | Any exception | Resume | Alarm Actor holds alarm state. Resume preserves state and continues evaluation on next value update. |
| Script Execution Actor | Unhandled exception | Stop | Short-lived, per-invocation. Failure is logged; the Script Actor coordinator remains active for future triggers. |
| Alarm Execution Actor | Unhandled exception | Stop | Short-lived, per on-trigger invocation. Same as Script Execution Actor. |
Script Actor and Alarm Actor have **no children of their own** since WP3.1 removed the per-run execution actors, so neither declares a supervision strategy any more. A run's own exceptions were always contained inside the run's try/catch (they never reached supervision); a failure of the *launch* is caught by the coordinator, which answers the caller and releases the run slot rather than letting the throw escalate to the Instance Actor.
The Deployment Manager singleton supervises Instance Actors with a **OneForOneStrategy** — one Instance Actor's failure does not affect other instances.
@@ -196,11 +198,11 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
- **Coordinator** for a single script definition on an instance.
- Holds the compiled script code and trigger configuration.
- Manages trigger evaluation (interval timer, value change detection, conditional evaluation).
- Spawns short-lived Script Execution Actors for each invocation.
- Launches script runs directly for each invocation (no per-run child actor since WP3.1).
### Trigger Management
- **Interval**: The Script Actor manages an internal timer. When the timer fires, it spawns a Script Execution Actor.
- **Value Change**: The Script Actor subscribes to attribute change notifications from its parent Instance Actor for the specific monitored attribute. When the attribute changes, it spawns a Script Execution Actor.
- **Interval**: The Script Actor manages an internal timer. When the timer fires, it launches a run.
- **Value Change**: The Script Actor subscribes to attribute change notifications from its parent Instance Actor for the specific monitored attribute. When the attribute changes, it launches a run.
- **Conditional**: The Script Actor subscribes to attribute change notifications for the monitored attribute. On each update, it evaluates the condition (compares the attribute against a threshold). Firing depends on the **fire mode** (see below).
- **Expression**: The Script Actor evaluates a compiled boolean expression against an attribute snapshot on each attribute change. Firing depends on the **fire mode** (see below).
- **Fire mode (Conditional + Expression)**:
@@ -211,20 +213,22 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
> **Per-attribute change routing (P2).** Rather than broadcasting every attribute change to all child Script/Alarm Actors, the Instance Actor routes each change only to the children that monitor that attribute (built from their trigger configs at spawn time); **Expression-trigger children, which read an attribute snapshot, receive all changes**. Each child still keeps its own trigger gate as defense-in-depth — the routing is an optimization, not the authority.
### Concurrent Execution
- Each invocation spawns a **new Script Execution Actor** as a child.
- Multiple Script Execution Actors can run concurrently (e.g., a trigger fires while a previous `Instance.CallScript` invocation is still running).
- The Script Actor coordinates but does not block on child completion.
- Each invocation launches a **new run** on the script-execution scheduler.
- Multiple runs can execute concurrently (e.g., a trigger fires while a previous `Instance.CallScript` invocation is still running), up to `MaxConcurrentRunsPerScript` — beyond which the newest is shed (see below).
- The Script Actor coordinates but does not block on run completion.
### Script Execution Actor
- **Short-lived** child actor created per invocation.
- Receives: compiled script code, input parameters, reference to the parent Instance Actor, current call depth.
- Executes the script in the Akka actor context.
- Has access to the full Script Runtime API (see below).
- Returns the script's return value (if defined) to the caller, then stops.
- The script body itself runs on the **dedicated `ScriptExecutionScheduler`** (a bounded set of dedicated threads), not the shared .NET thread pool, so blocking script I/O cannot starve the global pool or stall Akka dispatchers. The scheduler is **process-wide by default** (one pool per host, sized from `SiteRuntimeOptions.ScriptExecutionThreadCount`), but each script/alarm actor takes it through an **optional injection seam** rather than reaching for the static directly: the Host injects nothing and gets the shared pool, while tests (or a future multi-site host) can hand an actor its own instance. The shared accessor also recreates a disposed pool rather than returning it, so a disposed scheduler can never silently poison later executions.
### Script Run Launch (WP3.1 — replaces the Script Execution Actor)
- A run is launched **directly by the Script Actor** through `ScriptRunLauncher`. There is no per-run child actor.
- The former `ScriptExecutionActor` / `AlarmExecutionActor` were already inert shells: neither declared a `Receive` handler (they executed from their constructor), neither had a `PostStop`, state, or stash, and neither's `IActorRef` was ever a message target — the whole lifecycle lived inside a detached task the actor never observed. What they cost was an actor cell, mailbox, and name registration **per run**, plus a per-spawn expression-tree `Props.Create`. Removing them changed no semantics; the run body moved verbatim into the shared launcher.
- Everything the shells provided is preserved: exception and timeout containment, one DI scope per run disposed on every path, the site-event/health telemetry, the Ask reply, the completion notification (now the coordinator's own `Self`), and the audit `ExecutionId` / `ParentExecutionId` threading. A run still in flight when its Script Actor is stopped runs to completion and its completion message dead-letters, exactly as before — **stopping does NOT cancel in-flight runs**; redeploy/undeploy semantics are unchanged.
- One deliberate improvement: a failure of the *launch itself* (e.g. queueing onto a disposed scheduler) is caught by the Script Actor, which replies to the Ask caller and releases the run slot. The old per-run child's constructor throw was handled by a Stop supervision directive that sent no reply, leaving the caller to hang to its Ask timeout.
- The script body runs on the **dedicated `ScriptExecutionScheduler`** (a bounded set of dedicated threads), not the shared .NET thread pool, so blocking script I/O cannot starve the global pool or stall Akka dispatchers. The scheduler is **process-wide by default** (one pool per host), but each script/alarm actor takes it through an **optional injection seam** rather than reaching for the static directly: the Host injects nothing and gets the shared pool, while tests (or a future multi-site host) can hand an actor its own instance. The shared accessor also recreates a disposed pool rather than returning it, so a disposed scheduler can never silently poison later executions.
- **Pool sizing is instance-scaled and grow-only (WP3.1).** The pool was a fixed 8 threads regardless of load. It is now `clamp(max(ScriptExecutionThreadCount, ceil(enabledInstances / 8)), 1, ScriptExecutionMaxThreadCount)` — the existing `ScriptExecutionThreadCount` (default 8) becomes the **floor**, so configurations at or below 64 instances behave exactly as before, and the new `ScriptExecutionMaxThreadCount` (default 32) is the ceiling. Beyond the ceiling the per-script cap below is the real regulator. `DeploymentManagerActor.UpdateInstanceCounts` calls `EnsureCapacity` on every deploy / undeploy / enable / disable and once per staggered startup batch. Growth only: undeploying leaves idle threads, which cost nothing measurable and avoid drain/steal complexity.
- **The deadline is armed at enqueue, not at dequeue (WP3.1).** The run's timeout `CancellationTokenSource` is created on the actor thread *before* the body is queued, so queue wait consumes the script's own budget. A body that dequeues past its deadline **skips execution entirely** and takes the existing timeout path (site event, script-error counter, error reply, completion message): a saturated pool sheds stale work instead of running it late with a fresh full budget.
- **Concurrent runs per script are capped (WP3.1).** `MaxConcurrentRunsPerScript` (default 4) bounds runs in flight — queued or executing — for any one script or alarm on-trigger script. Over the cap the **newest** run is shed: the four already in flight are closest to their own deadlines and already charged against them, so nothing is ever reordered and no extra queue is needed (the scheduler's FIFO already is the queue). A shed increments `ISiteHealthCollector.IncrementScriptRunShed` (surfaced as `ScriptRunShedCount`), emits a `script`/`Warning` site event **rate-limited to one per script per minute** so a hot trigger cannot flood `site_events`, and — for an Ask-based `CallScript` — replies with an explicit error so a nested call or inbound-API route fails fast instead of hanging.
### Handling `Instance.CallScript`
- When an external caller (another Script Execution Actor, an Alarm Execution Actor, or a routed call from the Inbound API) sends a `CallScript` message to the Script Actor, it spawns a Script Execution Actor to handle the call.
- When an external caller (another script run, an alarm on-trigger run, or a routed call from the Inbound API) sends a `CallScript` message to the Script Actor, it launches a run to handle the call.
- The caller uses the **Akka ask pattern** and receives the return value when the execution completes.
---
@@ -244,7 +248,7 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
- **Range Violation**: Value is outside the allowed min/max range.
- **Rate of Change**: Value change rate exceeds the defined threshold over a configurable time window. Direction filter (rising / falling / either) restricts which side of the rate triggers.
- **HiLo**: Multi-setpoint level alarm with up to four configurable setpoints (LoLo, Lo, Hi, HiHi). Any subset may be configured. Each setpoint may carry its own priority that overrides the alarm-level priority for that band.
- **Expression** trigger evaluation runs on the bounded script-execution thread pool shared with script bodies (and Expression *script* triggers): scheduler saturation — e.g. stuck scripts occupying all threads — delays Expression alarm transitions site-wide until a thread frees. This is a deliberate trade-off (evaluation no longer blocks dispatcher threads; per-actor coalescing bounds queue growth) and the scheduler gauges + stuck-script watchdog make the saturated state visible.
- **Expression** trigger evaluation runs on the shared .NET thread pool behind a process-wide concurrency gate (`TriggerEvalGate`, sized by `TriggerEvalMaxConcurrency`, default `max(2, ProcessorCount)`) — **not** on the bounded script-execution pool. This is the WP3.1 fix for arch-review finding #4 (High): when evaluation shared that pool, N script bodies blocked in synchronous I/O stalled *every* Expression trigger on the node — scripts and alarms alike — for an unbounded time, and the evaluation's own 2 s timeout was constructed inside the queued body, so it did not start ticking until dequeue. An alarm that should have raised in milliseconds simply never raised, with neither a raise nor a timeout visible to the operator. Trigger expressions are non-blocking **by construction** (`TriggerExpressionGlobals` exposes only reads over an in-memory snapshot, and the script trust gate has already denied I/O, network, threading, and reflection), so the shared pool is where they belong; a second dedicated pool was considered and rejected as adding threads, gauges, and a second starvation surface for no isolation gain. The evaluation deadline (`TriggerEvalTimeoutSeconds`, default 2, previously hardcoded) is now armed **at enqueue**, so gate-wait time burns the same budget and a saturated gate yields a timely `false` rather than an unbounded stall. Per-actor coalescing (one evaluation in flight, one pending) is unchanged and caps waiters at one per Expression trigger, so the gate queue is bounded by trigger count.
- For binary trigger types (ValueMatch / RangeViolation / RateOfChange), when the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**:
- Updates the alarm state on the parent Instance Actor (which publishes to the Akka stream).
- If an on-trigger script is defined, spawns an Alarm Execution Actor to execute it.
@@ -256,9 +260,8 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
- Held **in memory** only — not persisted to SQLite. State comprises `AlarmState` (Active / Normal) and `AlarmLevel` (None for binary triggers; the active band for HiLo).
- On restart (or failover), alarm states are re-evaluated from incoming values. All alarms start in normal state with level None and transition based on incoming values.
### Alarm Execution Actor
- **Short-lived** child actor created when an on-trigger script needs to execute.
- Same pattern as Script Execution Actor — receives compiled code, executes, returns, and stops.
### Alarm On-Trigger Run
- Launched directly by the Alarm Actor when an on-trigger script needs to execute — the same `ScriptRunLauncher` path as an instance script run, with the firing alarm's name/level/priority/message exposed through the `Alarm` global. Bounded by the same `MaxConcurrentRunsPerScript` cap.
- Has access to the Instance Actor for `GetAttribute`/`SetAttribute`.
- **Can** call instance scripts via `Instance.CallScript()` — sends an ask message to the appropriate sibling Script Actor.
- Instance scripts **cannot** call alarm on-trigger scripts — the call direction is one-way.
@@ -275,7 +278,7 @@ change:
|---|---|
| `ScriptRuntimeContext.SetAttribute` (this run's `ExecutionId`) / `RouteToSetAttributesRequest.ParentExecutionId` (the inbound request's) | → `SetStaticAttributeCommand.SourceExecutionId` |
| Instance Actor static-write path (`HandleSetStaticAttributeCore`) | → `AttributeValueChanged.SourceExecutionId` |
| Alarm Actor trigger evaluation → `SpawnAlarmExecution` | → `AlarmExecutionActor``ScriptRuntimeContext.ParentExecutionId` |
| Alarm Actor trigger evaluation → `SpawnAlarmExecution` | → `ScriptRunLauncher.LaunchAlarmScript``ScriptRuntimeContext.ParentExecutionId` |
All four computed trigger types participate. `Expression` triggers evaluate a
whole attribute snapshot **off the dispatcher**, so the firing change is no
@@ -382,7 +385,7 @@ The enriched message flows Instance Actor → site-wide Akka stream → `SiteStr
- Shared scripts are compiled at the site when received from central.
- Compiled code is stored in memory and made available to all Script Actors.
- When a Script Execution Actor calls `Scripts.CallShared("scriptName", params)`, the shared script code executes **inline** in the Script Execution Actor's context — it is a direct method invocation, not an actor message.
- When a script run calls `Scripts.CallShared("scriptName", params)`, the shared script code executes **inline** in that run's context — it is a direct method invocation, not an actor message.
- This avoids serialization bottlenecks since there is no shared script actor to contend for.
- Shared scripts have access to the same runtime API as instance scripts (GetAttribute, SetAttribute, external systems, notifications, databases).
- **Shared scripts always execute with Root scope (C5)**: `Attributes[...]` inside a shared script addresses **root** attributes even when the shared script is invoked from a composed-module script. Callers needing module-scoped access must pass the values in as parameters.
@@ -417,14 +420,14 @@ All script types can be updated without restarting the cluster, but the mechanis
## Script Runtime API
Available to all Script Execution Actors and Alarm Execution Actors:
Available to all script runs and alarm on-trigger runs:
### Instance Attributes
- `Instance.GetAttribute("name")` — Read an attribute value from the parent Instance Actor.
- `Instance.SetAttribute("name", value)` — Write an attribute value. For data-connected attributes, writes to the DCL; for static attributes, updates in-memory and persists to local SQLite (survives restart/failover, reset on redeployment).
### Other Scripts
- `Instance.CallScript("scriptName", parameters)` — Send an ask message to a sibling Script Actor. The target Script Actor spawns a Script Execution Actor, executes, and returns the result. The call includes the current recursion depth.
- `Instance.CallScript("scriptName", parameters)` — Send an ask message to a sibling Script Actor. The target Script Actor launches a run, executes, and returns the result (or an explicit shed error if that script is already at its concurrent-run cap). The call includes the current recursion depth.
- `Scripts.CallShared("scriptName", parameters)` — Execute shared script code inline (direct method invocation). The call includes the current recursion depth.
### External Systems
@@ -474,7 +477,7 @@ Scripts execute **in-process** with constrained access. The following restrictio
- **Allowed**: Access to the Script Runtime API (GetAttribute, SetAttribute, CallScript, CallShared, ExternalSystem, Notify, Database, Tracking, Alarms), standard C# language features, basic .NET types (collections, string manipulation, math, date/time). `System.Diagnostics.Stopwatch`, `Debug`, and `Activity` are permitted.
- **Forbidden**: File system access (`System.IO`), process spawning (`System.Diagnostics.Process`), threading (`System.Threading` — except `Tasks`, `CancellationToken`, and `CancellationTokenSource`), reflection (`System.Reflection`), all raw network access (`System.Net` — must use `ExternalSystem.Call`), native interop (`System.Runtime.InteropServices`, `Microsoft.Win32`), assembly loading, unsafe code, `dynamic`, `Activator`.
- **Execution timeout**: Configurable per-script maximum execution time. Exceeding the timeout cancels the script **cooperatively** (S2/UA5): a script blocked in synchronous I/O or a tight CPU loop does not observe cancellation and continues to occupy its dedicated script-execution thread. A **watchdog logs the script by name** once its thread has not returned within a grace period after the timeout, and the script-execution scheduler's **queue depth / busy-thread / oldest-busy-age gauges** are reported through Health Monitoring so a saturated or stuck scheduler is visible.
- **Execution timeout**: Configurable per-script maximum execution time, **measured from enqueue** (WP3.1) — a run that spends its whole budget queued behind blocked scripts is shed at dequeue without executing, rather than being granted a fresh full budget for work whose triggering condition is already stale. Exceeding the timeout cancels the script **cooperatively** (S2/UA5): a script blocked in synchronous I/O or a tight CPU loop does not observe cancellation and continues to occupy its dedicated script-execution thread. A **watchdog names the script** once its thread has not returned within a grace period after the timeout — and, as of WP3.1, **detaches and replaces that worker thread**, so a wedged script costs the pool a thread only until the watchdog fires rather than for the rest of the process. The detached worker exits (instead of pulling more work) when its body finally returns, so capacity never silently doubles. Live detached workers are capped at the pool size — at the cap no replacement is started and an Error site event says so, because bounded starvation is preferable to unbounded thread growth when scripts wedge en masse. The scheduler's **queue depth / busy-thread / oldest-busy-age / detached-thread gauges** and the **per-interval shed count** are reported through Health Monitoring so a saturated, stuck, or shedding scheduler is visible.
- **Memory**: Scripts share the host process memory. No per-script memory limit, but the execution timeout prevents runaway allocations.
The forbidden-API policy is defined authoritatively in `ScriptTrustPolicy` (Script Analysis component, #25). `ScriptCompilationService.ValidateTrustModel` delegates to `ScriptTrustValidator.FindViolations` for the trust verdict; the Site Runtime also performs a `CSharpScript.Compile` against the real `ScriptGlobals` for execution. This is defence-in-depth static enforcement, not a true runtime sandbox.
@@ -540,7 +543,7 @@ Per Akka.NET best practices, internal actor communication uses **Tell** (fire-an
### Script Compilation Errors
- If script compilation fails when a deployment is received, the entire deployment for that instance is **rejected**. No partial state is applied.
- The failure is reported back to central as a failed deployment.
- **Enforced by a site-side pre-compile validation gate (S3)**: before the Instance Actor is created or the config persisted, the Deployment Manager compiles **all** of the instance's scripts, alarm on-trigger scripts, and trigger expressions **synchronously, as a pure prefix step of the deploy handler on the singleton's actor thread**deliberately not off-thread, because piping the verdict back to self would reorder concurrent deploys against delete/disable and break the redeploy-supersede mailbox-FIFO ordering (see the in-code rationale in `DeploymentManagerActor.HandleDeploy`); unchanged script bodies hit the process-wide compile cache, so the gate recompiles only genuinely new code. Any compile failure rejects the deployment with the collected compile errors. This makes the deployment result honest even if central's pre-deployment validation was bypassed or skew let a bad artifact through.
- **Enforced by a site-side pre-compile validation gate (S3)**: before the Instance Actor is created or the config persisted, the Deployment Manager compiles **all** of the instance's scripts, alarm on-trigger scripts, and trigger expressions **synchronously, as a pure prefix step of the deploy application on the singleton's actor thread**the verdict is never derived off-thread, because piping a verdict back to self would reorder concurrent deploys against delete/disable and break the redeploy-supersede mailbox-FIFO ordering. WP3.1 made it **warm-then-gate**: the same compile is first run off-thread purely to populate the process-wide compile cache (`DeployCompileWarmed` pipes back), so the authoritative synchronous gate is then all cache hits and no longer holds the singleton for a Roslyn compile. Ordering is preserved by a **per-instance in-flight guard** — while a warm is in flight for instance X, further mutating commands for X (deploy / delete / disable / enable) are queued and re-dispatched in arrival order once the deploy applies; a second deploy with nothing queued behind it supersedes the pending one last-write-wins and its displaced deployer is answered `Failed`-superseded rather than left to Ask-timeout. Commands for **other** instances flow freely, which is the point, and is safe because cross-instance ordering was never guaranteed to callers. Unchanged script bodies hit the compile cache, so the gate recompiles only genuinely new code. Any compile failure rejects the deployment with the collected compile errors. This makes the deployment result honest even if central's pre-deployment validation was bypassed or skew let a bad artifact through.
- Note: Pre-deployment validation at central should still catch compilation errors before they reach the site; the site gate is the authoritative backstop.
---
@@ -74,6 +74,25 @@ public record SiteHealthReport(
/// </summary>
public double? ScriptOldestBusyAgeSeconds { get; init; }
/// <summary>
/// WP3.1: script-execution worker threads the stuck-script watchdog has DETACHED and
/// replaced — a script wedged past its deadline plus grace in uninterruptible blocking
/// I/O, so the pool started a fresh thread and the wedged one will exit only when its
/// body finally returns. Point-in-time, refreshed by <c>ScriptSchedulerStatsReporter</c>.
/// Zero is the healthy state; a value that climbs and never drains means script bodies
/// are permanently consuming threads and the pool is being repeatedly rebuilt around them.
/// </summary>
public int DetachedScriptThreads { get; init; }
/// <summary>
/// WP3.1: per-interval count of script and alarm on-trigger runs SHED because
/// <c>MaxConcurrentRunsPerScript</c> runs were already in flight for that script. Raw
/// per-interval count (drained on collect) like <see cref="ScriptErrorCount"/>. A
/// sustained non-zero value means a trigger is firing faster than its script completes;
/// the shed itself is the designed back-pressure, not an error.
/// </summary>
public int ScriptRunShedCount { get; init; }
// LocalDb 2-node replication of the consolidated site database (Phase 1).
// Additive init properties for the same reason as the scheduler gauges above:
// the positional constructor stays untouched. Refreshed on the site by
@@ -25,6 +25,20 @@ public interface ISiteHealthCollector
/// </summary>
void IncrementDeadLetter();
/// <summary>
/// WP3.1: increments the per-interval count of script/alarm runs SHED because
/// <c>MaxConcurrentRunsPerScript</c> runs were already in flight for that script. A raw
/// per-interval count like the script/alarm error counters — a sustained non-zero value
/// means a trigger is firing faster than its script can complete. Every shed is counted
/// here even when its accompanying site event is rate-limited away.
/// Default interface implementation is a no-op so existing test fakes continue to
/// compile without per-fake updates.
/// </summary>
void IncrementScriptRunShed()
{
// Default no-op so test fakes do not need to be updated.
}
/// <summary>
/// Increment the per-interval count of
/// <c>FallbackAuditWriter</c> primary failures. Bridged from the
@@ -173,7 +187,13 @@ public interface ISiteHealthCollector
/// <param name="queueDepth">Script tasks waiting to run.</param>
/// <param name="busyThreads">Worker threads currently executing a script.</param>
/// <param name="oldestBusyAgeSeconds">Age (seconds) of the oldest in-flight script, or <c>null</c> when idle.</param>
void SetScriptSchedulerStats(int queueDepth, int busyThreads, double? oldestBusyAgeSeconds)
/// <param name="detachedThreads">
/// WP3.1: worker threads the stuck-script watchdog has detached and replaced because
/// their script wedged past its deadline plus grace, and which have not yet returned.
/// A non-zero, non-draining value means script bodies are permanently blocking threads.
/// </param>
void SetScriptSchedulerStats(
int queueDepth, int busyThreads, double? oldestBusyAgeSeconds, int detachedThreads = 0)
{
// Default no-op so test fakes do not need to be updated.
}
@@ -39,6 +39,12 @@ public class SiteHealthCollector : ISiteHealthCollector
private int _scriptQueueDepth;
private int _scriptBusyThreads;
private long _scriptOldestBusyAgeBits = BitConverter.DoubleToInt64Bits(double.NaN);
// WP3.1: workers detached and replaced by the stuck-script watchdog and not yet exited
// (point-in-time, from the same reporter tick as the gauges above), and the per-interval
// count of runs shed by the per-script in-flight cap (reset on collect like the error
// counters, and restored by AddIntervalCounters when a report fails to send).
private int _scriptDetachedThreads;
private int _scriptRunShedCount;
// WP2.6d: cumulative alarm-publish-queue drop count, refreshed by
// SiteStreamAlarmDropReporter. Point-in-time (not reset on collect), like the
// scheduler gauges above.
@@ -162,12 +168,20 @@ public class SiteHealthCollector : ISiteHealthCollector
}
/// <inheritdoc />
public void SetScriptSchedulerStats(int queueDepth, int busyThreads, double? oldestBusyAgeSeconds)
public void SetScriptSchedulerStats(
int queueDepth, int busyThreads, double? oldestBusyAgeSeconds, int detachedThreads = 0)
{
Interlocked.Exchange(ref _scriptQueueDepth, queueDepth);
Interlocked.Exchange(ref _scriptBusyThreads, busyThreads);
Interlocked.Exchange(ref _scriptOldestBusyAgeBits,
BitConverter.DoubleToInt64Bits(oldestBusyAgeSeconds ?? double.NaN));
Interlocked.Exchange(ref _scriptDetachedThreads, detachedThreads);
}
/// <inheritdoc />
public void IncrementScriptRunShed()
{
Interlocked.Increment(ref _scriptRunShedCount);
}
/// <summary>Reads the atomically-stored oldest-busy script age, mapping the NaN sentinel back to null.</summary>
@@ -283,6 +297,11 @@ public class SiteHealthCollector : ISiteHealthCollector
ScriptQueueDepth = Interlocked.CompareExchange(ref _scriptQueueDepth, 0, 0),
ScriptBusyThreads = Interlocked.CompareExchange(ref _scriptBusyThreads, 0, 0),
ScriptOldestBusyAgeSeconds = ReadScriptOldestBusyAgeSeconds(),
// WP3.1: point-in-time (like the three gauges above), so a CompareExchange read
// rather than an Exchange reset.
DetachedScriptThreads = Interlocked.CompareExchange(ref _scriptDetachedThreads, 0, 0),
// WP3.1: per-interval, so drained on collect like the error counters.
ScriptRunShedCount = Interlocked.Exchange(ref _scriptRunShedCount, 0),
// Both fields come from the ONE snapshot read above. Null (the reporter has
// not run) leaves both report fields null — "no data", not "disconnected
// with an empty backlog".
@@ -25,10 +25,10 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// State (active/normal) is in memory only, NOT persisted.
/// On restart: starts normal, re-evaluates from incoming values.
///
/// AlarmExecutionActor CAN call Instance.CallScript() (ask to sibling Script Actor).
/// An alarm on-trigger run CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no Instance.CallAlarmScript API).
///
/// Supervision: Resume on exception; AlarmExecutionActor stopped on exception.
/// Supervision: Resume on exception (from the Instance Actor). This actor has no children.
/// </summary>
public class AlarmActor : ReceiveActor
{
@@ -44,12 +44,39 @@ public class AlarmActor : ReceiveActor
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide
/// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so this
/// alarm's trigger-expression evaluation and spawned on-trigger scripts run on a
/// caller-owned pool. Resolved lazily at each use so the null (host) path is
/// unchanged.
/// alarm's launched on-trigger scripts run on a caller-owned pool. Resolved lazily at
/// each use so the null (host) path is unchanged.
///
/// <para>WP3.1: trigger-expression evaluation no longer uses this scheduler — see
/// <see cref="_evalGate"/>. That split is the whole point of finding #4: an alarm whose
/// Expression trigger should raise in milliseconds must not queue behind blocking
/// script bodies.</para>
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
/// <summary>
/// WP3.1 (finding #4): the concurrency gate for trigger-expression evaluation, or null
/// for the process-wide <see cref="TriggerEvalGate.Shared"/>. Evaluations run as plain
/// async work on the shared .NET thread pool behind this gate.
/// </summary>
private readonly TriggerEvalGate? _evalGate;
/// <summary>
/// WP3.1: on-trigger runs launched but not yet completed. Incremented at launch,
/// decremented on <see cref="AlarmExecutionCompleted"/>, which every terminal path
/// emits — including the launch-path catch. Touched only on the actor thread.
/// </summary>
private int _runsInFlight;
/// <summary>
/// WP3.1: when the last shed Warning site event was emitted for this alarm. Sheds are
/// always counted; the event is rate-limited to one per alarm per minute.
/// </summary>
private DateTimeOffset _lastShedEventUtc = DateTimeOffset.MinValue;
/// <summary>Rate limit for the shed site event (the counter still counts every shed).</summary>
private static readonly TimeSpan ShedEventInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// The optional site operational-event log, resolved once from
/// <see cref="_serviceProvider"/> at construction and cached. The
@@ -84,7 +111,7 @@ public class AlarmActor : ReceiveActor
/// <summary>
/// The on-trigger script's per-script execution timeout in seconds,
/// or null to use the global default. Forwarded to each spawned
/// <see cref="AlarmExecutionActor"/>, which applies <c>perScript ?? global</c>
/// <see cref="Scripts.ScriptRunLauncher"/>, which applies <c>perScript ?? global</c>
/// (treating ≤ 0 as "use global"). The value comes from the referenced
/// on-trigger script's <see cref="ResolvedScript.ExecutionTimeoutSeconds"/>.
/// </summary>
@@ -125,10 +152,10 @@ public class AlarmActor : ReceiveActor
/// <summary>
/// Audit Log #23 (ParentExecutionId tag-cascade): the
/// <c>parentExecutionId</c> handed to the most recently spawned
/// <see cref="AlarmExecutionActor"/> — i.e. the execution whose attribute
/// on-trigger run — i.e. the execution whose attribute
/// write fired this alarm, or <c>null</c> when the firing change came from
/// the Data Connection Layer (external data has no spawning execution).
/// The spawned actor builds its own <see cref="ScriptRuntimeContext"/>
/// The launched run builds its own <see cref="ScriptRuntimeContext"/>
/// internally, so this is exposed for regression coverage of the cascade
/// contract (mirrors <see cref="SeedAttributesReference"/>).
/// </summary>
@@ -159,6 +186,7 @@ public class AlarmActor : ReceiveActor
/// execution timeout in seconds (from its <see cref="ResolvedScript.ExecutionTimeoutSeconds"/>),
/// or null/non-positive to use the global default.</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
/// <param name="evalGate">Optional trigger-expression concurrency gate override (WP3.1); null uses the process-wide shared gate.</param>
public AlarmActor(
string alarmName,
string instanceName,
@@ -175,7 +203,9 @@ public class AlarmActor : ReceiveActor
// Per-script timeout for the on-trigger script (null = global).
int? onTriggerExecutionTimeoutSeconds = null,
// Script-execution scheduler seam (#18); null uses the process-wide shared scheduler.
ScriptExecutionScheduler? scheduler = null)
ScriptExecutionScheduler? scheduler = null,
// WP3.1 trigger-eval gate seam; null uses the process-wide shared gate.
TriggerEvalGate? evalGate = null)
{
_alarmName = alarmName;
_instanceName = instanceName;
@@ -186,6 +216,7 @@ public class AlarmActor : ReceiveActor
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_scheduler = scheduler;
_evalGate = evalGate;
// Resolve the optional site event logger once and cache it,
// rather than calling GetService on every alarm transition.
_siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
@@ -217,9 +248,8 @@ public class AlarmActor : ReceiveActor
// Handle attribute value changes
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle alarm execution completion
Receive<AlarmExecutionCompleted>(_ =>
_logger.LogDebug("Alarm {Alarm} execution completed on {Instance}", _alarmName, _instanceName));
// Handle alarm execution completion (also releases the WP3.1 in-flight slot)
Receive<AlarmExecutionCompleted>(HandleAlarmExecutionCompleted);
// Handle the off-dispatcher trigger-expression evaluation result (P1).
Receive<ExpressionEvalResult>(HandleExpressionEvalResult);
@@ -238,20 +268,10 @@ public class AlarmActor : ReceiveActor
_alarmName, _instanceName, _triggerType);
}
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
_logger.LogWarning(ex,
"AlarmExecutionActor for {Alarm} on {Instance} failed, stopping",
_alarmName, _instanceName);
return Directive.Stop;
}));
}
// WP3.1: the Stop-on-failure SupervisorStrategy override is gone with the per-run
// AlarmExecutionActor child it supervised. This actor has no children left; a
// launch-path throw is caught in SpawnAlarmExecution so it can never escalate to
// InstanceActor, which continues to supervise this actor with Resume exactly as before.
/// <summary>
/// Evaluates alarm condition on attribute change. Alarm evaluation errors are logged,
@@ -345,7 +365,7 @@ public class AlarmActor : ReceiveActor
// Operational `alarm` event — raise. Severity by priority.
LogAlarmEvent(RaiseSeverity(_priority), $"Alarm {_alarmName} activated (priority {_priority})");
// Spawn AlarmExecutionActor if on-trigger script defined
// Launch the on-trigger run if an on-trigger script is defined
if (_onTriggerCompiledScript != null)
{
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty, sourceExecutionId);
@@ -442,7 +462,7 @@ public class AlarmActor : ReceiveActor
/// <see cref="ISiteEventLogger"/> (resolved once at construction and cached
/// in <see cref="_siteEventLogger"/>). Never awaited so a logging failure
/// cannot affect alarm evaluation (matching the established
/// ScriptActor/ScriptExecutionActor pattern).
/// ScriptActor / script-run pattern).
/// </summary>
private void LogAlarmEvent(string severity, string message)
{
@@ -589,31 +609,50 @@ public class AlarmActor : ReceiveActor
// later change arriving while the evaluation is in flight cannot
// mis-attribute the raise this evaluation produces.
var sourceExecutionId = _latestSourceExecutionId;
Task.Factory.StartNew(async () =>
var gate = _evalGate ?? TriggerEvalGate.Shared(_options);
// WP3.1 (finding #4): the deadline clock starts AT ENQUEUE, on the actor thread —
// gate-wait time burns the same budget, so a saturated gate yields a timely false
// instead of an unbounded stall. And the work runs on the shared .NET thread pool,
// NOT the blocking script pool, so it can never queue behind a blocked script body.
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_options.TriggerEvalTimeoutSeconds));
Task.Run(async () =>
{
try
{
// Bound evaluation with a short timeout. The CancellationToken covers
// cooperative/async cases; a pathological CPU-bound expression is not
// fully interruptible — acceptable because trigger expressions are
// authored by trusted Design-role users and compile-checked pre-deploy.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = await expression.RunAsync(new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
await gate.WaitAsync(cts.Token).ConfigureAwait(false);
try
{
// The CancellationToken covers cooperative/async cases; a pathological
// CPU-bound expression is not fully interruptible — acceptable because
// trigger expressions are authored by trusted Design-role users and
// compile-checked pre-deploy.
var state = await expression.RunAsync(
new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
return state.ReturnValue is bool b && b;
}
finally
{
gate.Release();
}
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too and is
// treated as false. _healthCollector (Interlocked) and _logger are
// thread-safe, so this catch is safe off the actor thread.
// OperationCanceledException (timeout, INCLUDING one that fired while still
// waiting on the gate) falls through here too and is treated as false.
// _healthCollector (Interlocked) and _logger are thread-safe, so this catch
// is safe off the actor thread.
_healthCollector?.IncrementAlarmError();
_logger.LogError(ex,
"Alarm {Alarm} trigger expression evaluation failed on {Instance}; treated as false",
_alarmName, _instanceName);
return false;
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
_scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
finally
{
cts.Dispose();
}
}).PipeTo(self,
success: r => new ExpressionEvalResult(r, sourceExecutionId),
failure: ex => new ExpressionEvalFailed(ex, sourceExecutionId));
}
@@ -705,9 +744,34 @@ public class AlarmActor : ReceiveActor
}
/// <summary>
/// Spawns an AlarmExecutionActor to run the on-trigger script.
/// WP3.1: releases the in-flight slot for a completed on-trigger run. Every terminal
/// path emits exactly one <see cref="AlarmExecutionCompleted"/> (success / timeout /
/// failure / launch failure), so the counter tracks reality.
/// </summary>
private void HandleAlarmExecutionCompleted(AlarmExecutionCompleted msg)
{
if (_runsInFlight > 0) _runsInFlight--;
_logger.LogDebug(
"Alarm {Alarm} execution completed on {Instance}: success={Success}",
_alarmName, _instanceName, msg.Success);
}
/// <summary>
/// WP3.1: on-trigger runs launched but not yet completed. Exposed for regression
/// coverage of the shed cap.
/// </summary>
internal int RunsInFlight => _runsInFlight;
/// <summary>
/// Launches the on-trigger script run.
/// Passes the firing alarm's level/priority/message so the script can
/// branch on severity via the <c>Alarm</c> global.
///
/// <para>WP3.1: launched directly via <see cref="ScriptRunLauncher"/> rather than
/// through a short-lived <c>AlarmExecutionActor</c> child, and bounded by
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> — a raise arriving while
/// the cap is reached is shed (counted, rate-limited site event) rather than piling
/// another run onto a saturated pool.</para>
/// </summary>
/// <param name="level">The firing alarm severity level.</param>
/// <param name="priority">The firing alarm priority.</param>
@@ -727,15 +791,27 @@ public class AlarmActor : ReceiveActor
{
if (_onTriggerCompiledScript == null) return;
var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}";
if (_runsInFlight >= _options.MaxConcurrentRunsPerScript)
{
ShedAlarmRun();
return;
}
var runId = _executionCounter++;
// Record what the on-trigger run was parented to (null = root); read by
// the tag-cascade regression tests, which cannot see inside the child.
// the tag-cascade regression tests, which cannot observe the run directly.
LastOnTriggerParentExecutionId = parentExecutionId;
// Incremented BEFORE the launch so the launch-path catch below (which always emits
// an AlarmExecutionCompleted) balances it on every path.
_runsInFlight++;
try
{
// The on-trigger script body runs on the dedicated
// ScriptExecutionScheduler, not the shared .NET thread pool.
var props = Props.Create(() => new AlarmExecutionActor(
ScriptRunLauncher.LaunchAlarmScript(
_alarmName,
_instanceName,
level,
@@ -745,16 +821,52 @@ public class AlarmActor : ReceiveActor
_instanceActor,
_sharedScriptLibrary,
_options,
// Completion target: this actor. Identical delivery to the old
// Context.Parent.Tell from the execution actor.
Self,
_logger,
runId,
// Per-script timeout from the on-trigger script (null = global).
_onTriggerExecutionTimeoutSeconds,
// The firing execution's id — null for DCL-originated changes.
parentExecutionId,
// Scheduler seam (#18): share this alarm's scheduler override with the
// spawned on-trigger script body (null = process-wide shared).
_scheduler));
// launched on-trigger script body (null = process-wide shared).
_scheduler);
}
catch (Exception ex)
{
// WP3.1 supervision parity: mirrors the removed OneForOneStrategy's warn-and-stop
// for a per-run child that failed to construct. The alarm continues.
_logger.LogWarning(ex,
"Alarm on-trigger execution launch for {Alarm} on {Instance} failed, stopping",
_alarmName, _instanceName);
Self.Tell(new AlarmExecutionCompleted(_alarmName, false));
}
}
Context.ActorOf(props, executionId);
/// <summary>
/// WP3.1 shed policy for alarm on-trigger runs: refuses the newest run when
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> are already in flight.
/// There is no Ask caller on this path (an on-trigger run is never awaited), so the
/// shed surfaces as a counter plus a rate-limited Warning site event.
/// </summary>
private void ShedAlarmRun()
{
_healthCollector?.IncrementScriptRunShed();
var message = $"Alarm on-trigger script for '{_alarmName}' on instance '{_instanceName}': run shed — " +
$"{_runsInFlight} runs already in flight (cap {_options.MaxConcurrentRunsPerScript}).";
_logger.LogWarning("{Message}", message);
var now = DateTimeOffset.UtcNow;
if (now - _lastShedEventUtc >= ShedEventInterval)
{
_lastShedEventUtc = now;
_ = _siteEventLogger?.LogEventAsync(
"script", "Warning", _instanceName, $"AlarmActor:{_alarmName}", message);
}
}
private AlarmEvalConfig ParseEvalConfig(string? triggerConfigJson)
@@ -1,167 +0,0 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Alarm Execution Actor -- short-lived child of Alarm Actor.
/// Same pattern as ScriptExecutionActor.
/// CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no API for it).
/// Supervision: Stop on unhandled exception.
/// </summary>
public class AlarmExecutionActor : ReceiveActor
{
/// <summary>Initializes a new <see cref="AlarmExecutionActor"/> and immediately schedules execution of the alarm on-trigger script.</summary>
/// <param name="alarmName">The canonical name of the alarm that triggered.</param>
/// <param name="instanceName">The name of the owning instance.</param>
/// <param name="level">The alarm severity level at the time of triggering.</param>
/// <param name="priority">The alarm priority value.</param>
/// <param name="message">The alarm message to pass to the script.</param>
/// <param name="compiledScript">The pre-compiled on-trigger script to execute.</param>
/// <param name="instanceActor">Reference to the parent instance actor for attribute/script calls.</param>
/// <param name="sharedScriptLibrary">Shared script library providing common utilities.</param>
/// <param name="options">Site runtime configuration options, including the execution timeout.</param>
/// <param name="logger">Logger for execution diagnostics.</param>
/// <param name="executionTimeoutSeconds">The on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
/// <param name="parentExecutionId">
/// ParentExecutionId tag-cascade: the <c>ExecutionId</c> of
/// the execution whose attribute write fired this alarm, threaded into the
/// on-trigger script's <see cref="ScriptRuntimeContext"/> as its
/// <c>ParentExecutionId</c> so the alarm-triggered run chains under its
/// firing execution. Null when the firing value came from the Data
/// Connection Layer (external data has no spawning execution) — that
/// on-trigger run is a tree root.
/// </param>
public AlarmExecutionActor(
string alarmName,
string instanceName,
AlarmLevel level,
int priority,
string message,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger,
// Per-script execution timeout override (seconds) for the
// alarm on-trigger script. Null or non-positive falls back to the global.
int? executionTimeoutSeconds = null,
// The firing context's execution id (null today).
Guid? parentExecutionId = null,
// Script-execution scheduler seam (#18): the process-wide scheduler by
// default; null selects the shared default.
ScriptExecutionScheduler? scheduler = null)
{
var self = Self;
var parent = Context.Parent;
ExecuteAlarmScript(
alarmName, instanceName, level, priority, message,
compiledScript, instanceActor,
sharedScriptLibrary, options, self, parent, logger,
executionTimeoutSeconds, parentExecutionId, scheduler);
}
private static void ExecuteAlarmScript(
string alarmName,
string instanceName,
AlarmLevel level,
int priority,
string message,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef self,
IActorRef parent,
ILogger logger,
int? executionTimeoutSeconds,
Guid? parentExecutionId,
ScriptExecutionScheduler? scheduler)
{
// Per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
var timeout = TimeSpan.FromSeconds(
executionTimeoutSeconds is { } perScript && perScript > 0
? perScript
: options.ScriptExecutionTimeoutSeconds);
// Run the alarm on-trigger body on the dedicated
// script-execution scheduler, not the shared .NET thread pool. An injected
// scheduler (#18) overrides the process-wide default.
var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
_ = Task.Factory.StartNew(async () =>
{
using var cts = new CancellationTokenSource(timeout);
try
{
// AlarmExecutionActor can call Instance.CallScript()
// via the ScriptRuntimeContext injected into globals
var context = new ScriptRuntimeContext(
instanceActor,
self,
sharedScriptLibrary,
currentCallDepth: 0,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger,
// ParentExecutionId tag-cascade: the
// alarm on-trigger run mints its own fresh ExecutionId (the
// ctor's `?? NewGuid()` fallback) and records the firing
// execution's id as its ParentExecutionId — null (a root)
// only when the firing value came from the DCL.
parentExecutionId: parentExecutionId,
// WaitForAttribute (spec §4.4): thread the alarm on-trigger
// script's per-script execution-timeout token so a
// Attributes.WaitAsync inside an on-trigger script is bounded
// by the same script deadline.
scriptTimeoutToken: cts.Token);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new ScriptParameters(),
CancellationToken = cts.Token,
Alarm = new AlarmContext
{
Name = alarmName,
Level = level,
Priority = priority,
Message = message
}
};
await compiledScript.RunAsync(globals, cts.Token);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, true));
}
catch (OperationCanceledException)
{
logger.LogWarning(
"Alarm on-trigger script for {Alarm} on {Instance} timed out",
alarmName, instanceName);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
catch (Exception ex)
{
// Failures logged, alarm continues
logger.LogError(ex,
"Alarm on-trigger script for {Alarm} on {Instance} failed",
alarmName, instanceName);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
finally
{
self.Tell(PoisonPill.Instance);
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler).Unwrap();
}
}
@@ -142,6 +142,23 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// </summary>
private readonly HashSet<string> _initFailedPendingRowRemoval = new();
/// <summary>
/// WP3.1 warm-then-gate: instances whose deploy compile is being warmed off the actor
/// thread, keyed by instance name. Presence is the per-instance in-flight guard that keeps
/// same-instance command ordering intact while OTHER instances' commands flow freely.
/// Entries are added in <see cref="HandleDeploy"/> and removed in
/// <see cref="HandleDeployCompileWarmed"/> — one or the other always runs, because the
/// warm task pipes its result back unconditionally (it swallows its own exceptions).
/// </summary>
private readonly Dictionary<string, DeployWarmState> _deployWarms = new();
/// <summary>
/// WP3.1: script-execution scheduler seam (#18) — the pool this actor grows as instances
/// deploy. Null selects the process-wide shared scheduler; tests inject their own so
/// sizing assertions do not perturb (or depend on) the process-wide singleton.
/// </summary>
private readonly ScriptExecutionScheduler? _scriptScheduler;
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!;
@@ -172,6 +189,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// external-system changes. Optional/null in tests that do not exercise external-system
/// caching.
/// </param>
/// <param name="scriptScheduler">
/// WP3.1: optional script-execution scheduler override (#18). This actor grows the pool
/// towards <see cref="ScriptExecutionScheduler.ComputeTargetThreads"/> on every instance-
/// count change; null uses the process-wide shared scheduler.
/// </param>
public DeploymentManagerActor(
SiteStorageService storage,
ScriptCompilationService compilationService,
@@ -186,8 +208,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
IDeploymentConfigFetcher? configFetcher = null,
TimeSpan? startupLoadRetryInterval = null,
Func<Task<List<DeployedInstance>>>? configLoader = null,
ExternalSystemDefinitionCache? externalSystemCache = null)
ExternalSystemDefinitionCache? externalSystemCache = null,
ScriptExecutionScheduler? scriptScheduler = null)
{
_scriptScheduler = scriptScheduler;
_storage = storage;
_compilationService = compilationService;
_deployCompileValidator = new DeployCompileValidator(compilationService);
@@ -212,9 +236,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Lifecycle commands
Receive<DeployInstanceCommand>(cmd => HandleDeploy(cmd, Sender));
Receive<DisableInstanceCommand>(HandleDisable);
Receive<EnableInstanceCommand>(HandleEnable);
Receive<DeleteInstanceCommand>(HandleDelete);
Receive<DisableInstanceCommand>(cmd => HandleDisable(cmd, Sender));
Receive<EnableInstanceCommand>(cmd => HandleEnable(cmd, Sender));
Receive<DeleteInstanceCommand>(cmd => HandleDelete(cmd, Sender));
// WP3.1 warm-then-gate: the off-thread compile warm for a deploy has finished, so
// the (now all-cache-hit) synchronous compile gate can run on the actor thread.
Receive<DeployCompileWarmed>(HandleDeployCompileWarmed);
// WP3.1: a staggered startup batch's compiles have been pre-warmed off-thread;
// create the batch's Instance Actors now that their PreStart compiles are hits.
Receive<BatchCompileWarmed>(msg => CreateInstanceActorBatch(msg.Batch));
// Notify-and-fetch: central sends a small RefreshDeploymentCommand;
// the active singleton fetches the flattened config over HTTP, then reuses the
@@ -461,8 +492,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Creates Instance Actors in batches with a configurable delay between batches
/// to prevent reconnection storms on failover.
/// WP3.1: pre-warms one staggered startup batch's script and trigger-expression compiles
/// off the actor thread, then pipes <see cref="BatchCompileWarmed"/> so
/// <see cref="CreateInstanceActorBatch"/> can create the batch's Instance Actors with
/// every <c>PreStart</c> compile already a cache hit.
///
/// <para>This closes the long-standing "per-instance compilation during staggered startup"
/// gap: on failover, each Instance Actor used to Roslyn-compile its own scripts inside
/// <c>PreStart</c>, serialising a site's whole recovery behind compilation. The cost is one
/// extra message per batch.</para>
/// </summary>
private void HandleStartNextBatch(StartNextBatch msg)
{
@@ -471,6 +509,45 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var startIdx = state.NextIndex;
var endIdx = Math.Min(startIdx + batchSize, state.Configs.Count);
var validator = _deployCompileValidator;
var batchConfigs = new string[endIdx - startIdx];
for (var i = startIdx; i < endIdx; i++)
batchConfigs[i - startIdx] = state.Configs[i].ConfigJson;
Task.Run(() =>
{
foreach (var configJson in batchConfigs)
{
try
{
// Verdict discarded: startup does not gate on compile failures (a failing
// script is logged by the Instance Actor and leaves the rest running).
// This call exists only to populate SiteScriptCompileCache.
validator.Validate(configJson);
}
catch
{
// Best-effort warm — a throw here just means that instance's PreStart
// compiles the hard way, exactly as it did before WP3.1.
}
}
return new BatchCompileWarmed(msg);
}).PipeTo(Self);
}
/// <summary>
/// Creates one batch's Instance Actors (after its compiles have been pre-warmed) and
/// schedules the next batch, with a configurable delay between batches to prevent
/// reconnection storms on failover.
/// </summary>
private void CreateInstanceActorBatch(StartNextBatch msg)
{
var state = msg.State;
var batchSize = _options.StartupBatchSize;
var startIdx = state.NextIndex;
var endIdx = Math.Min(startIdx + batchSize, state.Configs.Count);
_logger.LogDebug(
"Creating Instance Actors batch [{Start}..{End}) of {Total}",
startIdx, endIdx, state.Configs.Count);
@@ -520,23 +597,164 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// apply still replies to the right actor.
/// </param>
private void HandleDeploy(DeployInstanceCommand command, IActorRef replyTo)
{
var instanceName = command.InstanceUniqueName;
// WP3.1 warm-then-gate. The S3 gate below must stay synchronous on the actor thread
// (mailbox FIFO is what makes redeploy-supersede and delete-during-redeploy correct),
// but the Roslyn compile it performs used to hold the whole singleton — every OTHER
// instance's commands stalled behind one instance's compile. So the compile is WARMED
// off-thread first and the synchronous gate then re-runs as pure cache hits.
//
// Ordering is preserved by an explicit per-instance in-flight guard: while a warm is
// in flight for instance X, further mutating commands for X are queued here rather
// than racing ahead. Commands for OTHER instances flow freely, which is the whole
// point — and safe, because cross-instance ordering was never guaranteed to callers.
if (_deployWarms.TryGetValue(instanceName, out var warming))
{
// Nothing queued behind the pending deploy yet: plain last-write-wins, and the
// displaced deployer is told it was superseded so it never waits out its Ask —
// the same contract as the mid-termination redeploy buffer below.
if (warming.Buffered.Count == 0)
{
warming.ReplyTo.Tell(new DeploymentStatusResponse(
warming.Command.DeploymentId, instanceName, DeploymentStatus.Failed,
$"superseded by newer deployment {command.DeploymentId} before the site compile gate ran",
DateTimeOffset.UtcNow));
warming.Command = command;
warming.ReplyTo = replyTo;
// Warm the NEW config; the in-flight warm's result is recognised as stale by
// the reference check in HandleDeployCompileWarmed and dropped.
StartDeployCompileWarm(command);
return;
}
// A delete/disable/enable is already queued behind the pending deploy —
// superseding now would reorder it past this deploy, so queue instead.
warming.Buffered.Add(new BufferedInstanceCommand(command, replyTo));
return;
}
_deployWarms[instanceName] = new DeployWarmState(command, replyTo);
StartDeployCompileWarm(command);
}
/// <summary>
/// Compiles the deployment's scripts off the actor thread purely to populate the
/// process-wide <see cref="SiteScriptCompileCache"/>, then pipes
/// <see cref="DeployCompileWarmed"/> back so the authoritative gate can run on the actor
/// thread against a warm cache. The warm is best-effort: its verdict is discarded and any
/// exception swallowed, because <see cref="RunDeployGateAndProceed"/> re-derives the
/// verdict (and reproduces any failure) exactly as it did before WP3.1.
/// </summary>
private void StartDeployCompileWarm(DeployInstanceCommand command)
{
var validator = _deployCompileValidator;
var configJson = command.FlattenedConfigurationJson;
Task.Run(() =>
{
try
{
validator.Validate(configJson);
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Deploy compile warm for {Instance} threw; the synchronous gate will re-run it",
command.InstanceUniqueName);
}
return new DeployCompileWarmed(command);
}).PipeTo(Self);
}
/// <summary>
/// Runs the authoritative compile gate for a warmed deployment on the actor thread, then
/// drains any commands queued for that instance during the warm — inline and in arrival
/// order, NOT by re-telling <c>Self</c>, which would put them behind messages that landed
/// in the mailbox during the warm and so reorder same-instance commands.
/// </summary>
private void HandleDeployCompileWarmed(DeployCompileWarmed msg)
{
var instanceName = msg.Command.InstanceUniqueName;
if (!_deployWarms.TryGetValue(instanceName, out var warming))
return;
// A newer deploy superseded this one while its warm was running; that supersede
// started its own warm, so this result is stale and must not apply a dead command.
if (!ReferenceEquals(warming.Command, msg.Command))
return;
_deployWarms.Remove(instanceName);
RunDeployGateAndProceed(warming.Command, warming.ReplyTo);
foreach (var queued in warming.Buffered)
DispatchBufferedCommand(queued);
}
/// <summary>
/// Queues a mutating lifecycle command for an instance whose deploy is mid-compile-warm.
/// Returns <see langword="true"/> when the command was queued (the caller must return
/// immediately), <see langword="false"/> when no warm is in flight and the caller should
/// proceed normally.
/// </summary>
private bool TryBufferDuringDeployWarm(string instanceName, object command, IActorRef replyTo)
{
if (!_deployWarms.TryGetValue(instanceName, out var warming)) return false;
warming.Buffered.Add(new BufferedInstanceCommand(command, replyTo));
return true;
}
/// <summary>
/// Re-dispatches one command queued during a deploy compile warm, restoring the original
/// sender. A queued deploy legitimately opens a NEW warm window, in which case the
/// commands after it re-queue behind that one — ordering still holds.
/// </summary>
private void DispatchBufferedCommand(BufferedInstanceCommand queued)
{
switch (queued.Command)
{
case DeployInstanceCommand deploy:
HandleDeploy(deploy, queued.Sender);
break;
case DeleteInstanceCommand delete:
HandleDelete(delete, queued.Sender);
break;
case DisableInstanceCommand disable:
HandleDisable(disable, queued.Sender);
break;
case EnableInstanceCommand enable:
HandleEnable(enable, queued.Sender);
break;
default:
_logger.LogWarning(
"Unhandled buffered command type {Type} for a deploy compile warm — dropped",
queued.Command.GetType().Name);
break;
}
}
/// <summary>
/// The authoritative site-side compile gate (S3) plus the deploy application. Reached
/// only from <see cref="HandleDeployCompileWarmed"/>, i.e. after the same compile has
/// been warmed off-thread.
/// </summary>
private void RunDeployGateAndProceed(DeployInstanceCommand command, IActorRef replyTo)
{
// Site-side compile gate (S3): a compile failure must reject the
// deployment with NO partial state applied (no Instance Actor, no
// persisted config) — the design spec's contract. Validation runs
// synchronously on the actor thread: it is a pure prefix step of the
// deploy handler, so the existing redeploy-supersede / delete-during-
// redeploy ordering (which depends on strict mailbox FIFO) is preserved
// exactly. It is NOT run off-thread — piping the verdict back to self
// reorders concurrent deploys relative to each other and to
// delete/disable commands, breaking that ordering. A deploy is an
// infrequent admin command, so briefly holding the singleton for a pure
// Roslyn compile is acceptable; the central deployer already Asks and
// waits for the DeploymentStatusResponse. Redeploys and multi-instance
// deploys of unchanged scripts hit the process-wide compile cache
// (SiteScriptCompileCache), so the synchronous gate recompiles only
// genuinely new code and the Instance Actor's PreStart reuses the gate's
// compile (N4).
// persisted config) — the design spec's contract. Validation still runs
// synchronously on the actor thread as a pure prefix step of the deploy
// application, so the existing redeploy-supersede / delete-during-redeploy
// ordering (which depends on strict mailbox FIFO) is preserved exactly.
// WP3.1 removed the COST rather than the ordering: the same compile has
// already been warmed off-thread (StartDeployCompileWarm), so this call is
// pure cache hits and no longer holds the singleton — while the per-instance
// warm guard keeps this instance's own command ordering intact. Redeploys and
// multi-instance deploys of unchanged scripts hit the process-wide compile
// cache (SiteScriptCompileCache) too, and the Instance Actor's PreStart reuses
// the gate's compile (N4).
var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson);
if (errors.Count > 0)
{
@@ -942,10 +1160,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Disables an instance: stops the actor and marks as disabled in SQLite.
/// </summary>
private void HandleDisable(DisableInstanceCommand command)
private void HandleDisable(DisableInstanceCommand command, IActorRef replyTo)
{
var instanceName = command.InstanceUniqueName;
// WP3.1 warm-then-gate: a deploy for this instance is mid-compile-warm; queue this
// command so same-instance ordering is preserved (see TryBufferDuringDeployWarm).
if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;
// A disable arriving mid-redeploy must cancel the buffered
// redeploy. Otherwise HandleTerminated re-creates the Instance Actor and
// re-stores its config with isEnabled: true when the predecessor terminates,
@@ -975,7 +1197,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
UpdateInstanceCounts();
var sender = Sender;
var sender = replyTo;
_storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
@@ -1005,10 +1227,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// Enables an instance: marks as enabled in SQLite and re-creates the Instance Actor
/// from the stored config.
/// </summary>
private void HandleEnable(EnableInstanceCommand command)
private void HandleEnable(EnableInstanceCommand command, IActorRef replyTo)
{
var instanceName = command.InstanceUniqueName;
var sender = Sender;
// WP3.1 warm-then-gate: see TryBufferDuringDeployWarm.
if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;
var sender = replyTo;
Task.Run(async () =>
{
@@ -1061,10 +1287,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// Deletes an instance: stops the actor and removes config from SQLite.
/// Note: store-and-forward messages are NOT cleared per design decision.
/// </summary>
private void HandleDelete(DeleteInstanceCommand command)
private void HandleDelete(DeleteInstanceCommand command, IActorRef replyTo)
{
var instanceName = command.InstanceUniqueName;
// WP3.1 warm-then-gate: see TryBufferDuringDeployWarm.
if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;
// A delete arriving while a redeploy is still terminating must
// be authoritative over the mid-redeploy bookkeeping. HandleDeploy already
// removed the instance from _instanceActors and buffered a PendingRedeploy
@@ -1105,7 +1334,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_deployedInstanceNames.Remove(instanceName);
UpdateInstanceCounts();
var sender = Sender;
var sender = replyTo;
_storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
@@ -1135,7 +1364,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// Fire-and-forget a <c>deployment</c> operational event to the optional
/// <see cref="ISiteEventLogger"/> on a deploy/enable/disable/delete outcome.
/// Resolved optionally and never awaited so a logging failure cannot affect the
/// deployment pipeline (matching the established ScriptActor/ScriptExecutionActor
/// deployment pipeline (matching the established ScriptActor / script-run
/// pattern).
/// <para>
/// <b>Thread-safety:</b> the disable (<see cref="HandleDisable"/>) and delete
@@ -2089,7 +2318,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
internal int InstanceActorCount => _instanceActors.Count;
/// <summary>
/// Updates the health collector with current instance counts.
/// Updates the health collector with current instance counts, and (WP3.1) grows the
/// script-execution pool to match.
/// Total deployed = _deployedInstanceNames.Count, enabled = running actors, disabled = difference.
/// </summary>
private void UpdateInstanceCounts()
@@ -2098,6 +2328,22 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
deployed: _deployedInstanceNames.Count,
enabled: _instanceActors.Count,
disabled: _deployedInstanceNames.Count - _instanceActors.Count);
// WP3.1: the blocking script pool scales with the number of running instances rather
// than sitting at a fixed 8 forever. This is the single call site because it already
// runs on every deploy / undeploy / enable / disable and once per staggered startup
// batch. Growth is idempotent and one-way — see ScriptExecutionScheduler.EnsureCapacity.
try
{
var target = ScriptExecutionScheduler.ComputeTargetThreads(_instanceActors.Count, _options);
(_scriptScheduler ?? ScriptExecutionScheduler.Shared(_options)).EnsureCapacity(target);
}
catch (Exception ex)
{
// Pool sizing is an optimisation, never a correctness requirement: a failure here
// must not fail the deploy/enable/disable that triggered it.
_logger.LogWarning(ex, "Failed to resize the script-execution pool; continuing at its current size.");
}
}
// ── Internal messages ──
@@ -2131,6 +2377,43 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
internal record SharedScriptsLoaded(
List<DeployedInstance> EnabledConfigs, int CompiledCount, int TotalCount);
/// <summary>
/// WP3.1 warm-then-gate: piped back to self once a deployment's scripts have been
/// compiled off the actor thread into the process-wide compile cache. Carries the exact
/// command instance it warmed so a result superseded mid-warm is recognised by reference
/// and dropped.
/// </summary>
internal sealed record DeployCompileWarmed(DeployInstanceCommand Command);
/// <summary>
/// WP3.1: piped back to self once a staggered startup batch's compiles have been
/// pre-warmed, carrying the original batch message so actor creation resumes unchanged.
/// </summary>
internal sealed record BatchCompileWarmed(StartNextBatch Batch);
/// <summary>
/// A lifecycle command queued because its instance's deploy was mid-compile-warm, with
/// the sender to answer once it is re-dispatched.
/// </summary>
internal sealed record BufferedInstanceCommand(object Command, IActorRef Sender);
/// <summary>
/// The in-flight state of one instance's deploy compile warm: the deploy that will be
/// applied when the warm lands (replaceable last-write-wins while nothing is queued behind
/// it), the deployer to answer, and the ordered commands queued during the warm.
/// </summary>
internal sealed class DeployWarmState(DeployInstanceCommand command, IActorRef replyTo)
{
/// <summary>The deploy to apply when the warm completes.</summary>
public DeployInstanceCommand Command { get; set; } = command;
/// <summary>The deployer awaiting this deploy's <see cref="DeploymentStatusResponse"/>.</summary>
public IActorRef ReplyTo { get; set; } = replyTo;
/// <summary>Commands for this instance that arrived during the warm, in arrival order.</summary>
public List<BufferedInstanceCommand> Buffered { get; } = [];
}
internal record StartNextBatch(BatchState State);
internal record BatchState(List<DeployedInstance> Configs, int NextIndex);
internal record EnableResult(
@@ -374,7 +374,7 @@ public class InstanceActor : ReceiveActor
/// Fire-and-forget an <c>instance_lifecycle</c> operational event to the
/// optional <see cref="ISiteEventLogger"/>. Resolved optionally and never
/// awaited so a logging failure cannot affect the instance lifecycle
/// (matching the established ScriptActor/ScriptExecutionActor pattern).
/// (matching the established ScriptActor / script-run pattern).
/// </summary>
private void LogLifecycleEvent(string message)
{
@@ -1666,8 +1666,8 @@ public class InstanceActor : ReceiveActor
{
Script<object?>? onTriggerScript = null;
// The on-trigger script's per-script execution timeout,
// captured from its ResolvedScript so the AlarmExecutionActor can
// apply perScript ?? global. Null when there is no on-trigger script.
// captured from its ResolvedScript so the launched on-trigger run
// can apply perScript ?? global. Null when there is no on-trigger script.
int? onTriggerTimeoutSeconds = null;
// Compile on-trigger script if defined
@@ -444,7 +444,7 @@ public class NativeAlarmActor : ReceiveActor
/// condition's severity); an inactive condition is a return-to-normal; an
/// acknowledge transition is informational. Resolved optionally and never
/// awaited so a logging failure cannot affect the mirror (matching the
/// established ScriptActor/ScriptExecutionActor pattern).
/// established ScriptActor / script-run pattern).
/// </summary>
private void LogAlarmEvent(NativeAlarmTransition t, AlarmConditionState condition)
{
@@ -15,8 +15,16 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Script Actor — coordinator actor, child of Instance Actor.
/// Holds compiled script delegate, manages trigger configuration, and spawns
/// ScriptExecutionActor children per invocation. Does not block on child completion.
/// Holds compiled script delegate, manages trigger configuration, and launches script
/// runs per invocation. Does not block on run completion.
///
/// <para>WP3.1: runs are launched directly via <see cref="ScriptRunLauncher"/> rather than
/// through a short-lived <c>ScriptExecutionActor</c> child (which had no <c>Receive</c>
/// handler, no <c>PostStop</c>, and whose <c>IActorRef</c> was never a message target — pure
/// per-run actor-cell overhead). Concurrent runs are bounded by
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>; over the cap the NEWEST
/// trigger is shed. Trigger-expression evaluation no longer runs on the blocking script pool
/// at all — see <see cref="TriggerEvalGate"/>.</para>
///
/// Trigger types:
/// - Interval: uses Akka timers to fire periodically
@@ -43,20 +51,52 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide
/// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so
/// this actor's trigger-expression evaluation and spawned script bodies run on a
/// caller-owned pool instead of the shared one. Resolved lazily at each use so the
/// null (host) path stays byte-for-byte identical to the previous static call.
/// this actor's launched script bodies run on a caller-owned pool instead of the
/// shared one. Resolved lazily at each use so the null (host) path stays
/// byte-for-byte identical to the previous static call.
///
/// <para>WP3.1: trigger-expression evaluation no longer uses this scheduler — see
/// <see cref="_evalGate"/>.</para>
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
/// <summary>
/// WP3.1 (finding #4): the concurrency gate for trigger-expression evaluation, or null
/// for the process-wide <see cref="TriggerEvalGate.Shared"/>. Evaluations run as plain
/// async work on the shared .NET thread pool behind this gate, NOT on
/// <see cref="_scheduler"/> — that is what stops an Expression trigger from queueing
/// behind blocking script bodies.
/// </summary>
private readonly TriggerEvalGate? _evalGate;
/// <summary>
/// WP3.1: runs launched but not yet completed (queued or executing) for this script.
/// Incremented at launch, decremented on <see cref="ScriptExecutionCompleted"/>, which
/// every terminal path emits — including the launch-path catch, so the counter cannot
/// leak. Touched only on the actor thread.
/// </summary>
private int _runsInFlight;
/// <summary>
/// WP3.1: when the last shed Warning site event was emitted for this script. Sheds are
/// ALWAYS counted on the health collector; the site event is rate-limited to one per
/// script per minute so a hot trigger against a saturated cap cannot flood
/// <c>site_events</c>.
/// </summary>
private DateTimeOffset _lastShedEventUtc = DateTimeOffset.MinValue;
/// <summary>Rate limit for the shed site event (the counter still counts every shed).</summary>
private static readonly TimeSpan ShedEventInterval = TimeSpan.FromMinutes(1);
private Script<object?>? _compiledScript;
private ScriptTriggerConfig? _triggerConfig;
private TimeSpan? _minTimeBetweenRuns;
/// <summary>
/// The per-script execution timeout in seconds, or null to use the
/// global default. Threaded down to each spawned <see cref="ScriptExecutionActor"/>,
/// which applies <c>perScript ?? global</c> (and treats ≤ 0 as "use global").
/// global default. Threaded down to each launched run via
/// <see cref="ScriptRunLauncher"/>, which applies <c>perScript ?? global</c>
/// (and treats ≤ 0 as "use global").
/// </summary>
private readonly int? _executionTimeoutSeconds;
private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
@@ -111,6 +151,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <param name="healthCollector">Optional health metrics collector.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution context services.</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
/// <param name="evalGate">Optional trigger-expression concurrency gate override (WP3.1); null uses the process-wide shared gate.</param>
public ScriptActor(
string scriptName,
string instanceName,
@@ -124,7 +165,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
ScriptExecutionScheduler? scheduler = null)
ScriptExecutionScheduler? scheduler = null,
TriggerEvalGate? evalGate = null)
{
_scriptName = scriptName;
_instanceName = instanceName;
@@ -136,6 +178,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_scheduler = scheduler;
_evalGate = evalGate;
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
_executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds;
_scope = scriptConfig.Scope;
@@ -201,24 +244,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
_scriptName, _instanceName);
}
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
_logger.LogWarning(ex,
"ScriptExecutionActor for {Script} on {Instance} failed, stopping",
_scriptName, _instanceName);
return Directive.Stop;
}));
}
// WP3.1: the Stop-on-failure SupervisorStrategy override is gone with the per-run
// ScriptExecutionActor child it supervised. This actor has no children left; a
// launch-path throw is caught in SpawnExecution (which replies to the Ask caller and
// releases the in-flight slot) so it can never escalate to InstanceActor, which
// continues to supervise this actor with Resume exactly as before.
/// <summary>
/// Handles CallScript ask from ScriptRuntimeContext or Instance Actor.
/// Spawns a ScriptExecutionActor and forwards the sender for reply.
/// Launches a run and captures the sender for the eventual reply.
/// </summary>
private void HandleScriptCallRequest(ScriptCallRequest request)
{
@@ -233,7 +267,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
// (ParentExecutionId): carry any inbound-routed ParentExecutionId
// through to the ScriptExecutionActor so the routed script's
// through to the launched run so the routed script's
// ScriptRuntimeContext can record its spawner. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
SpawnExecution(
@@ -287,12 +321,22 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// Starts an off-dispatcher evaluation of the compiled trigger expression (P1).
/// The expression previously ran synchronously on the actor's dispatcher thread
/// via <c>RunAsync(...).GetAwaiter().GetResult()</c>, blocking the dispatcher on
/// every attribute change. It now runs on the dedicated script-execution
/// scheduler against a point-in-time snapshot; the boolean result is piped back
/// to this actor as an <see cref="ExpressionEvalResult"/> so all edge state is
/// applied on the actor thread. Bursts coalesce: at most one evaluation is in
/// flight, at most one pending, so a storm of changes collapses to the latest
/// snapshot without unbounded task fan-out.
/// every attribute change. It runs off-thread against a point-in-time snapshot;
/// the boolean result is piped back to this actor as an
/// <see cref="ExpressionEvalResult"/> so all edge state is applied on the actor
/// thread. Bursts coalesce: at most one evaluation is in flight, at most one
/// pending, so a storm of changes collapses to the latest snapshot without
/// unbounded task fan-out.
///
/// <para>WP3.1 (finding #4) changed WHERE it runs and WHEN its clock starts. It used
/// to run on the dedicated <see cref="ScriptExecutionScheduler"/> — the same bounded
/// pool as blocking script bodies — so N blocked scripts stalled every Expression
/// trigger on the node indefinitely. It now runs as plain async work on the shared
/// .NET thread pool behind <see cref="TriggerEvalGate"/>: trigger expressions are
/// non-blocking by construction, so they belong there. And the deadline
/// <see cref="CancellationTokenSource"/> is armed HERE, on the actor thread, before
/// the work is queued — so gate-wait time burns the same budget and a saturated gate
/// yields a timely false instead of an unbounded stall.</para>
/// </summary>
private void StartExpressionEvaluation()
{
@@ -303,29 +347,45 @@ public class ScriptActor : ReceiveActor, IWithTimers
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
var expression = _compiledTriggerExpression;
var self = Self;
Task.Factory.StartNew(async () =>
var gate = _evalGate ?? TriggerEvalGate.Shared(_options);
// Clock starts AT ENQUEUE, not at dequeue.
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_options.TriggerEvalTimeoutSeconds));
Task.Run(async () =>
{
try
{
// Bound evaluation with a short timeout. The CancellationToken covers
// cooperative/async cases; a pathological CPU-bound expression is not
// fully interruptible — acceptable because trigger expressions are
// authored by trusted Design-role users and compile-checked pre-deploy.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = await expression.RunAsync(new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
await gate.WaitAsync(cts.Token).ConfigureAwait(false);
try
{
// The CancellationToken covers cooperative/async cases; a pathological
// CPU-bound expression is not fully interruptible — acceptable because
// trigger expressions are authored by trusted Design-role users and
// compile-checked pre-deploy.
var state = await expression.RunAsync(
new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
return state.ReturnValue is bool b && b;
}
finally
{
gate.Release();
}
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too and is
// treated as false. LogExpressionError touches only thread-safe
// members (_healthCollector Interlocked, _logger, DI-resolved
// singleton logger) so it is safe off the actor thread.
// OperationCanceledException (timeout, INCLUDING a timeout that fired while
// still waiting on the gate) falls through here too and is treated as false.
// LogExpressionError touches only thread-safe members (_healthCollector
// Interlocked, _logger, DI-resolved singleton logger) so it is safe off the
// actor thread.
LogExpressionError(ex);
return false;
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
_scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
finally
{
cts.Dispose();
}
}).PipeTo(self,
success: r => new ExpressionEvalResult(r),
failure: ex => new ExpressionEvalFailed(ex));
}
@@ -421,7 +481,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <summary>
/// Records a trigger-expression evaluation failure to the site event log,
/// mirroring how ScriptExecutionActor reports script errors.
/// mirroring how a script run reports its own errors.
/// </summary>
private void LogExpressionError(Exception ex)
{
@@ -457,8 +517,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Spawns a new ScriptExecutionActor child for this invocation.
/// Multiple concurrent executions are allowed.
/// Launches a run of this script. Multiple concurrent runs are allowed, up to
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>; beyond that the newest
/// trigger is shed (see <see cref="ShedRun"/>).
///
/// <para>WP3.1: the run is launched directly on the script-execution scheduler via
/// <see cref="ScriptRunLauncher"/> — the actor's mailbox stays on the default dispatcher,
/// but the script body runs on the bounded set of dedicated threads, so blocking script
/// I/O is contained there and cannot starve the shared .NET thread pool. No per-run child
/// actor is created.</para>
/// </summary>
private void SpawnExecution(
IReadOnlyDictionary<string, object?>? parameters,
@@ -467,13 +534,20 @@ public class ScriptActor : ReceiveActor, IWithTimers
string correlationId,
Guid? parentExecutionId = null)
{
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
if (_runsInFlight >= _options.MaxConcurrentRunsPerScript)
{
ShedRun(replyTo, correlationId);
return;
}
// The actor's mailbox stays on the default dispatcher, but the
// script body itself runs on the dedicated ScriptExecutionScheduler (a bounded
// set of dedicated threads), so blocking script I/O is contained there and
// cannot starve the shared .NET thread pool.
var props = Props.Create(() => new ScriptExecutionActor(
var runId = _executionCounter++;
// Incremented BEFORE the launch so the launch-path catch below (which always emits a
// ScriptExecutionCompleted) balances it on every path — the counter cannot leak.
_runsInFlight++;
try
{
ScriptRunLauncher.LaunchScript(
_scriptName,
_instanceName,
_compiledScript!,
@@ -484,8 +558,17 @@ public class ScriptActor : ReceiveActor, IWithTimers
_options,
replyTo,
correlationId,
// Completion target: this actor. Identical delivery to the old
// Context.Parent.Tell from the execution actor, and it doubles as the
// in-flight release.
Self,
// Notification Outbox: the site communication actor Notify.Status queries
// central through. Resolved here, on the actor thread, and handed to the
// launcher (which has no ActorContext of its own).
Context.System.ActorSelection("/user/site-communication"),
_logger,
_scope,
runId,
_healthCollector,
_serviceProvider,
// (ParentExecutionId): null for trigger-driven runs;
@@ -494,19 +577,80 @@ public class ScriptActor : ReceiveActor, IWithTimers
// Per-script timeout override (null = use global).
_executionTimeoutSeconds,
// Scheduler seam (#18): thread this actor's scheduler override down so
// spawned script bodies share the same pool (null = process-wide shared).
_scheduler));
// launched script bodies share the same pool (null = process-wide shared).
_scheduler);
}
catch (Exception ex)
{
// WP3.1 supervision parity: the only failure the removed per-run child could
// surface was a constructor throw (e.g. queueing onto a disposed scheduler),
// which the old OneForOneStrategy logged and stopped — leaving an Ask caller to
// hang with no reply. Same log shape, same "coordinator unaffected" outcome, plus
// a reply so the caller fails fast instead of waiting out its Ask timeout.
var errorMsg = $"Script '{_scriptName}' on instance '{_instanceName}' could not be launched: {ex.Message}";
_logger.LogWarning(ex,
"Script execution launch for {Script} on {Instance} failed, stopping",
_scriptName, _instanceName);
Context.ActorOf(props, executionId);
if (!replyTo.IsNobody())
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
Self.Tell(new ScriptExecutionCompleted(_scriptName, false, errorMsg));
}
}
/// <summary>
/// WP3.1 shed policy: refuses the incoming run because
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> runs are already in flight.
/// The four already queued/running are kept — they are closest to their own deadlines and
/// already charged against them — so nothing is ever reordered. A trigger-driven run is
/// simply not launched; an Ask-based <c>CallScript</c> gets an explicit error so a nested
/// call or inbound-API route fails fast rather than hanging to its Ask timeout.
/// </summary>
private void ShedRun(IActorRef replyTo, string correlationId)
{
_healthCollector?.IncrementScriptRunShed();
var message = $"Script '{_scriptName}' on instance '{_instanceName}': run shed — " +
$"{_runsInFlight} runs already in flight (cap {_options.MaxConcurrentRunsPerScript}).";
_logger.LogWarning("{Message}", message);
// Rate-limited to one event per script per minute: the counter above still counts
// every shed, but a hot trigger against a saturated cap must not flood site_events.
var now = DateTimeOffset.UtcNow;
if (now - _lastShedEventUtc >= ShedEventInterval)
{
_lastShedEventUtc = now;
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
"script", "Warning", _instanceName, $"ScriptActor:{_scriptName}", message);
}
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(
correlationId, false, null,
$"shed: {_runsInFlight} runs already in flight"));
}
}
private void HandleExecutionCompleted(ScriptExecutionCompleted msg)
{
// WP3.1: release the in-flight slot. Every terminal path emits exactly one of these
// (success / timeout / failure / launch failure), so the counter tracks reality.
if (_runsInFlight > 0) _runsInFlight--;
_logger.LogDebug(
"Script {Script} execution completed on {Instance}: success={Success}",
_scriptName, _instanceName, msg.Success);
}
/// <summary>
/// WP3.1: runs launched but not yet completed. Exposed for regression coverage of the
/// shed cap — the counter is otherwise invisible from outside the actor.
/// </summary>
internal int RunsInFlight => _runsInFlight;
// internal (not private) so the culture-invariance of the non-numeric fallback
// can be unit-tested directly on the test thread — the live path evaluates on a
// dispatcher thread whose CurrentCulture the test cannot deterministically set.
@@ -1,341 +0,0 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Script Execution Actor -- short-lived child of Script Actor.
/// Receives compiled code, params, Instance Actor ref, and call depth.
/// Executes the script via Script Runtime API, returns result, then stops.
///
/// The actor itself and its mailbox run on the default Akka dispatcher; only the
/// script body is dispatched off the actor thread, onto the dedicated
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptExecutionScheduler"/>,
/// so blocking script I/O cannot starve the shared thread pool
/// or stall other Akka dispatchers.
///
/// Script failures are logged but do not disable the script.
/// Supervision: Stop on unhandled exception (parent ScriptActor decides).
/// </summary>
public class ScriptExecutionActor : ReceiveActor
{
/// <summary>
/// Initializes the actor and immediately begins script execution on construction.
/// </summary>
/// <param name="scriptName">Name of the script being executed.</param>
/// <param name="instanceName">Name of the instance that owns the script.</param>
/// <param name="compiledScript">Compiled Roslyn script to execute.</param>
/// <param name="parameters">Optional named parameter values for the script.</param>
/// <param name="callDepth">Current call-nesting depth (used to enforce the max-depth limit).</param>
/// <param name="instanceActor">Parent instance actor reference for attribute access.</param>
/// <param name="sharedScriptLibrary">Library of shared scripts available during execution.</param>
/// <param name="options">Site runtime options applied during execution.</param>
/// <param name="replyTo">Actor reference that receives the script result.</param>
/// <param name="correlationId">Application-level correlation id threaded through the execution.</param>
/// <param name="logger">Logger for script execution events.</param>
/// <param name="scope">Script scope controlling which APIs are available.</param>
/// <param name="healthCollector">Optional health collector for recording execution metrics.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
/// <param name="parentExecutionId">ExecutionId of the spawning inbound-API execution for audit correlation; null for normal runs.</param>
/// <param name="executionTimeoutSeconds">Per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
public ScriptExecutionActor(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
// The spawning execution's
// ExecutionId for an inbound-API-routed call. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
Guid? parentExecutionId = null,
// Per-script execution timeout override (seconds). Null or
// non-positive falls back to the global ScriptExecutionTimeoutSeconds.
int? executionTimeoutSeconds = null,
// Script-execution scheduler seam (#18): the process-wide
// ScriptExecutionScheduler by default; a test (or a future multi-site
// host) can inject its own instance so script bodies never run on the
// shared process-wide pool. Null selects the shared default.
ScriptExecutionScheduler? scheduler = null)
{
// Immediately begin execution
var self = Self;
var parent = Context.Parent;
ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, scope, healthCollector, serviceProvider,
parentExecutionId, executionTimeoutSeconds, scheduler);
}
private static void ExecuteScript(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
IActorRef self,
IActorRef parent,
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider,
Guid? parentExecutionId,
int? executionTimeoutSeconds,
ScriptExecutionScheduler? scheduler)
{
// Per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
var timeout = TimeSpan.FromSeconds(
executionTimeoutSeconds is { } perScript && perScript > 0
? perScript
: options.ScriptExecutionTimeoutSeconds);
// Run the script body on the dedicated script-execution
// scheduler, not the shared .NET thread pool, so blocking script I/O cannot
// starve the global pool and stall Akka dispatchers / HTTP handling. An
// injected scheduler (#18) overrides the process-wide default.
var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
// Notification Outbox: the site communication actor that Notify.Status queries
// central through. Resolved by actor path so the Notify helper does not need an
// IActorRef threaded all the way down from the host wiring.
var siteCommunicationActor = Context.System.ActorSelection("/user/site-communication");
// CTS must be created inside the async lambda so it outlives this method
_ = Task.Factory.StartNew(async () =>
{
IServiceScope? serviceScope = null;
// ISiteEventLogger is a singleton; resolve from the root provider so
// it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
using var cts = new CancellationTokenSource(timeout);
// Stuck-script watchdog (S2). The CTS firing only REQUESTS cooperative
// cancellation; it does NOT free a thread blocked in synchronous I/O.
// When the timeout elapses, wait a grace period on the thread pool and,
// if the body still hasn't returned, name the script loudly — this is
// the only signal an operator gets that one of the bounded
// script-execution threads is gone. `completed` is flipped in the
// finally below; Register fires on cancellation only (normal completion
// disposes the CTS with no callback). The Task.Run/Task.Delay run on the
// thread pool, not the (possibly saturated) script scheduler — deliberate.
var completed = 0;
var graceMs = options.StuckScriptGraceMs;
cts.Token.Register(() => _ = Task.Run(async () =>
{
await Task.Delay(graceMs);
if (Volatile.Read(ref completed) == 0)
{
var stuckMsg = $"Script '{scriptName}' on instance '{instanceName}' exceeded its " +
$"{timeout.TotalSeconds:F0}s timeout and is STILL EXECUTING — its dedicated " +
"script-execution thread is blocked (cooperative cancellation not observed).";
logger.LogError(stuckMsg);
_ = siteEventLogger?.LogEventAsync("script", "Error", instanceName,
$"ScriptActor:{scriptName}", stuckMsg);
}
}));
try
{
// Resolve integration services from DI (scoped lifetime)
IExternalSystemClient? externalSystemClient = null;
IDatabaseGateway? databaseGateway = null;
// Notification Outbox: the S&F engine is a singleton; the site identity
// provider supplies the site id stamped on enqueued notifications.
StoreAndForwardService? storeAndForward = null;
var siteId = string.Empty;
// The writer is a singleton (FallbackAuditWriter
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
// that haven't called AddAuditLog, which the helper handles as a no-op.
IAuditWriter? auditWriter = null;
// Site-local tracking store
// backing Tracking.Status(id). Singleton; null in tests / hosts
// that haven't wired the store, which the helper handles by
// throwing on access.
IOperationTrackingStore? operationTrackingStore = null;
// Site-side cached-call
// telemetry forwarder. Singleton bound to the AuditLog
// composition root; null in tests / hosts that haven't called
// AddAuditLog, in which case the cached-call helpers degrade
// to the no-emission path (the underlying S&F handoff still
// happens and a TrackedOperationId is still returned).
ICachedCallTelemetryForwarder? cachedForwarder = null;
// SourceNode-stamping: the local node name
// resolved from INodeIdentityProvider — node-a/node-b on site
// hosts. Null in tests / hosts that haven't registered the
// provider, in which case NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode stay null and central
// persists the rows with SourceNode NULL.
string? sourceNode = null;
if (serviceProvider != null)
{
serviceScope = serviceProvider.CreateScope();
externalSystemClient = serviceScope.ServiceProvider.GetService<IExternalSystemClient>();
databaseGateway = serviceScope.ServiceProvider.GetService<IDatabaseGateway>();
storeAndForward = serviceScope.ServiceProvider.GetService<StoreAndForwardService>();
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
?? string.Empty;
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
}
var context = new ScriptRuntimeContext(
instanceActor,
self,
sharedScriptLibrary,
callDepth,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger,
externalSystemClient,
databaseGateway,
storeAndForward,
siteCommunicationActor,
siteId,
// Notification Outbox (FU3): stamp the executing script onto outbound
// notifications using the Site Event Logging "Source" convention.
sourceScript: $"ScriptActor:{scriptName}",
// Emit one ApiOutbound/ApiCall row per
// ExternalSystem.Call. Writer is best-effort; failures are logged
// and swallowed inside the helper so the script's call path is
// never aborted by an audit failure.
auditWriter: auditWriter,
// Site-local tracking store
// backing Tracking.Status(id). Authoritative source of truth for
// cached-call status — read directly by the script API.
operationTrackingStore: operationTrackingStore,
// Cached-call telemetry
// forwarder for ExternalSystem.CachedCall / Database.CachedWrite
// CachedSubmit emission + the immediate-success terminal-row
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
cachedForwarder: cachedForwarder,
// The spawning execution's
// id for an inbound-API-routed call. The routed script still
// mints its own fresh ExecutionId — this records the spawner.
// Null for normal (tag-change / timer) runs.
parentExecutionId: parentExecutionId,
// SourceNode-stamping: the local node name
// (node-a/node-b on a site) — threaded down so Notify.Send
// and the four cached-call telemetry constructors can stamp
// it onto NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode respectively.
sourceNode: sourceNode,
// Thread the singleton site event logger so
// recursion-limit violations at CallScript/CallShared emit a
// script Error site event in addition to ILogger.LogError.
siteEventLogger: siteEventLogger,
// WaitForAttribute (spec §4.3/§4.4): thread the per-script
// execution-timeout token so Attributes.WaitAsync's Ask is
// bounded by the script's own ExecutionTimeoutSeconds — a
// shorter script deadline wins over the wait's own timeout.
scriptTimeoutToken: cts.Token);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
CancellationToken = cts.Token,
Scope = scope
};
// Operational `script` event — execution started. Fire-and-forget
// (the `_ =` discards the task) so the event log can never block or
// fault the script's own run; mirrors the existing Error-path emit.
_ = siteEventLogger?.LogEventAsync(
"script", "Info", instanceName, $"ScriptActor:{scriptName}",
$"Script '{scriptName}' on instance '{instanceName}' started");
var state = await compiledScript.RunAsync(globals, cts.Token);
// Send result to requester if this was an Ask-based call
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null));
}
// Operational `script` event — execution completed successfully.
_ = siteEventLogger?.LogEventAsync(
"script", "Info", instanceName, $"ScriptActor:{scriptName}",
$"Script '{scriptName}' on instance '{instanceName}' completed");
// Notify parent of completion
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, true, null));
}
catch (OperationCanceledException)
{
healthCollector?.IncrementScriptError();
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
logger.LogWarning(errorMsg);
// Failures recorded to site event log; script NOT disabled after failure.
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
catch (Exception ex)
{
healthCollector?.IncrementScriptError();
// Failures recorded to site event log; script NOT disabled after failure.
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName);
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
finally
{
// Mark the body finished so the stuck-script watchdog (registered on
// cts.Token above) treats a timely cancellation as NOT stuck.
Interlocked.Exchange(ref completed, 1);
// Dispose the DI scope (and scoped services) after script execution completes
serviceScope?.Dispose();
// Stop self after execution completes
self.Tell(PoisonPill.Instance);
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler).Unwrap();
}
}
@@ -3,30 +3,106 @@ using System.Collections.Concurrent;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// A dedicated, bounded <see cref="TaskScheduler"/> for running script
/// The outcome of a <see cref="ScriptExecutionScheduler.TryDetachWorker"/> attempt.
/// </summary>
public enum WorkerDetachOutcome
{
/// <summary>
/// The recorded slot/run pair no longer identifies a running task (the script finished,
/// or it never held a worker because it hopped threads on an <c>await</c>). Nothing was
/// detached and nothing needed to be — the worker is not lost.
/// </summary>
NotRunning,
/// <summary>The worker was marked detached and a replacement thread was started.</summary>
Detached,
/// <summary>
/// The number of live detached threads already equals the pool size, so no replacement was
/// started. Bounded starvation is preferable to unbounded thread growth when scripts wedge
/// en masse; the caller is expected to log this loudly.
/// </summary>
AtCap
}
/// <summary>
/// A dedicated, grow-only <see cref="TaskScheduler"/> for running script
/// and alarm on-trigger bodies.
///
/// Script bodies may perform synchronous blocking I/O (a database connection, a
/// synchronous external-system call). Running them on the shared .NET
/// <see cref="ThreadPool"/> lets a burst of blocking scripts starve the pool and stall
/// unrelated Akka dispatchers and HTTP request handling. This scheduler owns a fixed set
/// unrelated Akka dispatchers and HTTP request handling. This scheduler owns a set
/// of dedicated threads, so script blocking is contained to those threads and cannot
/// exhaust the global pool.
///
/// <para>WP3.1 changed three things about that pool:</para>
/// <list type="number">
/// <item>It is no longer fixed-size. <see cref="EnsureCapacity"/> grows it towards
/// <see cref="ComputeTargetThreads"/> (instance-scaled, clamped between the configured
/// floor and ceiling). It is deliberately <em>grow-only</em>: undeploying instances
/// leaves idle threads, which cost nothing measurable and avoid drain/steal complexity.</item>
/// <item>A worker whose task has wedged in uninterruptible blocking I/O can be
/// <see cref="TryDetachWorker">detached</see> and replaced, so a stuck script no longer
/// permanently costs the pool a thread. Live detached threads are capped at the pool size.</item>
/// <item>Trigger-expression evaluations no longer run here at all — they are non-blocking
/// by construction and now run on the shared thread pool behind
/// <see cref="TriggerEvalGate"/>, so an alarm's Expression trigger can never queue behind
/// eight blocking script bodies (finding #4).</item>
/// </list>
///
/// The scheduler is process-wide (one set of threads for all instances) and is sized
/// from <see cref="SiteRuntimeOptions"/> the first time it is configured.
/// </summary>
public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
{
/// <summary>
/// Deployed instances per script-execution thread used by <see cref="ComputeTargetThreads"/>.
/// A named constant rather than an option: no deployment needs to tune the ratio
/// independently of the floor (<see cref="SiteRuntimeOptions.ScriptExecutionThreadCount"/>)
/// and the ceiling (<see cref="SiteRuntimeOptions.ScriptExecutionMaxThreadCount"/>).
/// </summary>
internal const int InstancesPerScriptThread = 8;
/// <summary>Thread-name prefix; also the inlining guard in <see cref="TryExecuteTaskInline"/>.</summary>
private const string ThreadNamePrefix = "script-execution-";
private readonly BlockingCollection<Task> _queue = new();
private readonly List<Thread> _threads;
/// <summary>
/// Immutable-on-read snapshot array of worker slots. Growth (EnsureCapacity, detach
/// replacement) publishes a NEW longer array under <see cref="_growLock"/>; existing
/// slot objects are carried over by reference so a worker's index stays stable for its
/// whole life. Readers take one volatile read and then work on that snapshot.
/// </summary>
private volatile WorkerSlot[] _slots;
/// <summary>Guards every mutation of <see cref="_slots"/>, <see cref="_configuredCount"/>, and the detach bookkeeping.</summary>
private readonly object _growLock = new();
/// <summary>
/// Number of NON-detached workers — i.e. the pool's nominal size. Unchanged by a
/// detach (each detach starts a replacement), grown only by <see cref="EnsureCapacity"/>.
/// </summary>
private int _configuredCount;
/// <summary>Detached workers that have not yet finished their wedged task and exited.</summary>
private int _detachedLive;
/// <summary>Monotonic run identity; see <see cref="WorkerSlot.RunStamp"/>.</summary>
private long _runStampSeed;
private int _disposed;
// Per-worker "busy since" timestamp (Environment.TickCount64 ms) while a task
// is executing on that worker, 0 when idle. Written by the worker thread,
// read (lock-free) by the observability gauges below. S2/UA5: makes a
// saturated or stuck script-execution pool visible on the site health report.
private readonly long[] _busySinceTicks;
/// <summary>
/// The slot index of the script-execution worker running the current thread, or null on
/// any other thread. Captured by the first synchronous segment of a script body so the
/// stuck-script watchdog can identify — and replace — the exact worker a wedged script
/// is holding. A script that hopped threads on an <c>await</c> is no longer on its worker,
/// which is correct: it holds no thread and must not cause a detach.
/// </summary>
[ThreadStatic]
internal static int? CurrentWorkerSlot;
private static volatile ScriptExecutionScheduler? _shared;
private static readonly object SharedLock = new();
@@ -34,8 +110,9 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
/// <summary>
/// The process-wide script-execution scheduler, used as the default when no scheduler
/// is injected. Lazily created on first use with the thread count from
/// <see cref="SiteRuntimeOptions.ScriptExecutionThreadCount"/>; the first caller wins,
/// subsequent calls reuse the existing instance.
/// <see cref="SiteRuntimeOptions.ScriptExecutionThreadCount"/> (the floor — the Deployment
/// Manager grows it from there as instances deploy); the first caller wins, subsequent
/// calls reuse the existing instance.
///
/// If the cached instance has been disposed it is recreated rather than handed back:
/// a disposed scheduler can execute no work, so returning it would silently poison
@@ -59,27 +136,40 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
}
}
/// <summary>
/// Pure sizing function for the blocking script-execution pool: the configured floor,
/// raised to one thread per <see cref="InstancesPerScriptThread"/> enabled instances,
/// clamped to the configured ceiling. Static and side-effect-free so the policy is
/// unit-testable without starting a single thread.
/// </summary>
/// <param name="enabledInstances">Number of currently-running (enabled) Instance Actors.</param>
/// <param name="options">Site runtime options supplying the floor and ceiling.</param>
/// <returns>The target worker-thread count, always at least 1.</returns>
public static int ComputeTargetThreads(int enabledInstances, SiteRuntimeOptions options)
{
var floor = Math.Max(1, options.ScriptExecutionThreadCount);
// A ceiling below the floor is rejected by SiteRuntimeOptionsValidator; clamp here
// too so a directly-constructed options object can never invert the range.
var ceiling = Math.Max(floor, options.ScriptExecutionMaxThreadCount);
var scaled = enabledInstances <= 0
? 0
: (int)Math.Ceiling(enabledInstances / (double)InstancesPerScriptThread);
return Math.Clamp(Math.Max(floor, scaled), 1, ceiling);
}
/// <summary>
/// Creates a scheduler backed by <paramref name="threadCount"/> dedicated threads.
/// </summary>
/// <param name="threadCount">Number of dedicated worker threads to create.</param>
/// <param name="threadCount">Initial number of dedicated worker threads to create.</param>
public ScriptExecutionScheduler(int threadCount)
{
if (threadCount < 1)
threadCount = 1;
_busySinceTicks = new long[threadCount];
_threads = new List<Thread>(threadCount);
for (var i = 0; i < threadCount; i++)
_slots = [];
lock (_growLock)
{
var index = i; // capture per-worker slot index
var thread = new Thread(() => WorkerLoop(index))
{
IsBackground = true,
Name = $"script-execution-{i}"
};
_threads.Add(thread);
thread.Start();
GrowTo(threadCount);
}
}
@@ -88,19 +178,28 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
public bool IsDisposed => Volatile.Read(ref _disposed) != 0;
/// <inheritdoc />
public override int MaximumConcurrencyLevel => _threads.Count;
public override int MaximumConcurrencyLevel => Volatile.Read(ref _configuredCount);
/// <summary>Number of tasks waiting in the queue (not counting those currently executing).</summary>
public int QueueDepth => _queue.Count;
/// <summary>Number of worker threads currently executing a task.</summary>
/// <summary>
/// Workers whose task wedged past its deadline plus the stuck-script grace, which have been
/// detached and replaced but have not yet returned. Surfaced on the site health report as
/// <c>DetachedScriptThreads</c>: a non-zero, non-draining value means script bodies are
/// permanently blocking threads.
/// </summary>
public int DetachedThreadCount => Volatile.Read(ref _detachedLive);
/// <summary>Number of worker threads currently executing a task (detached workers included — they really are busy).</summary>
public int BusyThreadCount
{
get
{
var slots = _slots;
var count = 0;
for (var i = 0; i < _busySinceTicks.Length; i++)
if (Volatile.Read(ref _busySinceTicks[i]) != 0) count++;
foreach (var slot in slots)
if (Volatile.Read(ref slot.BusySinceTicks) != 0) count++;
return count;
}
}
@@ -115,10 +214,11 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
get
{
var now = Environment.TickCount64;
var slots = _slots;
long oldestSince = 0;
for (var i = 0; i < _busySinceTicks.Length; i++)
foreach (var slot in slots)
{
var since = Volatile.Read(ref _busySinceTicks[i]);
var since = Volatile.Read(ref slot.BusySinceTicks);
if (since != 0 && (oldestSince == 0 || since < oldestSince))
oldestSince = since;
}
@@ -126,20 +226,138 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
}
}
/// <summary>
/// Grows the pool to <paramref name="target"/> non-detached workers. Idempotent and
/// never shrinking: a target at or below the current size is a no-op. Called from
/// <c>DeploymentManagerActor.UpdateInstanceCounts</c> on every deploy/undeploy/enable/
/// disable and per staggered startup batch.
/// </summary>
/// <param name="target">Desired number of non-detached worker threads.</param>
/// <returns>The pool's non-detached worker count after the call.</returns>
public int EnsureCapacity(int target)
{
if (IsDisposed) return Volatile.Read(ref _configuredCount);
if (target <= Volatile.Read(ref _configuredCount)) return Volatile.Read(ref _configuredCount);
lock (_growLock)
{
if (IsDisposed || target <= _configuredCount) return _configuredCount;
GrowTo(target - _configuredCount);
return _configuredCount;
}
}
/// <summary>
/// The identity stamp of the task currently running on <paramref name="slot"/>, or 0 when
/// that worker is idle. Captured alongside the slot index at script-body start and handed
/// back to <see cref="TryDetachWorker"/>, which detaches only if the SAME task is still
/// running. A monotonic counter rather than a timestamp: two runs on one worker inside the
/// same <see cref="Environment.TickCount64"/> tick would otherwise be indistinguishable.
/// </summary>
/// <param name="slot">The worker slot index.</param>
/// <returns>The current run stamp, or 0 when the slot is idle or out of range.</returns>
internal long CurrentRunStamp(int slot)
{
var slots = _slots;
return slot >= 0 && slot < slots.Length ? Volatile.Read(ref slots[slot].RunStamp) : 0L;
}
/// <summary>
/// Detaches the worker at <paramref name="slot"/> — if it is still running the task
/// identified by <paramref name="observedRunStamp"/> — and starts a replacement thread, so
/// a script wedged in uninterruptible blocking I/O no longer costs the pool a thread
/// permanently. The detached worker exits (instead of pulling more work) as soon as its
/// wedged task finally returns, so capacity never silently doubles-and-drains.
/// </summary>
/// <param name="slot">The worker slot recorded when the script body started.</param>
/// <param name="observedRunStamp">The run stamp recorded at the same moment.</param>
/// <returns>What was done; see <see cref="WorkerDetachOutcome"/>.</returns>
internal WorkerDetachOutcome TryDetachWorker(int slot, long observedRunStamp)
{
if (observedRunStamp == 0 || IsDisposed) return WorkerDetachOutcome.NotRunning;
lock (_growLock)
{
if (IsDisposed) return WorkerDetachOutcome.NotRunning;
var slots = _slots;
if (slot < 0 || slot >= slots.Length) return WorkerDetachOutcome.NotRunning;
var worker = slots[slot];
// Same task still running on that exact worker? If the stamp moved (or went to 0)
// the script finished or never held this thread — there is nothing lost to replace.
if (Volatile.Read(ref worker.RunStamp) != observedRunStamp) return WorkerDetachOutcome.NotRunning;
if (Volatile.Read(ref worker.Detached) != 0) return WorkerDetachOutcome.NotRunning;
// Bound: never hold more than 2x threads (N wedged + N live).
if (_detachedLive >= _configuredCount) return WorkerDetachOutcome.AtCap;
Volatile.Write(ref worker.Detached, 1);
_detachedLive++;
_configuredCount--; // the detached worker no longer counts towards the pool …
GrowTo(1); // … and GrowTo puts the count back by starting its replacement.
return WorkerDetachOutcome.Detached;
}
}
/// <summary>Appends <paramref name="count"/> new worker slots + threads. Caller holds <see cref="_growLock"/>.</summary>
private void GrowTo(int count)
{
var existing = _slots;
var grown = new WorkerSlot[existing.Length + count];
Array.Copy(existing, grown, existing.Length);
for (var i = 0; i < count; i++)
{
var index = existing.Length + i;
grown[index] = new WorkerSlot();
}
// Publish the array BEFORE starting the threads: a worker's very first action is to
// index its own slot, which must already be visible on the published snapshot.
_slots = grown;
_configuredCount += count;
for (var i = 0; i < count; i++)
{
var index = existing.Length + i;
var thread = new Thread(() => WorkerLoop(index))
{
IsBackground = true,
Name = ThreadNamePrefix + index
};
grown[index].Thread = thread;
thread.Start();
}
}
private void WorkerLoop(int index)
{
CurrentWorkerSlot = index;
var slot = _slots[index];
try
{
foreach (var task in _queue.GetConsumingEnumerable())
{
Volatile.Write(ref _busySinceTicks[index], Environment.TickCount64);
Volatile.Write(ref slot.RunStamp, Interlocked.Increment(ref _runStampSeed));
Volatile.Write(ref slot.BusySinceTicks, Environment.TickCount64);
try
{
TryExecuteTask(task);
}
finally
{
Volatile.Write(ref _busySinceTicks[index], 0);
Volatile.Write(ref slot.BusySinceTicks, 0);
Volatile.Write(ref slot.RunStamp, 0);
}
// Detached while this task ran: a replacement worker is already live, so
// exit rather than pulling more work — otherwise capacity would silently
// double once the wedged script finally returned.
if (Volatile.Read(ref slot.Detached) != 0)
{
Interlocked.Decrement(ref _detachedLive);
return;
}
}
}
@@ -147,6 +365,10 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
{
// Scheduler disposed — worker exits.
}
finally
{
CurrentWorkerSlot = null;
}
}
/// <inheritdoc />
@@ -157,7 +379,7 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
{
// Only inline if we are already on one of this scheduler's worker threads,
// so script work never escapes onto a thread-pool thread.
if (Thread.CurrentThread.Name?.StartsWith("script-execution-", StringComparison.Ordinal) != true)
if (Thread.CurrentThread.Name?.StartsWith(ThreadNamePrefix, StringComparison.Ordinal) != true)
return false;
return TryExecuteTask(task);
@@ -173,8 +395,35 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
return;
_queue.CompleteAdding();
foreach (var thread in _threads)
thread.Join(TimeSpan.FromSeconds(5));
foreach (var slot in _slots)
slot.Thread?.Join(TimeSpan.FromSeconds(5));
_queue.Dispose();
}
/// <summary>
/// Per-worker bookkeeping. One instance per slot index, created once and carried by
/// reference across every <see cref="_slots"/> growth so a worker's state survives
/// the array being replaced.
/// </summary>
private sealed class WorkerSlot
{
/// <summary>
/// <see cref="Environment.TickCount64"/> at which the worker picked up its current
/// task, 0 when idle. Written by the worker, read lock-free by the gauges.
/// </summary>
public long BusySinceTicks;
/// <summary>
/// Monotonic identity of the task currently running on this worker, 0 when idle.
/// Distinguishes two runs that start inside the same clock tick, which
/// <see cref="BusySinceTicks"/> alone cannot.
/// </summary>
public long RunStamp;
/// <summary>Non-zero once the worker has been detached; it exits after its current task.</summary>
public int Detached;
/// <summary>The worker thread, for <see cref="Dispose"/> to join.</summary>
public Thread? Thread;
}
}
@@ -0,0 +1,570 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.1: launches one script or alarm on-trigger run directly from its owning coordinator
/// (<see cref="ScriptActor"/> / <see cref="AlarmActor"/>), replacing the short-lived
/// <c>ScriptExecutionActor</c> and <c>AlarmExecutionActor</c>.
///
/// <para>Those actors were already inert shells: neither declared a single <c>Receive</c>
/// handler (they executed from their constructor), neither had a <c>PostStop</c>, state, or
/// stash, and their <c>IActorRef</c> was never a message target — the whole lifecycle lived
/// inside a detached <see cref="Task"/> the actor never observed. What they cost was a real
/// actor cell, mailbox, and name registration per run, plus a per-spawn expression-tree
/// <c>Props.Create</c>. Removing them changes no semantics; the run body below is the former
/// <c>ExecuteScript</c>/<c>ExecuteAlarmScript</c> body, unified.</para>
///
/// <para>Two behaviours DID change, both deliberately:</para>
/// <list type="number">
/// <item>The deadline <see cref="CancellationTokenSource"/> is now armed by the CALLER,
/// before the body is queued to the <see cref="ScriptExecutionScheduler"/>, so queue wait
/// consumes the script's own budget. A body that dequeues past its deadline skips execution
/// entirely and takes the timeout path — a saturated pool sheds stale work instead of
/// running it late.</item>
/// <item>The stuck-script watchdog now DETACHES and replaces the worker thread a wedged
/// script is holding (<see cref="ScriptExecutionScheduler.TryDetachWorker"/>) instead of
/// only naming it, so the pool recovers its capacity.</item>
/// </list>
///
/// <para>Ordering, supervision, telemetry, DI scoping, Ask replies, completion messages, and
/// the audit <c>ExecutionId</c>/<c>ParentExecutionId</c> threading are all unchanged. The
/// launch call itself can throw only if the target scheduler is unusable (e.g. disposed); the
/// caller wraps it so that failure replies to the Ask caller and decrements the in-flight
/// counter rather than escalating to the coordinator's supervisor.</para>
/// </summary>
internal static class ScriptRunLauncher
{
/// <summary>
/// Launches an instance script run. Returns as soon as the body is queued; the run reports
/// completion to <paramref name="completionTarget"/>.
/// </summary>
/// <param name="scriptName">Name of the script being executed.</param>
/// <param name="instanceName">Name of the instance that owns the script.</param>
/// <param name="compiledScript">Compiled Roslyn script to execute.</param>
/// <param name="parameters">Optional named parameter values for the script.</param>
/// <param name="callDepth">Current call-nesting depth (used to enforce the max-depth limit).</param>
/// <param name="instanceActor">Instance actor reference for attribute access.</param>
/// <param name="sharedScriptLibrary">Library of shared scripts available during execution.</param>
/// <param name="options">Site runtime options applied during execution.</param>
/// <param name="replyTo">Actor reference that receives the script result; <c>Nobody</c> for fire-and-forget.</param>
/// <param name="correlationId">Application-level correlation id threaded through the execution.</param>
/// <param name="completionTarget">The owning <see cref="ScriptActor"/>, which receives the completion message.</param>
/// <param name="siteCommunicationActor">Site communication actor (resolved on the actor thread) for <c>Notify.Status</c>.</param>
/// <param name="logger">Logger for script execution events.</param>
/// <param name="scope">Script scope controlling which APIs are available.</param>
/// <param name="runId">Per-script run counter, used only in log messages (replaces the former per-run actor name).</param>
/// <param name="healthCollector">Optional health collector for recording execution metrics.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
/// <param name="parentExecutionId">ExecutionId of the spawning execution for audit correlation; null for root runs.</param>
/// <param name="executionTimeoutSeconds">Per-script execution timeout in seconds. Null or non-positive falls back to the global value.</param>
/// <param name="scheduler">Script-execution scheduler seam (#18); null selects the process-wide shared instance.</param>
public static void LaunchScript(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
IActorRef completionTarget,
ICanTell? siteCommunicationActor,
ILogger logger,
ScriptScope scope,
long runId,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider,
Guid? parentExecutionId,
int? executionTimeoutSeconds,
ScriptExecutionScheduler? scheduler)
{
var timeout = ResolveTimeout(executionTimeoutSeconds, options);
var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
// Armed HERE, on the caller's (actor) thread, not inside the queued body: queue wait
// must consume the script's own budget, otherwise a saturated pool silently grants
// every backlogged run a fresh full timeout.
var cts = new CancellationTokenSource(timeout);
try
{
_ = Task.Factory.StartNew(
() => RunScriptAsync(
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
completionTarget, siteCommunicationActor, logger, scope, runId,
healthCollector, serviceProvider, parentExecutionId, timeout,
executionScheduler, cts),
CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler)
.Unwrap();
}
catch
{
// The body never got queued, so nothing will ever dispose the CTS.
cts.Dispose();
throw;
}
}
/// <summary>
/// Launches an alarm on-trigger run. Same contract as <see cref="LaunchScript"/>, with the
/// firing alarm's level/priority/message exposed to the body through the <c>Alarm</c> global.
/// </summary>
/// <param name="alarmName">The canonical name of the alarm that triggered.</param>
/// <param name="instanceName">The name of the owning instance.</param>
/// <param name="level">The alarm severity level at the time of triggering.</param>
/// <param name="priority">The alarm priority value.</param>
/// <param name="message">The alarm message to pass to the script.</param>
/// <param name="compiledScript">The pre-compiled on-trigger script to execute.</param>
/// <param name="instanceActor">Reference to the instance actor for attribute/script calls.</param>
/// <param name="sharedScriptLibrary">Shared script library providing common utilities.</param>
/// <param name="options">Site runtime configuration options, including the execution timeout.</param>
/// <param name="completionTarget">The owning <see cref="AlarmActor"/>, which receives the completion message.</param>
/// <param name="logger">Logger for execution diagnostics.</param>
/// <param name="runId">Per-alarm run counter, used only in log messages.</param>
/// <param name="executionTimeoutSeconds">The on-trigger script's per-script timeout in seconds. Null or non-positive falls back to the global value.</param>
/// <param name="parentExecutionId">
/// ParentExecutionId tag-cascade: the <c>ExecutionId</c> of the execution whose attribute
/// write fired this alarm. Null when the firing value came from the Data Connection Layer
/// (external data has no spawning execution) — that on-trigger run is a tree ROOT.
/// </param>
/// <param name="scheduler">Script-execution scheduler seam (#18); null selects the process-wide shared instance.</param>
public static void LaunchAlarmScript(
string alarmName,
string instanceName,
AlarmLevel level,
int priority,
string message,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef completionTarget,
ILogger logger,
long runId,
int? executionTimeoutSeconds,
Guid? parentExecutionId,
ScriptExecutionScheduler? scheduler)
{
var timeout = ResolveTimeout(executionTimeoutSeconds, options);
var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
// Enqueue-anchored deadline; see LaunchScript.
var cts = new CancellationTokenSource(timeout);
try
{
_ = Task.Factory.StartNew(
() => RunAlarmScriptAsync(
alarmName, instanceName, level, priority, message, compiledScript,
instanceActor, sharedScriptLibrary, options, completionTarget, logger,
runId, parentExecutionId, timeout, cts),
CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler)
.Unwrap();
}
catch
{
cts.Dispose();
throw;
}
}
/// <summary>
/// Per-script timeout overrides the global default. A null or non-positive per-script
/// value (≤ 0) falls back to the global.
/// </summary>
private static TimeSpan ResolveTimeout(int? executionTimeoutSeconds, SiteRuntimeOptions options)
=> TimeSpan.FromSeconds(
executionTimeoutSeconds is { } perScript && perScript > 0
? perScript
: options.ScriptExecutionTimeoutSeconds);
private static async Task RunScriptAsync(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
IActorRef completionTarget,
ICanTell? siteCommunicationActor,
ILogger logger,
ScriptScope scope,
long runId,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider,
Guid? parentExecutionId,
TimeSpan timeout,
ScriptExecutionScheduler executionScheduler,
CancellationTokenSource cts)
{
IServiceScope? serviceScope = null;
// ISiteEventLogger is a singleton; resolve from the root provider so
// it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
// WP3.1: identify the worker this body is holding, so the watchdog can detach and
// replace it if the body wedges. Read in the FIRST synchronous segment — a script
// that later hops threads on an await no longer holds this worker, and the run-stamp
// guard in TryDetachWorker will (correctly) refuse to detach it.
var workerSlot = ScriptExecutionScheduler.CurrentWorkerSlot;
var workerRunStamp = workerSlot is { } slot ? executionScheduler.CurrentRunStamp(slot) : 0L;
var completed = 0;
ArmStuckScriptWatchdog(
cts, options, logger, siteEventLogger, executionScheduler,
workerSlot, workerRunStamp, timeout,
() => Volatile.Read(ref completed) != 0,
$"Script '{scriptName}' on instance '{instanceName}'",
instanceName, $"ScriptActor:{scriptName}");
try
{
// WP3.1 shed-at-dequeue: the deadline was armed at ENQUEUE, so an already-cancelled
// token here means this run spent its entire budget queueing. Running it now would
// burn a scarce script thread on work that is already stale — throw straight into
// the existing timeout path instead, BEFORE the "started" event and before the body.
cts.Token.ThrowIfCancellationRequested();
// Resolve integration services from DI (scoped lifetime)
IExternalSystemClient? externalSystemClient = null;
IDatabaseGateway? databaseGateway = null;
// Notification Outbox: the S&F engine is a singleton; the site identity
// provider supplies the site id stamped on enqueued notifications.
StoreAndForwardService? storeAndForward = null;
var siteId = string.Empty;
// The writer is a singleton (FallbackAuditWriter
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
// that haven't called AddAuditLog, which the helper handles as a no-op.
IAuditWriter? auditWriter = null;
// Site-local tracking store
// backing Tracking.Status(id). Singleton; null in tests / hosts
// that haven't wired the store, which the helper handles by
// throwing on access.
IOperationTrackingStore? operationTrackingStore = null;
// Site-side cached-call
// telemetry forwarder. Singleton bound to the AuditLog
// composition root; null in tests / hosts that haven't called
// AddAuditLog, in which case the cached-call helpers degrade
// to the no-emission path (the underlying S&F handoff still
// happens and a TrackedOperationId is still returned).
ICachedCallTelemetryForwarder? cachedForwarder = null;
// SourceNode-stamping: the local node name
// resolved from INodeIdentityProvider — node-a/node-b on site
// hosts. Null in tests / hosts that haven't registered the
// provider, in which case NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode stay null and central
// persists the rows with SourceNode NULL.
string? sourceNode = null;
if (serviceProvider != null)
{
serviceScope = serviceProvider.CreateScope();
externalSystemClient = serviceScope.ServiceProvider.GetService<IExternalSystemClient>();
databaseGateway = serviceScope.ServiceProvider.GetService<IDatabaseGateway>();
storeAndForward = serviceScope.ServiceProvider.GetService<StoreAndForwardService>();
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
?? string.Empty;
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
}
var context = new ScriptRuntimeContext(
instanceActor,
sharedScriptLibrary,
callDepth,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger,
externalSystemClient,
databaseGateway,
storeAndForward,
siteCommunicationActor,
siteId,
// Notification Outbox (FU3): stamp the executing script onto outbound
// notifications using the Site Event Logging "Source" convention.
sourceScript: $"ScriptActor:{scriptName}",
// Emit one ApiOutbound/ApiCall row per
// ExternalSystem.Call. Writer is best-effort; failures are logged
// and swallowed inside the helper so the script's call path is
// never aborted by an audit failure.
auditWriter: auditWriter,
// Site-local tracking store
// backing Tracking.Status(id). Authoritative source of truth for
// cached-call status — read directly by the script API.
operationTrackingStore: operationTrackingStore,
// Cached-call telemetry
// forwarder for ExternalSystem.CachedCall / Database.CachedWrite
// CachedSubmit emission + the immediate-success terminal-row
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
cachedForwarder: cachedForwarder,
// The spawning execution's
// id for an inbound-API-routed call. The routed script still
// mints its own fresh ExecutionId — this records the spawner.
// Null for normal (tag-change / timer) runs.
parentExecutionId: parentExecutionId,
// SourceNode-stamping: the local node name
// (node-a/node-b on a site) — threaded down so Notify.Send
// and the four cached-call telemetry constructors can stamp
// it onto NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode respectively.
sourceNode: sourceNode,
// Thread the singleton site event logger so
// recursion-limit violations at CallScript/CallShared emit a
// script Error site event in addition to ILogger.LogError.
siteEventLogger: siteEventLogger,
// WaitForAttribute (spec §4.3/§4.4): thread the per-script
// execution-timeout token so Attributes.WaitAsync's Ask is
// bounded by the script's own ExecutionTimeoutSeconds — a
// shorter script deadline wins over the wait's own timeout.
scriptTimeoutToken: cts.Token);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
CancellationToken = cts.Token,
Scope = scope
};
// Operational `script` event — execution started. Fire-and-forget
// (the `_ =` discards the task) so the event log can never block or
// fault the script's own run; mirrors the existing Error-path emit.
_ = siteEventLogger?.LogEventAsync(
"script", "Info", instanceName, $"ScriptActor:{scriptName}",
$"Script '{scriptName}' on instance '{instanceName}' started");
var state = await compiledScript.RunAsync(globals, cts.Token);
// Send result to requester if this was an Ask-based call
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null));
}
// Operational `script` event — execution completed successfully.
_ = siteEventLogger?.LogEventAsync(
"script", "Info", instanceName, $"ScriptActor:{scriptName}",
$"Script '{scriptName}' on instance '{instanceName}' completed");
// Notify the owning ScriptActor of completion (also releases its in-flight slot).
completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, true, null));
}
catch (OperationCanceledException)
{
healthCollector?.IncrementScriptError();
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
logger.LogWarning("{Message} (run {RunId})", errorMsg, runId);
// Failures recorded to site event log; script NOT disabled after failure.
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
catch (Exception ex)
{
healthCollector?.IncrementScriptError();
// Failures recorded to site event log; script NOT disabled after failure.
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance} (run {RunId})",
scriptName, instanceName, runId);
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
finally
{
// Mark the body finished so the stuck-script watchdog (registered on
// cts.Token above) treats a timely cancellation as NOT stuck.
Interlocked.Exchange(ref completed, 1);
// Dispose the DI scope (and scoped services) after script execution completes
serviceScope?.Dispose();
cts.Dispose();
}
}
private static async Task RunAlarmScriptAsync(
string alarmName,
string instanceName,
AlarmLevel level,
int priority,
string message,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef completionTarget,
ILogger logger,
long runId,
Guid? parentExecutionId,
TimeSpan timeout,
CancellationTokenSource cts)
{
try
{
// Enqueue-anchored deadline: an already-cancelled token means this on-trigger run
// spent its whole budget queueing behind other script bodies. Shed it rather than
// run it late — the alarm it would react to is already stale.
cts.Token.ThrowIfCancellationRequested();
// An alarm on-trigger run can call Instance.CallScript()
// via the ScriptRuntimeContext injected into globals
var context = new ScriptRuntimeContext(
instanceActor,
sharedScriptLibrary,
currentCallDepth: 0,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger,
// ParentExecutionId tag-cascade: the
// alarm on-trigger run mints its own fresh ExecutionId (the
// ctor's `?? NewGuid()` fallback) and records the firing
// execution's id as its ParentExecutionId — null (a root)
// only when the firing value came from the DCL.
parentExecutionId: parentExecutionId,
// WaitForAttribute (spec §4.4): thread the alarm on-trigger
// script's per-script execution-timeout token so a
// Attributes.WaitAsync inside an on-trigger script is bounded
// by the same script deadline.
scriptTimeoutToken: cts.Token);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new ScriptParameters(),
CancellationToken = cts.Token,
Alarm = new AlarmContext
{
Name = alarmName,
Level = level,
Priority = priority,
Message = message
}
};
await compiledScript.RunAsync(globals, cts.Token);
completionTarget.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, true));
}
catch (OperationCanceledException)
{
logger.LogWarning(
"Alarm on-trigger script for {Alarm} on {Instance} timed out (run {RunId})",
alarmName, instanceName, runId);
completionTarget.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
catch (Exception ex)
{
// Failures logged, alarm continues
logger.LogError(ex,
"Alarm on-trigger script for {Alarm} on {Instance} failed (run {RunId})",
alarmName, instanceName, runId);
completionTarget.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
finally
{
cts.Dispose();
}
}
/// <summary>
/// Arms the stuck-script watchdog (S2, extended by WP3.1).
///
/// <para>The CTS firing only REQUESTS cooperative cancellation; it does NOT free a thread
/// blocked in synchronous I/O. When the deadline elapses, this waits
/// <see cref="SiteRuntimeOptions.StuckScriptGraceMs"/> and, if the body still has not
/// returned, (a) names the script loudly on the site event log and (b) DETACHES the worker
/// thread it is holding and starts a replacement — so a wedged script costs the pool one
/// thread only until the watchdog fires, not forever. At the detach cap it logs an Error
/// instead of growing threads without bound.</para>
///
/// <para>The <c>Task.Run</c>/<c>Task.Delay</c> run on the shared thread pool, never on the
/// (possibly saturated) script scheduler — deliberate, and the whole point of the watchdog.
/// <c>Register</c> fires on cancellation only; a normally-completing run disposes its CTS
/// with no callback. A run cancelled while still QUEUED flips its completed flag in the
/// body's <c>finally</c> long before the grace elapses, so it is correctly not reported.</para>
/// </summary>
private static void ArmStuckScriptWatchdog(
CancellationTokenSource cts,
SiteRuntimeOptions options,
ILogger logger,
ISiteEventLogger? siteEventLogger,
ScriptExecutionScheduler scheduler,
int? workerSlot,
long workerRunStamp,
TimeSpan timeout,
Func<bool> isCompleted,
string subject,
string instanceName,
string source)
{
var graceMs = options.StuckScriptGraceMs;
cts.Token.Register(() => _ = Task.Run(async () =>
{
await Task.Delay(graceMs);
if (isCompleted()) return;
var stuckMsg = $"{subject} exceeded its {timeout.TotalSeconds:F0}s timeout and is STILL EXECUTING — " +
"its dedicated script-execution thread is blocked (cooperative cancellation not observed).";
var outcome = workerSlot is { } slot
? scheduler.TryDetachWorker(slot, workerRunStamp)
: WorkerDetachOutcome.NotRunning;
switch (outcome)
{
case WorkerDetachOutcome.Detached:
stuckMsg += " The worker thread has been DETACHED and replaced; it will exit when the body returns.";
break;
case WorkerDetachOutcome.AtCap:
stuckMsg += $" The script-execution pool already holds {scheduler.DetachedThreadCount} detached " +
"stuck threads (at cap); NOT replacing this one.";
break;
case WorkerDetachOutcome.NotRunning:
// The body is not on a worker thread (it hopped threads on an await), so no
// dedicated thread is lost — report it, but there is nothing to replace.
break;
}
logger.LogError(stuckMsg);
_ = siteEventLogger?.LogEventAsync("script", "Error", instanceName, source, stuckMsg);
}));
}
}
@@ -40,7 +40,6 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
public class ScriptRuntimeContext
{
private readonly IActorRef _instanceActor;
private readonly IActorRef _self;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly int _currentCallDepth;
private readonly int _maxCallDepth;
@@ -50,7 +49,7 @@ public class ScriptRuntimeContext
/// <summary>
/// WaitForAttribute (spec §4.3): the per-script execution-timeout token from
/// the owning <c>ScriptExecutionActor</c>/<c>AlarmExecutionActor</c>
/// the owning script or alarm on-trigger run
/// (<c>cts.Token</c>). Bounds the <c>Attributes.WaitAsync</c> Ask so a script
/// that hits its own <c>ExecutionTimeoutSeconds</c> abandons the wait. Defaults
/// to <see cref="CancellationToken.None"/> for contexts that do not thread one
@@ -170,7 +169,6 @@ public class ScriptRuntimeContext
/// execution, external system calls, database access, and notification delivery.
/// </summary>
/// <param name="instanceActor">Reference to the Instance Actor managing this instance's state.</param>
/// <param name="self">Reference to the executing script actor.</param>
/// <param name="sharedScriptLibrary">Library containing shared scripts available to all instances.</param>
/// <param name="currentCallDepth">Current recursion depth of script calls.</param>
/// <param name="maxCallDepth">Maximum allowed recursion depth before an error is thrown.</param>
@@ -217,7 +215,6 @@ public class ScriptRuntimeContext
/// </param>
public ScriptRuntimeContext(
IActorRef instanceActor,
IActorRef self,
SharedScriptLibrary sharedScriptLibrary,
int currentCallDepth,
int maxCallDepth,
@@ -240,7 +237,6 @@ public class ScriptRuntimeContext
CancellationToken scriptTimeoutToken = default)
{
_instanceActor = instanceActor;
_self = self;
_sharedScriptLibrary = sharedScriptLibrary;
_currentCallDepth = currentCallDepth;
_maxCallDepth = maxCallDepth;
@@ -257,7 +253,7 @@ public class ScriptRuntimeContext
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
// SourceNode-stamping: the local node name read from
// INodeIdentityProvider at the ScriptExecutionActor; null when no
// INodeIdentityProvider at the launching script run; null when no
// provider was wired so the downstream callsites pass null through
// verbatim — leaving central SourceNode as NULL.
_sourceNode = sourceNode;
@@ -265,7 +261,7 @@ public class ScriptRuntimeContext
// (ParentExecutionId): stored verbatim — no `?? NewGuid()`
// fallback. A non-routed run legitimately has no parent and stays null.
_parentExecutionId = parentExecutionId;
// Optional — null when not wired (tests / AlarmExecutionActor).
// Optional — null when not wired (tests / alarm on-trigger runs).
_siteEventLogger = siteEventLogger;
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
@@ -302,7 +298,6 @@ public class ScriptRuntimeContext
{
return new ScriptRuntimeContext(
_instanceActor,
_self,
_sharedScriptLibrary,
childCallDepth,
_maxCallDepth,
@@ -333,7 +328,7 @@ public class ScriptRuntimeContext
/// <summary>
/// Fire-and-forget emission of a <c>script</c> Error site event
/// for a recursion-limit violation. Mirrors the call shape used by
/// <c>ScriptExecutionActor</c>'s catch blocks. A fault from
/// the run's own catch blocks. A fault from
/// the site-event logger is observed-and-dropped (best-effort) via
/// <c>ContinueWith(OnlyOnFaulted)</c> — it never blocks or faults the
/// <c>_logger.LogError</c> + throw path that follows. A null logger is a no-op.
@@ -1135,7 +1130,7 @@ public class ScriptRuntimeContext
SourceSite: _siteId,
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// at the launching script run; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: "Submitted",
@@ -1254,7 +1249,7 @@ public class ScriptRuntimeContext
SourceSite: _siteId,
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// at the launching script run; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: "Attempted",
@@ -1322,7 +1317,7 @@ public class ScriptRuntimeContext
SourceSite: _siteId,
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// at the launching script run; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: operationalTerminalStatus,
@@ -1913,7 +1908,7 @@ public class ScriptRuntimeContext
SourceSite: _siteId,
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// at the launching script run; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: "Submitted",
@@ -2260,7 +2255,7 @@ public class ScriptRuntimeContext
OriginParentExecutionId: _parentExecutionId,
// SourceNode-stamping: the cluster node name on which this
// notification was emitted (node-a/node-b). Stamped from the local
// INodeIdentityProvider via ScriptExecutionActor. Rides inside the
// INodeIdentityProvider via the launching script run. Rides inside the
// serialized payload through the S&F buffer to central, where
// NotificationOutboxActor persists it on the Notifications row.
SourceNode: _sourceNode);
@@ -80,7 +80,9 @@ public sealed class ScriptSchedulerStatsReporter : BackgroundService
_collector.SetScriptSchedulerStats(
scheduler.QueueDepth,
scheduler.BusyThreadCount,
scheduler.OldestBusyAge?.TotalSeconds);
scheduler.OldestBusyAge?.TotalSeconds,
// WP3.1: workers detached and replaced by the stuck-script watchdog.
scheduler.DetachedThreadCount);
}
catch (Exception ex)
{
@@ -26,19 +26,41 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
///
/// <para>
/// Bounded at <see cref="MaxEntries"/> entries — deliberately smaller than the verdict cache's
/// 4096 because entries pin compiled assemblies, not just verdict strings. On overflow the cache
/// is cleared wholesale (results are recomputable, so a coarse reset avoids eviction bookkeeping).
/// <see cref="Hits"/>/<see cref="Count"/>/<see cref="Clear"/> are exposed for tests and diagnostics.
/// 4096 because entries pin compiled assemblies, not just verdict strings.
/// </para>
///
/// <para><b>WP3.1 — approximate LRU replaced the overflow cliff.</b> The cache used to
/// <c>Clear()</c> wholesale on overflow. Instance scripts AND trigger expressions share this
/// cache, so on a site large enough to cross 1024 entries every overflow discarded up to 1023
/// live compiled scripts and the next deploy or Instance-Actor start paid a full recompile
/// storm — on actor threads. Now an insert at the bound evicts only the oldest
/// <see cref="EvictionBatchDivisor">⅛</see> of entries by last-access stamp: overflow costs one
/// 1024-element scan instead of 1023 future recompiles, and hot entries survive.</para>
///
/// <para>Recency is an <see cref="Interlocked"/> access sequence, not a clock: it is
/// deterministic for tests and immune to clock steps. Hits update the stamp lock-free; the
/// only lock is the (rare) eviction sweep, double-checked so concurrent inserts do not
/// stampede it.</para>
/// </summary>
internal static class SiteScriptCompileCache
{
/// <summary>Upper bound on cached entries; the cache is cleared wholesale on overflow.</summary>
/// <summary>Upper bound on cached entries; crossing it evicts the oldest batch.</summary>
internal const int MaxEntries = 1024;
private static readonly ConcurrentDictionary<string, ScriptCompilationResult> Cache = new();
/// <summary>
/// Fraction of the cache evicted in one sweep (⅛ = 128 entries at
/// <see cref="MaxEntries"/>). Batching amortises the O(n) scan across many inserts, so
/// steady-state churn does not re-scan on every single miss.
/// </summary>
private const int EvictionBatchDivisor = 8;
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new();
private static readonly object EvictionLock = new();
private static long _hits;
/// <summary>Monotonic access sequence; the recency stamp written onto entries.</summary>
private static long _accessSequence;
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
public static long Hits => Interlocked.Read(ref _hits);
@@ -48,8 +70,8 @@ internal static class SiteScriptCompileCache
/// <summary>
/// Returns the cached compile result for <paramref name="code"/> against
/// <paramref name="globalsType"/>, or computes it via <paramref name="factory"/> and caches
/// it on a miss. A hit increments <see cref="Hits"/>. Both success and failure results are
/// cached — the error text is name-free by construction.
/// it on a miss. A hit increments <see cref="Hits"/> and refreshes the entry's recency.
/// Both success and failure results are cached — the error text is name-free by construction.
/// </summary>
/// <param name="code">The script source code to look up (hashed to form the cache key).</param>
/// <param name="globalsType">The Roslyn globals surface the script compiles against; part of the key so identical source under different globals stays distinct.</param>
@@ -59,19 +81,19 @@ internal static class SiteScriptCompileCache
{
var key = globalsType.FullName + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
if (Cache.TryGetValue(key, out var result))
if (Cache.TryGetValue(key, out var entry))
{
Interlocked.Increment(ref _hits);
return result;
Touch(entry);
return entry.Result;
}
result = factory();
var result = factory();
// Coarse bound: on overflow drop everything rather than track evictions.
if (Cache.Count >= MaxEntries)
Cache.Clear();
EvictOldestBatch();
Cache[key] = result;
Cache[key] = new CacheEntry(result) { LastAccess = NextStamp() };
return result;
}
@@ -81,4 +103,53 @@ internal static class SiteScriptCompileCache
Cache.Clear();
Interlocked.Exchange(ref _hits, 0);
}
/// <summary>Refreshes an entry's recency stamp. Lock-free — a lost race only costs accuracy, never correctness.</summary>
private static void Touch(CacheEntry entry) => Volatile.Write(ref entry.LastAccess, NextStamp());
private static long NextStamp() => Interlocked.Increment(ref _accessSequence);
/// <summary>
/// Evicts the oldest <see cref="MaxEntries"/> / <see cref="EvictionBatchDivisor"/> entries
/// by recency stamp. Double-checked under <see cref="EvictionLock"/> so several concurrent
/// inserts crossing the bound together perform ONE sweep rather than one each.
/// </summary>
private static void EvictOldestBatch()
{
lock (EvictionLock)
{
if (Cache.Count < MaxEntries) return; // another thread already swept
var batch = Math.Max(1, MaxEntries / EvictionBatchDivisor);
// ConcurrentDictionary.ToArray() takes an ATOMIC snapshot. Enumerating (or
// LINQ-ing) the dictionary directly does not: LINQ's ToArray picks the
// ICollection<KeyValuePair<,>>.CopyTo fast path, which reads Count and then
// copies, and throws ArgumentException when a concurrent insert lands between
// the two. The eviction lock only excludes other EVICTORS — inserts run
// lock-free by design — so the snapshot must be the thread-safe one.
var victims = Cache.ToArray()
.OrderBy(kvp => Volatile.Read(ref kvp.Value.LastAccess))
.Take(batch)
.Select(kvp => kvp.Key)
.ToArray();
foreach (var victim in victims)
Cache.TryRemove(victim, out _);
}
}
/// <summary>
/// A cached compile result plus its recency stamp. A class (not a struct) so
/// <see cref="Touch"/> can update recency in place without replacing the dictionary
/// value — the hot path stays a single volatile write.
/// </summary>
private sealed class CacheEntry(ScriptCompilationResult result)
{
/// <summary>The memoised compile result (success or failure).</summary>
public ScriptCompilationResult Result { get; } = result;
/// <summary>Access sequence number of the most recent hit; older = evicted first.</summary>
public long LastAccess;
}
}
@@ -0,0 +1,81 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.1 (finding #4): the process-wide concurrency gate for trigger-expression evaluation.
///
/// <para>Before WP3.1, <c>ScriptActor</c> and <c>AlarmActor</c> evaluated their compiled
/// Expression triggers on the <see cref="ScriptExecutionScheduler"/> — the same bounded pool
/// of dedicated threads that runs script bodies. Eight scripts blocked in synchronous I/O
/// therefore stalled EVERY Expression trigger on the node behind them, for an unbounded time,
/// and the evaluation's own 2 s timeout did not even start until it was dequeued. An alarm
/// that should have raised in milliseconds simply never raised.</para>
///
/// <para>Trigger expressions are non-blocking <em>by construction</em>:
/// <see cref="TriggerExpressionGlobals"/> exposes only reads over an in-memory snapshot
/// dictionary, and the script trust gate has already denied I/O, network, threading, and
/// reflection long before the expression can deploy. They are short CPU-bound work — exactly
/// what the shared .NET thread pool is for. So they run there, and this semaphore is the only
/// thing bounding their fan-out. A second dedicated pool was considered and rejected: it would
/// add threads, gauges, and a second starvation surface for no isolation gain.</para>
///
/// <para>Per-trigger coalescing in the actors (one evaluation in flight, one pending) already
/// caps waiters at one per Expression trigger, so this gate's queue is bounded by trigger
/// count.</para>
///
/// Mirrors <see cref="ScriptExecutionScheduler.Shared"/>'s lazy-singleton plus injectable-seam
/// shape so tests can drive a gate of size 1 deterministically.
/// </summary>
public sealed class TriggerEvalGate : IDisposable
{
private readonly SemaphoreSlim _gate;
private static volatile TriggerEvalGate? _shared;
private static readonly object SharedLock = new();
/// <summary>Creates a gate admitting <paramref name="maxConcurrency"/> concurrent evaluations.</summary>
/// <param name="maxConcurrency">Maximum concurrent trigger-expression evaluations; values below 1 are clamped to 1.</param>
public TriggerEvalGate(int maxConcurrency)
{
MaxConcurrency = Math.Max(1, maxConcurrency);
_gate = new SemaphoreSlim(MaxConcurrency, MaxConcurrency);
}
/// <summary>The configured concurrency limit.</summary>
public int MaxConcurrency { get; }
/// <summary>Free permits right now; 0 means the gate is saturated and new evaluations will queue.</summary>
public int AvailablePermits => _gate.CurrentCount;
/// <summary>
/// The process-wide gate, used when no gate is injected. Lazily created from
/// <see cref="SiteRuntimeOptions.TriggerEvalMaxConcurrency"/>; the first caller wins.
/// </summary>
/// <param name="options">Site runtime options supplying the concurrency limit.</param>
/// <returns>The shared gate instance.</returns>
public static TriggerEvalGate Shared(SiteRuntimeOptions options)
{
var existing = _shared;
if (existing is not null) return existing;
lock (SharedLock)
{
return _shared ??= new TriggerEvalGate(options.TriggerEvalMaxConcurrency);
}
}
/// <summary>
/// Waits for a permit. The caller passes the evaluation's own deadline token, which was
/// armed at ENQUEUE — so time spent queueing here burns the same budget the evaluation
/// itself would, and a saturated gate produces a timely cancellation (treated as
/// <see langword="false"/> by both actors) instead of an unbounded stall.
/// </summary>
/// <param name="cancellationToken">The evaluation's deadline token.</param>
/// <returns>A task that completes when a permit is acquired.</returns>
public Task WaitAsync(CancellationToken cancellationToken) => _gate.WaitAsync(cancellationToken);
/// <summary>Returns a permit. Must be called exactly once per successful <see cref="WaitAsync"/>.</summary>
public void Release() => _gate.Release();
/// <inheritdoc />
public void Dispose() => _gate.Dispose();
}
@@ -38,13 +38,64 @@ public class SiteRuntimeOptions
public int StreamBufferSize { get; set; } = 1000;
/// <summary>
/// Number of dedicated threads in the script-execution scheduler.
/// FLOOR for the number of dedicated threads in the script-execution scheduler.
/// Script and alarm on-trigger bodies run on these threads instead of the shared
/// .NET thread pool, so blocking script I/O cannot starve the global pool.
///
/// <para>WP3.1: this was a fixed size and is now the lower bound of a grow-only,
/// instance-scaled pool — see
/// <see cref="Scripts.ScriptExecutionScheduler.ComputeTargetThreads"/>. Existing
/// configurations keep exactly the previous behaviour at or below
/// <c>ScriptExecutionThreadCount * InstancesPerScriptThread</c> deployed instances.</para>
///
/// Default: 8.
/// </summary>
public int ScriptExecutionThreadCount { get; set; } = 8;
/// <summary>
/// WP3.1: CEILING for the instance-scaled script-execution pool. The pool grows
/// towards <c>ceil(enabledInstances / 8)</c> threads but never past this value; beyond
/// it, <see cref="MaxConcurrentRunsPerScript"/> is the real regulator. A 1 MB-stack
/// dedicated thread is cheap, so 32 (≈ 256 instances at the /8 ratio) is a generous
/// default. Must be greater than or equal to <see cref="ScriptExecutionThreadCount"/>.
/// Default: 32.
/// </summary>
public int ScriptExecutionMaxThreadCount { get; set; } = 32;
/// <summary>
/// WP3.1 (finding #4): maximum number of trigger-expression evaluations allowed to run
/// concurrently across the whole process. Trigger expressions are non-blocking by
/// construction (<see cref="Scripts.TriggerExpressionGlobals"/> exposes only reads over an
/// in-memory snapshot, and the script trust gate has already denied I/O, network, and
/// threading), so they run as plain async work on the shared .NET thread pool behind this
/// gate rather than on the blocking script-execution pool. That is what keeps an alarm's
/// Expression trigger from queueing behind blocking script bodies.
/// Default: <c>max(2, Environment.ProcessorCount)</c>.
/// </summary>
public int TriggerEvalMaxConcurrency { get; set; } = Math.Max(2, Environment.ProcessorCount);
/// <summary>
/// WP3.1: timeout (seconds) for a single trigger-expression evaluation. Previously
/// hardcoded at 2 s in both <c>ScriptActor</c> and <c>AlarmActor</c>. The deadline is now
/// armed when the evaluation is ENQUEUED, not when it is dequeued, so time spent waiting
/// on <see cref="TriggerEvalMaxConcurrency"/> burns the same budget — a saturated gate
/// produces a timely "false" instead of an unbounded stall.
/// Default: 2.
/// </summary>
public int TriggerEvalTimeoutSeconds { get; set; } = 2;
/// <summary>
/// WP3.1: maximum number of concurrent runs (queued or executing) for any single script
/// or alarm on-trigger script. A trigger that arrives while the cap is reached is SHED —
/// the newest run is refused, the four already in flight (which are closest to their own
/// deadlines) are kept, and no reordering occurs. A shed increments
/// <c>ISiteHealthCollector.IncrementScriptRunShed</c>, emits a rate-limited Warning site
/// event, and — for an Ask-based <c>CallScript</c> — replies with an explicit error rather
/// than letting the caller hang to its Ask timeout.
/// Default: 4.
/// </summary>
public int MaxConcurrentRunsPerScript { get; set; } = 4;
/// <summary>
/// Max mirrored native alarms retained per source binding before older entries are dropped (logged).
/// Default: 1000.
@@ -39,7 +39,28 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase<SiteRunti
builder.RequireThat(options.ScriptExecutionThreadCount > 0,
$"ScadaBridge:SiteRuntime:ScriptExecutionThreadCount must be greater than 0 " +
$"(was {options.ScriptExecutionThreadCount}); it sizes the dedicated script-execution scheduler.");
$"(was {options.ScriptExecutionThreadCount}); it is the FLOOR for the dedicated script-execution scheduler.");
builder.RequireThat(options.ScriptExecutionMaxThreadCount >= options.ScriptExecutionThreadCount,
$"ScadaBridge:SiteRuntime:ScriptExecutionMaxThreadCount must be >= ScriptExecutionThreadCount " +
$"(was {options.ScriptExecutionMaxThreadCount} vs {options.ScriptExecutionThreadCount}); it is the " +
"CEILING for the instance-scaled script-execution pool and a ceiling below the floor would " +
"silently shrink the configured pool.");
builder.RequireThat(options.TriggerEvalMaxConcurrency > 0,
$"ScadaBridge:SiteRuntime:TriggerEvalMaxConcurrency must be greater than 0 " +
$"(was {options.TriggerEvalMaxConcurrency}); it gates concurrent trigger-expression evaluations " +
"and a zero gate would park every Expression trigger on the node forever.");
builder.RequireThat(options.TriggerEvalTimeoutSeconds > 0,
$"ScadaBridge:SiteRuntime:TriggerEvalTimeoutSeconds must be greater than 0 " +
$"(was {options.TriggerEvalTimeoutSeconds}); it bounds a single trigger-expression evaluation " +
"(measured from enqueue, so it also bounds gate-wait time).");
builder.RequireThat(options.MaxConcurrentRunsPerScript > 0,
$"ScadaBridge:SiteRuntime:MaxConcurrentRunsPerScript must be greater than 0 " +
$"(was {options.MaxConcurrentRunsPerScript}); it caps concurrent runs per script and a zero cap " +
"would shed every trigger.");
builder.RequireThat(options.MirroredAlarmCapPerSource > 0,
$"ScadaBridge:SiteRuntime:MirroredAlarmCapPerSource must be greater than 0 " +
@@ -254,7 +254,6 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
@@ -594,7 +594,6 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
// the routed script execution gets its OWN fresh ExecutionId, and the
// inbound request's ExecutionId arrives as ParentExecutionId.
var routedContext = new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
@@ -154,7 +154,6 @@ public class IntegrationSurfaceTests
Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteRuntime.Scripts.SharedScriptLibrary>.Instance);
return new SiteRuntime.Scripts.ScriptRuntimeContext(
actorRef,
actorRef,
sharedLibrary,
currentCallDepth: 0,
@@ -1028,14 +1028,22 @@ public class AlarmActorTests : TestKit, IDisposable
}
[Fact]
public void ExpressionAlarm_EvaluatesOnSchedulerThread_AndActivates()
public void ExpressionAlarm_EvaluatesOffTheBlockingScriptPool_AndActivates()
{
// TRUE only when evaluated on a script-execution thread. Before P1 the
// expression ran on the actor dispatcher (name is NOT "script-execution-*")
// → false → alarm never activates. After P1 it runs on the script scheduler.
// WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
//
// P1 moved the evaluation off the actor's dispatcher and onto the dedicated
// script-execution scheduler, and this test asserted exactly that. WP3.1 (finding #4)
// proved that destination wrong for alarms in particular: sharing the blocking pool
// meant an alarm that should raise in milliseconds queued behind blocked script bodies
// for an unbounded time, and its 2 s evaluation timeout did not even start ticking
// until it was dequeued. Evaluations now run on the shared .NET thread pool behind
// TriggerEvalGate.
//
// TRUE only when evaluated OFF a script-execution thread.
var expr = CompileRawTriggerExpression(
"System.Threading.Thread.CurrentThread.Name != null && " +
"System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
"System.Threading.Thread.CurrentThread.Name == null || " +
"!System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "ExprAlarm",
@@ -217,7 +217,6 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
var executionId = Guid.NewGuid();
var context = new ScriptRuntimeContext(
probe.Ref,
ActorRefs.Nobody,
_sharedLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
@@ -0,0 +1,230 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.TestSupport;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 9 — warm-then-gate deploys and startup batch pre-warm.
///
/// <para>The site-side compile gate (S3) must stay synchronous on the Deployment Manager's
/// thread, because redeploy-supersede and delete-during-redeploy both depend on strict mailbox
/// FIFO. But the Roslyn compile it performs used to hold the singleton for the whole
/// compilation, stalling every OTHER instance's commands behind one instance's scripts. WP3.1
/// warms the compile off-thread first and then re-runs the gate as pure cache hits, with a
/// per-instance in-flight guard preserving same-instance ordering.</para>
///
/// <para>These tests pin the ordering contract, not the timing: a command for the SAME instance
/// arriving during a warm must be queued and applied after the deploy, a superseded deploy must
/// answer its deployer instead of leaving it to Ask-timeout, and commands for DIFFERENT
/// instances must not block each other.</para>
///
/// <para>Shares the <c>SiteScriptCompileCache</c> collection because the batch pre-warm test
/// asserts on that process-wide cache's hit counter.</para>
/// </summary>
[Collection("SiteScriptCompileCache")]
public class DeploymentWarmThenGateTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
public DeploymentWarmThenGateTests()
{
_localDb = TestLocalDb.CreateTemp("dm-warm-gate-test");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private IActorRef CreateDeploymentManager(ISiteHealthCollector? healthCollector = null) =>
ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, _compilationService, _sharedScriptLibrary, null,
new SiteRuntimeOptions(), NullLogger<DeploymentManagerActor>.Instance, null,
healthCollector)));
/// <summary>
/// Captures the deployed-instance count the Deployment Manager reports. The count is
/// mutated only on the actor thread — <c>HandleDeploy</c> adds the instance name,
/// <c>HandleDelete</c> removes it — so it is an exact, storage-race-free record of the
/// order in which the two commands were APPLIED.
/// </summary>
private sealed class DeployedCountCollector : ISiteHealthCollector
{
public int LastDeployedCount { get; private set; }
public void IncrementScriptError() { }
public void IncrementAlarmError() { }
public void IncrementDeadLetter() { }
public void IncrementSiteAuditWriteFailures() { }
public void IncrementAuditRedactionFailure() { }
public void UpdateSiteAuditBacklog(Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
public void RemoveConnection(string connectionName) { }
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
public void SetInstanceCounts(int deployed, int enabled, int disabled) => LastDeployedCount = deployed;
public void SetParkedMessageCount(int count) { }
public void SetNodeHostname(string hostname) { }
public void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes) { }
public void SetActiveNode(bool isActive) { }
public bool IsActiveNode => true;
public Commons.Messages.Health.SiteHealthReport CollectReport(string siteId)
=> throw new NotSupportedException();
}
private static string ConfigJson(string instanceName, string? scriptCode = null) =>
JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
],
Scripts = scriptCode is null
? []
: [new ResolvedScript { CanonicalName = "Worker", Code = scriptCode, TriggerType = "Call" }]
});
[Fact]
public async Task DeleteArrivingDuringTheCompileWarm_IsQueuedAndAppliedAfterTheDeploy()
{
var health = new DeployedCountCollector();
var dm = CreateDeploymentManager(health);
await Task.Delay(500); // empty startup
Assert.Equal(0, health.LastDeployedCount);
var deployProbe = CreateTestProbe();
var deleteProbe = CreateTestProbe();
// Back-to-back on the mailbox: the delete lands while the deploy's compile warm is
// still in flight, so it must be queued rather than racing ahead of the deploy.
dm.Tell(new DeployInstanceCommand(
"dep-1", "WarmPump", "h1", ConfigJson("WarmPump", "return 1;"), "admin", DateTimeOffset.UtcNow),
deployProbe.Ref);
dm.Tell(new DeleteInstanceCommand("del-1", "WarmPump", DateTimeOffset.UtcNow), deleteProbe.Ref);
var deploy = deployProbe.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15));
Assert.Equal(DeploymentStatus.Success, deploy.Status);
var delete = deleteProbe.ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(15));
Assert.True(delete.Success);
// Terminal in-memory state: the deploy applied FIRST (adding the instance) and the
// delete applied SECOND (removing it), leaving the count at 0. Had the delete raced
// ahead of the warm it would have removed nothing and the deploy would have left the
// count at 1. This is the ordering signal rather than the SQLite row, because the
// deploy's store and the delete's remove are independent background tasks whose
// completion order the actor has never guaranteed (true before WP3.1 as well).
AwaitAssert(() => Assert.Equal(0, health.LastDeployedCount), TimeSpan.FromSeconds(10));
}
[Fact]
public async Task SecondDeployDuringTheWarm_SupersedesTheFirst_AndAnswersItsDeployer()
{
var dm = CreateDeploymentManager();
await Task.Delay(500);
var first = CreateTestProbe();
var second = CreateTestProbe();
dm.Tell(new DeployInstanceCommand(
"dep-a", "SupersedePump", "h1", ConfigJson("SupersedePump", "return 1;"), "admin", DateTimeOffset.UtcNow),
first.Ref);
dm.Tell(new DeployInstanceCommand(
"dep-b", "SupersedePump", "h2", ConfigJson("SupersedePump", "return 2;"), "admin", DateTimeOffset.UtcNow),
second.Ref);
// The displaced deployer is answered rather than left to time out its Ask.
var superseded = first.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15));
Assert.Equal("dep-a", superseded.DeploymentId);
Assert.Equal(DeploymentStatus.Failed, superseded.Status);
Assert.Contains("superseded", superseded.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
var winner = second.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15));
Assert.Equal("dep-b", winner.DeploymentId);
Assert.Equal(DeploymentStatus.Success, winner.Status);
// Exactly one row, carrying the winning revision hash.
var configs = await _storage.GetAllDeployedConfigsAsync();
var row = Assert.Single(configs, c => c.InstanceUniqueName == "SupersedePump");
Assert.Equal("h2", row.RevisionHash);
}
[Fact]
public async Task DeploysForDifferentInstances_DoNotBlockEachOther()
{
var dm = CreateDeploymentManager();
await Task.Delay(500);
var probeX = CreateTestProbe();
var probeY = CreateTestProbe();
dm.Tell(new DeployInstanceCommand(
"dep-x", "PumpX", "hx", ConfigJson("PumpX", "return 1;"), "admin", DateTimeOffset.UtcNow),
probeX.Ref);
dm.Tell(new DeployInstanceCommand(
"dep-y", "PumpY", "hy", ConfigJson("PumpY", "return 2;"), "admin", DateTimeOffset.UtcNow),
probeY.Ref);
// Both apply; the per-instance warm guard scopes to the instance, so a warm for X
// never queues a command for Y.
Assert.Equal(DeploymentStatus.Success,
probeX.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15)).Status);
Assert.Equal(DeploymentStatus.Success,
probeY.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15)).Status);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Contains(configs, c => c.InstanceUniqueName == "PumpX");
Assert.Contains(configs, c => c.InstanceUniqueName == "PumpY");
}
[Fact]
public async Task StaggeredStartup_PreWarmsEachBatchSoInstanceActorPreStartCompilesAreCacheHits()
{
// Two instances sharing one script body. The batch pre-warm compiles it once; the
// second config's warm and BOTH Instance Actors' PreStart compiles are then hits.
// Before WP3.1 every Instance Actor Roslyn-compiled its own scripts inside PreStart,
// serialising a site's whole failover recovery behind compilation.
const string sharedCode = "return 41 + 1;";
await _storage.StoreDeployedConfigAsync(
"BatchOne", ConfigJson("BatchOne", sharedCode), "d1", "h1", true);
await _storage.StoreDeployedConfigAsync(
"BatchTwo", ConfigJson("BatchTwo", sharedCode), "d2", "h2", true);
SiteScriptCompileCache.Clear();
Assert.Equal(0, SiteScriptCompileCache.Hits);
CreateDeploymentManager();
AwaitAssert(() =>
{
// One compile, then repeated hits: the second pre-warm plus both PreStarts.
Assert.True(SiteScriptCompileCache.Hits >= 3,
$"expected the pre-warmed body to be served from cache, saw {SiteScriptCompileCache.Hits} hits");
}, TimeSpan.FromSeconds(20));
}
}
@@ -1,440 +0,0 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
/// PoisonPill self-stop after completion.
/// </summary>
public class ExecutionActorTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly ScriptCompilationService _compilationService;
public ExecutionActorTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose() => Shutdown();
private static Script<object?> CompileScript(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
private static SiteRuntimeOptions Options(int timeoutSeconds = 30)
=> new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds };
// ── ScriptExecutionActor ──
[Fact]
public void ScriptExecutionActor_Success_RepliesWithResultAndStops()
{
var compiled = CompileScript("return 7 * 6;");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Answer", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-1", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.Equal("corr-1", result.CorrelationId);
Assert.Equal(42, result.ReturnValue);
// The actor must PoisonPill itself once execution completes.
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
// ── M1.8: site event log `script` started/completed ────────────────────
[Fact]
public void ScriptExecutionActor_Success_EmitsScriptStartedAndCompletedInfoEvents()
{
var compiled = CompileScript("return 7 * 6;");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var siteLog = new FakeSiteEventLogger();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Answer", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-evt-1", NullLogger.Instance,
ScriptScope.Root, null, new SingleServiceProvider(siteLog))));
Watch(exec);
replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
AwaitAssert(() =>
{
var rows = siteLog.OfType("script");
// started + completed, both Info, in order.
Assert.Equal(2, rows.Count);
Assert.All(rows, r =>
{
Assert.Equal("Info", r.Severity);
Assert.Equal("Inst1", r.InstanceId);
Assert.Equal("ScriptActor:Answer", r.Source);
});
Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("completed", rows[1].Message, StringComparison.OrdinalIgnoreCase);
}, TimeSpan.FromSeconds(2));
}
[Fact]
public void ScriptExecutionActor_Failure_EmitsStartedInfoThenErrorEvent()
{
var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var siteLog = new FakeSiteEventLogger();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Bad", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-evt-2", NullLogger.Instance,
ScriptScope.Root, null, new SingleServiceProvider(siteLog))));
Watch(exec);
replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
AwaitAssert(() =>
{
var rows = siteLog.OfType("script");
// started (Info) + failed (Error) — no completed.
Assert.Equal(2, rows.Count);
Assert.Equal("Info", rows[0].Severity);
Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal("Error", rows[1].Severity);
}, TimeSpan.FromSeconds(2));
}
[Fact]
public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops()
{
var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Bad", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-2", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("corr-2", result.CorrelationId);
Assert.Contains("boom", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_Timeout_RepliesFailureAndStops()
{
// A long busy loop that observes the cancellation token so the
// 1-second timeout fires cooperatively.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Slow", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
replyTo.Ref, "corr-3", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_PerScriptTimeout_OverridesLongerGlobal()
{
// M2.5 (#9): a short per-script timeout (1s) must win over a long global
// (300s), so the busy loop is cancelled at the per-script value.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Slow", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300),
replyTo.Ref, "corr-perscript", NullLogger.Instance,
ScriptScope.Root, null, null, null,
/* executionTimeoutSeconds */ 1)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_NullPerScriptTimeout_FallsBackToGlobal()
{
// M2.5 (#9): a null per-script timeout falls back to the global (1s here),
// so the busy loop is still cancelled at the global value.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Slow", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
replyTo.Ref, "corr-fallback", NullLogger.Instance,
ScriptScope.Root, null, null, null,
/* executionTimeoutSeconds */ null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal()
{
// M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use
// global", so the busy loop is cancelled at the global (1s) value.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Slow", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
replyTo.Ref, "corr-clamp", NullLogger.Instance,
ScriptScope.Root, null, null, null,
/* executionTimeoutSeconds */ 0)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
{
var compiled = CompileScript("return 1;");
var instanceActor = CreateTestProbe();
// ActorRefs.Nobody as replyTo — fire-and-forget execution.
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"FireForget", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
ActorRefs.Nobody, "corr-4", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
// ── AlarmExecutionActor ──
[Fact]
public void AlarmExecutionActor_Success_StopsAfterCompletion()
{
var compiled = CompileScript("return 0;");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(),
NullLogger.Instance)));
Watch(exec);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void AlarmExecutionActor_ScriptThrows_StillStops()
{
var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(),
NullLogger.Instance)));
Watch(exec);
// Even on a throwing on-trigger body, the actor must self-stop.
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void AlarmExecutionActor_PerScriptTimeout_OverridesLongerGlobal()
{
// M2.5 (#9): the alarm on-trigger script's per-script timeout (1s) wins
// over a long global (300s). The busy loop is cancelled and the actor
// self-stops (the timeout is logged, alarm continues).
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300),
NullLogger.Instance, /* executionTimeoutSeconds */ 1)));
Watch(exec);
// If the per-script timeout were ignored it would block ~300s and this
// ExpectTerminated would fail; with the override it stops within ~1s.
ExpectTerminated(exec, TimeSpan.FromSeconds(10));
}
[Fact]
public void AlarmExecutionActor_NullPerScriptTimeout_FallsBackToGlobal()
{
// M2.5 (#9): a null per-script timeout falls back to the global (1s here),
// so the busy loop is cancelled at the global value and the actor self-stops.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
NullLogger.Instance, /* executionTimeoutSeconds */ null)));
Watch(exec);
// Global timeout (1s) must fire even when per-script is null.
ExpectTerminated(exec, TimeSpan.FromSeconds(10));
}
[Fact]
public void AlarmExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal()
{
// M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use
// global", so the busy loop is cancelled at the global (1s) value.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
NullLogger.Instance, /* executionTimeoutSeconds */ 0)));
Watch(exec);
// Non-positive per-script timeout must be ignored; global (1s) must fire.
ExpectTerminated(exec, TimeSpan.FromSeconds(10));
}
// ── S2: stuck-script watchdog names the script holding a scheduler thread ──
/// <summary>
/// Compiles a raw script that can reach the test-assembly <see cref="StuckTestHooks"/>
/// (so a script body can block on a gate). Bypasses the trust validator, like
/// <see cref="CompileScript"/>.
/// </summary>
private static Script<object?> CompileRaw(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(StuckTestHooks).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
[Fact]
public void TimedOutScript_WithBlockedThread_EmitsStuckThreadSiteEvent()
{
// A script that blocks synchronously on a gate NEVER observes cooperative
// cancellation, so the CTS firing at the 1s timeout does not free its
// scheduler thread. After the 200ms grace the watchdog must name it loudly.
StuckTestHooks.Gate = new SemaphoreSlim(0);
var compiled = CompileRaw(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.StuckTestHooks.Gate.Wait(); return null;");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var siteLog = new FakeSiteEventLogger();
var options = new SiteRuntimeOptions { ScriptExecutionTimeoutSeconds = 1, StuckScriptGraceMs = 200 };
ActorOf(Props.Create(() => new ScriptExecutionActor(
"StuckScript", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, options,
replyTo.Ref, "corr-stuck", NullLogger.Instance,
ScriptScope.Root, null, new SingleServiceProvider(siteLog))));
try
{
AwaitAssert(() =>
{
var rows = siteLog.OfType("script");
Assert.Contains(rows, r =>
r.Severity == "Error" &&
r.Message.Contains("still executing", StringComparison.OrdinalIgnoreCase) &&
r.Message.Contains("StuckScript"));
}, TimeSpan.FromSeconds(10));
}
finally
{
// Free the blocked scheduler thread so the test run stays clean.
StuckTestHooks.Gate.Release();
}
}
}
/// <summary>
/// Test hook the stuck-script watchdog test uses to block a script-execution
/// thread deterministically: the compiled body waits on <see cref="Gate"/>, which
/// the test releases once it has observed the stuck-thread site event.
/// </summary>
public static class StuckTestHooks
{
/// <summary>Gate a blocking test script waits on; reset per test.</summary>
public static SemaphoreSlim Gate = new(0);
}
@@ -403,13 +403,15 @@ public class ScriptActorTests : TestKit, IDisposable
"ExprFault", "Expression", "{\"expression\":\"true\",\"mode\":\"OnTrue\"}", null, expr);
try
{
actor.Tell(Change("A", "1")); // eval starts on the scheduler and BLOCKS → _evalInFlight = true
actor.Tell(Change("A", "1")); // eval starts off-thread and BLOCKS → _evalInFlight = true
AwaitAssert(() => Assert.Equal(1, EvalGate.Entries), TimeSpan.FromSeconds(10));
// #18 seam: the blocked evaluation is running on THIS class's injected
// scheduler — not the process-wide singleton — so a worker it strands can
// never starve another test class.
Assert.Equal(1, _scheduler.BusyThreadCount);
// WP3.1: the blocked evaluation no longer occupies a script-execution worker at
// all — it runs on the shared thread pool behind TriggerEvalGate, which is the
// whole point of finding #4. The blocking pool must be completely idle here; the
// pre-WP3.1 assertion was the opposite (BusyThreadCount == 1).
Assert.Equal(0, _scheduler.BusyThreadCount);
Assert.Equal(0, _scheduler.QueueDepth);
actor.Tell(Change("A", "2")); // coalesces → _evalPending = true
@@ -432,15 +434,24 @@ public class ScriptActorTests : TestKit, IDisposable
}
[Fact]
public void ExpressionTrigger_EvaluatesOnScriptSchedulerThread_AndStillFires()
public void ExpressionTrigger_EvaluatesOffTheBlockingScriptPool_AndStillFires()
{
// The expression is TRUE only when evaluated on a script-execution thread.
// Before P1 it ran synchronously on the actor's dispatcher thread (name is
// NOT "script-execution-*") → false → no fire. After P1 it runs on the
// script scheduler → true → fire.
// WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
//
// P1 moved trigger-expression evaluation off the actor's dispatcher thread and onto
// the dedicated script-execution scheduler, and this test asserted exactly that
// ("evaluated on a script-execution-* thread"). WP3.1 (finding #4) proved that
// destination wrong: sharing the blocking pool meant N blocked script bodies stalled
// every Expression trigger on the node indefinitely. Evaluations are non-blocking by
// construction, so they now run as plain async work on the shared .NET thread pool
// behind TriggerEvalGate.
//
// The expression is therefore TRUE only when evaluated OFF a script-execution thread —
// still off the dispatcher (the P1 property, covered by the coalescing/PipeTo tests),
// and now provably off the blocking pool too.
var expr = CompileRawTriggerExpression(
"System.Threading.Thread.CurrentThread.Name != null && " +
"System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
"System.Threading.Thread.CurrentThread.Name == null || " +
"!System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
var (actor, instance) = CreateTriggeredActor(
"ExprThread",
"Expression",
@@ -449,7 +460,7 @@ public class ScriptActorTests : TestKit, IDisposable
expr);
actor.Tell(Change("Any", "1"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off-dispatcher
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off the blocking pool
}
[Fact]
@@ -0,0 +1,136 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 3 — a script's execution deadline is armed when the run is ENQUEUED, not
/// when it is dequeued.
///
/// <para>Before WP3.1 the deadline <see cref="CancellationTokenSource"/> was constructed inside
/// the queued body, so a run that spent ten minutes waiting behind blocked scripts still got a
/// fresh full 30 s budget when it finally started — and then ran work whose triggering
/// condition was long stale. Now queue wait consumes the run's own budget, and a body that
/// dequeues past its deadline is SHED at dequeue: it never executes, and takes the existing
/// timeout path (site event, script-error counter, error reply, completion message).</para>
/// </summary>
public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
public ScriptDeadlineAtEnqueueTests()
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
DeadlineHooks.Gate = new SemaphoreSlim(0);
DeadlineHooks.SecondScriptRan = false;
}
void IDisposable.Dispose()
{
DeadlineHooks.Gate.Release(8);
Shutdown();
}
private static Script<object?> CompileRaw(string code)
{
var options = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
typeof(DeadlineHooks).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, options, typeof(ScriptGlobals));
script.Compile();
return script;
}
private IActorRef BuildScriptActor(
string name, Script<object?> compiled, SiteRuntimeOptions options,
ScriptExecutionScheduler scheduler, IServiceProvider? serviceProvider)
{
var instance = CreateTestProbe().Ref;
var config = new ResolvedScript { CanonicalName = name, TriggerType = "Call" };
return ActorOf(Props.Create(() => new ScriptActor(
name, "Inst1", instance, compiled, config, _sharedLibrary, options,
NullLogger<ScriptActor>.Instance, null, null, null, serviceProvider, scheduler, null)));
}
[Fact]
public void ARunThatDequeuesPastItsDeadline_IsShedWithoutExecutingItsBody()
{
using var scheduler = new ScriptExecutionScheduler(1);
var siteLog = new FakeSiteEventLogger();
// The single worker is held by a first script for longer than the second script's
// entire timeout.
var blocker = BuildScriptActor(
"Blocker",
CompileRaw("ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.DeadlineHooks.Gate.Wait(); return null;"),
new SiteRuntimeOptions { ScriptExecutionTimeoutSeconds = 120, StuckScriptGraceMs = 120_000 },
scheduler, null);
blocker.Tell(new ScriptCallRequest("Blocker", null, 0, "corr-blocker"), ActorRefs.NoSender);
AwaitAssert(() => Assert.Equal(1, scheduler.BusyThreadCount), TimeSpan.FromSeconds(15));
// A 1 s script queued behind it. Its grace is generous so that if the watchdog DID
// fire it would have ample opportunity to emit — the assertion below is that it does not.
var lateOptions = new SiteRuntimeOptions
{
ScriptExecutionTimeoutSeconds = 1,
StuckScriptGraceMs = 1000
};
var late = BuildScriptActor(
"Late",
CompileRaw(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.DeadlineHooks.SecondScriptRan = true; return 1;"),
lateOptions, scheduler, new SingleServiceProvider(siteLog));
var caller = CreateTestProbe();
late.Tell(new ScriptCallRequest("Late", null, 0, "corr-late"), caller.Ref);
// Let its whole budget elapse while it is still queued, then free the worker.
Thread.Sleep(TimeSpan.FromSeconds(2));
DeadlineHooks.Gate.Release();
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(15));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
// The body never ran: stale work is shed at dequeue rather than executed late.
Assert.False(DeadlineHooks.SecondScriptRan,
"the queued body executed even though its deadline had already passed");
AwaitAssert(() =>
{
var rows = siteLog.OfType("script");
// Timeout path only — no "started" Info event, because the body was skipped.
Assert.Contains(rows, r => r.Severity == "Error" && r.Message.Contains("timed out"));
Assert.DoesNotContain(rows, r => r.Message.Contains("started", StringComparison.OrdinalIgnoreCase));
}, TimeSpan.FromSeconds(5));
// And the watchdog did NOT report it as a stuck thread: it was cancelled while queued,
// so it never held a worker.
Thread.Sleep(TimeSpan.FromSeconds(2)); // well past the 1 s grace
Assert.DoesNotContain(siteLog.OfType("script"),
r => r.Message.Contains("still executing", StringComparison.OrdinalIgnoreCase));
Assert.Equal(0, scheduler.DetachedThreadCount);
}
}
/// <summary>Test hooks for the enqueue-anchored deadline test.</summary>
public static class DeadlineHooks
{
/// <summary>Gate the blocking first script waits on.</summary>
public static SemaphoreSlim Gate = new(0);
/// <summary>Set by the second script's body — must stay false when the run is shed at dequeue.</summary>
public static bool SecondScriptRan;
}
@@ -0,0 +1,469 @@
using Akka.Actor;
using Akka.Event;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 supervision-parity set (design memo §4, seven pins) — the reworked successor to
/// <c>ExecutionActorTests</c>.
///
/// <para>WP3.1 eliminated the short-lived <c>ScriptExecutionActor</c> and
/// <c>AlarmExecutionActor</c>: neither had a <c>Receive</c> handler, a <c>PostStop</c>, or any
/// state, and neither's <c>IActorRef</c> was ever a message target — the entire lifecycle lived
/// in a detached task. Runs are now launched directly by the coordinator via
/// <see cref="ScriptRunLauncher"/>. These tests pin every behaviour the removed actors
/// provided onto its replacement: exception and timeout containment, the Ask reply, the
/// completion notification, one DI scope per run, the audit ParentExecutionId threading, and
/// the supervision outcome (coordinator unaffected — no stop, no restart).</para>
/// </summary>
public class ScriptRunLauncherParityTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly ScriptCompilationService _compilationService;
/// <summary>Own pool per test class (#18 seam), so a wedged body cannot strand the process-wide one.</summary>
private readonly ScriptExecutionScheduler _scheduler = new(4);
public ScriptRunLauncherParityTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
_scheduler.Dispose();
}
// ── helpers ──────────────────────────────────────────────────────────────────
private static Script<object?> CompileScript(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
typeof(RunLauncherHooks).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
private static SiteRuntimeOptions Options(int timeoutSeconds = 30, int graceMs = 30000)
=> new()
{
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = timeoutSeconds,
StuckScriptGraceMs = graceMs
};
private static ResolvedScript CallScript(int? timeoutSeconds = null) => new()
{
CanonicalName = "Runner",
TriggerType = "Call",
ExecutionTimeoutSeconds = timeoutSeconds
};
private TestActorRef<ScriptActor> BuildScriptActor(
Script<object?>? compiled,
SiteRuntimeOptions options,
IServiceProvider? serviceProvider = null,
ISiteHealthCollector? healthCollector = null,
ScriptExecutionScheduler? scheduler = null,
int? perScriptTimeoutSeconds = null,
IActorRef? instanceActor = null)
{
var instance = instanceActor ?? CreateTestProbe().Ref;
return ActorOfAsTestActorRef<ScriptActor>(
Props.Create(() => new ScriptActor(
"Runner", "Inst1", instance, compiled, CallScript(perScriptTimeoutSeconds),
_sharedLibrary, options, NullLogger<ScriptActor>.Instance,
null, null, healthCollector, serviceProvider, scheduler ?? _scheduler, null)),
"script-" + Guid.NewGuid().ToString("N"));
}
// ── Pin 1: throwing body — coordinator survives, everything is reported ───────
[Fact]
public void ThrowingScriptBody_LeavesScriptActorAliveAndReportsEverything()
{
var siteLog = new FakeSiteEventLogger();
var health = new SiteHealthCollector();
var actor = BuildScriptActor(
CompileScript("throw new InvalidOperationException(\"boom\");"),
Options(),
new SingleServiceProvider(siteLog),
health);
Watch(actor);
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-throw"), caller.Ref);
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("corr-throw", result.CorrelationId);
Assert.Contains("boom", result.ErrorMessage);
AwaitAssert(() =>
{
// Error site event + script-error counter, exactly as the execution actor emitted.
Assert.Contains(siteLog.OfType("script"),
r => r.Severity == "Error" && r.Message.Contains("failed", StringComparison.OrdinalIgnoreCase));
// The in-flight slot is released, so the script can run again.
Assert.Equal(0, actor.UnderlyingActor.RunsInFlight);
}, TimeSpan.FromSeconds(5));
Assert.Equal(1, health.CollectReport("site-1").ScriptErrorCount);
// The coordinator is neither stopped nor restarted — a throwing body was always
// contained inside the run's own try/catch, and still is. Watch() above means a stop
// would deliver Terminated to the TestActor; a still-answering call proves it is live.
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-still-alive"), caller.Ref);
Assert.Equal("corr-still-alive",
caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10)).CorrelationId);
}
// ── Pin 2: launch-path throw — improved over the old silent hang ──────────────
/// <summary>
/// The only failure the removed per-run child could surface was a constructor throw —
/// e.g. queueing onto a disposed <see cref="ScriptExecutionScheduler"/>. The old
/// <c>OneForOneStrategy</c> logged it and stopped the child, leaving the Ask caller to
/// hang with no reply at all. WP3.1 folds that into a launch-path catch that replies, an
/// intentional improvement pinned here so it is explicit rather than accidental.
/// </summary>
[Fact]
public void LaunchPathThrow_RepliesToTheCallerAndLeavesTheCoordinatorAlive()
{
var dead = new ScriptExecutionScheduler(1);
dead.Dispose();
var actor = BuildScriptActor(
CompileScript("return 1;"), Options(), scheduler: dead);
Watch(actor);
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-launch"), caller.Ref);
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("corr-launch", result.CorrelationId);
Assert.Contains("could not be launched", result.ErrorMessage);
// Watch() above means a stop would deliver Terminated to the TestActor; none arrives,
// so the coordinator neither died nor restarted on a launch failure.
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// The in-flight counter is incremented before the launch, so the catch MUST balance
// it — otherwise a run of launch failures would permanently wedge the script at cap.
AwaitAssert(() => Assert.Equal(0, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(5));
}
// ── Pin 3: exactly one DI scope per run, disposed on every path ───────────────
[Theory]
[InlineData("return 1;", 30)] // success
[InlineData("throw new InvalidOperationException(\"boom\");", 30)] // failure
[InlineData("while (true) { await Task.Delay(25, CancellationToken); }", 1)] // timeout
public void EachRun_CreatesOneDiScope_AndDisposesItExactlyOnce(string code, int timeoutSeconds)
{
var spy = new ScopeSpyServiceProvider();
var actor = BuildScriptActor(
CompileScript(code), Options(timeoutSeconds), spy);
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-scope"), caller.Ref);
caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(15));
AwaitAssert(() =>
{
Assert.Equal(1, spy.ScopesCreated);
Assert.Equal(1, spy.ScopesDisposed);
}, TimeSpan.FromSeconds(5));
}
// ── Pin 4: audit correlation threading survives the actor removal ─────────────
/// <summary>
/// A routed <see cref="ScriptCallRequest.ParentExecutionId"/> must still reach the run's
/// <see cref="ScriptRuntimeContext"/> — this is the inbound-API leg of the audit execution
/// tree, and it used to be threaded through the execution actor's constructor.
/// </summary>
[Fact]
public void RoutedParentExecutionId_ReachesTheRunsScriptRuntimeContext()
{
RunLauncherHooks.CapturedContext = null;
var parent = Guid.NewGuid();
var actor = BuildScriptActor(
CompileScript(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.RunLauncherHooks.CapturedContext = Instance; return 1;"),
Options());
var caller = CreateTestProbe();
actor.Tell(
new ScriptCallRequest("Runner", null, 0, "corr-parent", ParentExecutionId: parent),
caller.Ref);
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
AwaitAssert(() =>
{
Assert.NotNull(RunLauncherHooks.CapturedContext);
Assert.Equal(parent, RunLauncherHooks.CapturedContext!.ParentExecutionId);
// The routed run still mints its OWN ExecutionId — the parent is a pointer, not a copy.
Assert.NotEqual(parent, RunLauncherHooks.CapturedContext.ExecutionId);
}, TimeSpan.FromSeconds(5));
}
// ── Pin 5: timeout resolution parity (perScript ?? global, <= 0 => global) ────
[Theory]
[InlineData(300, 1)] // per-script override wins over a much longer global
[InlineData(1, null)] // null per-script falls back to the global
[InlineData(1, 0)] // non-positive per-script is treated as "use global"
public void TimeoutResolution_MatchesTheRemovedExecutionActor(int globalSeconds, int? perScriptSeconds)
{
var siteLog = new FakeSiteEventLogger();
var actor = BuildScriptActor(
CompileScript("while (true) { await Task.Delay(25, CancellationToken); }"),
Options(globalSeconds),
new SingleServiceProvider(siteLog),
perScriptTimeoutSeconds: perScriptSeconds);
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-timeout"), caller.Ref);
// If the effective timeout were the 300 s global (case 1) or ignored (cases 2/3) this
// would not answer inside the window.
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(15));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
AwaitAssert(
() => Assert.Contains(siteLog.OfType("script"),
r => r.Severity == "Error" && r.Message.Contains("timed out")),
TimeSpan.FromSeconds(5));
}
// ── Pin 6: stop-during-run parity ────────────────────────────────────────────
/// <summary>
/// Stopping a ScriptActor mid-run must NOT cancel the in-flight run (redeploy/undeploy
/// semantics: running scripts are allowed to finish). The run completes normally and its
/// completion message dead-letters, exactly as the old per-run child's
/// <c>parent.Tell</c> did once the subtree was stopped — dead letters are a health metric,
/// not an error.
/// </summary>
[Fact]
public void StoppingTheScriptActorMidRun_LetsTheRunFinishAndDeadLettersItsCompletion()
{
RunLauncherHooks.Gate = new SemaphoreSlim(0);
RunLauncherHooks.Finished = new ManualResetEventSlim(false);
RunLauncherHooks.ObservedCancellation = null;
var actor = BuildScriptActor(
CompileScript(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.RunLauncherHooks.Gate.Wait();" +
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.RunLauncherHooks.ObservedCancellation = CancellationToken.IsCancellationRequested;" +
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.RunLauncherHooks.Finished.Set();" +
"return 1;"),
Options());
var deadLetters = CreateTestProbe();
Sys.EventStream.Subscribe(deadLetters.Ref, typeof(DeadLetter));
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-stop"), caller.Ref);
// Wait until the body is actually on a worker thread, then stop the coordinator.
AwaitAssert(() => Assert.Equal(1, _scheduler.BusyThreadCount), TimeSpan.FromSeconds(10));
Watch(actor);
Sys.Stop(actor);
ExpectTerminated(actor, TimeSpan.FromSeconds(10));
RunLauncherHooks.Gate.Release();
// The run ran to completion and was never cancelled by the stop.
Assert.True(RunLauncherHooks.Finished.Wait(TimeSpan.FromSeconds(10)));
Assert.False(RunLauncherHooks.ObservedCancellation);
// The Ask caller still gets its result (the reply target is not the stopped actor)…
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
// …and the completion notification aimed at the now-stopped coordinator dead-letters.
deadLetters.FishForMessage<DeadLetter>(
d => d.Message is ScriptActor.ScriptExecutionCompleted,
TimeSpan.FromSeconds(10));
}
// ── Pin 7: alarm side ────────────────────────────────────────────────────────
/// <summary>
/// An alarm on-trigger run still receives the <c>Alarm</c> globals (name/level/priority/
/// message) and still reports <c>AlarmExecutionCompleted</c> back to its AlarmActor —
/// observable here through the in-flight counter returning to zero, which only the
/// completion message can do.
/// </summary>
[Fact]
public void AlarmOnTriggerRun_GetsAlarmGlobals_AndCompletesBackToTheAlarmActor()
{
RunLauncherHooks.CapturedAlarm = null;
var onTrigger = CompileScript(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.RunLauncherHooks.CapturedAlarm = Alarm; return null;");
var instanceProbe = CreateTestProbe();
var alarm = ActorOfAsTestActorRef<AlarmActor>(
Props.Create(() => new AlarmActor(
"TempBand", "Inst1", instanceProbe.Ref,
new ResolvedAlarm
{
CanonicalName = "TempBand",
TriggerType = "HiLo",
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":95,\"hiMessage\":\"too hot\"}",
PriorityLevel = 42
},
onTrigger, _sharedLibrary, Options(), NullLogger<AlarmActor>.Instance,
null, null, null, null, null, _scheduler, null)),
"alarm-" + Guid.NewGuid().ToString("N"));
alarm.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
"Inst1", "Temp", "Temp", 90.0, "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectMsg<Commons.Messages.Streaming.AlarmStateChanged>(TimeSpan.FromSeconds(10));
AwaitAssert(() =>
{
Assert.NotNull(RunLauncherHooks.CapturedAlarm);
Assert.Equal("TempBand", RunLauncherHooks.CapturedAlarm!.Name);
Assert.Equal(AlarmLevel.High, RunLauncherHooks.CapturedAlarm.Level);
Assert.Equal("too hot", RunLauncherHooks.CapturedAlarm.Message);
// Only AlarmExecutionCompleted releases the slot.
Assert.Equal(0, alarm.UnderlyingActor.RunsInFlight);
}, TimeSpan.FromSeconds(10));
}
// ── Retargeted from ExecutionActorTests: success path + operational events ────
[Fact]
public void SuccessfulRun_RepliesWithTheReturnValue()
{
var actor = BuildScriptActor(CompileScript("return 7 * 6;"), Options());
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-ok"), caller.Ref);
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal("corr-ok", result.CorrelationId);
Assert.Equal(42, result.ReturnValue);
}
[Fact]
public void SuccessfulRun_EmitsStartedThenCompletedInfoEvents()
{
var siteLog = new FakeSiteEventLogger();
var actor = BuildScriptActor(
CompileScript("return 7 * 6;"), Options(), new SingleServiceProvider(siteLog));
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-evt"), caller.Ref);
caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
AwaitAssert(() =>
{
var rows = siteLog.OfType("script");
Assert.Equal(2, rows.Count);
Assert.All(rows, r =>
{
Assert.Equal("Info", r.Severity);
Assert.Equal("Inst1", r.InstanceId);
Assert.Equal("ScriptActor:Runner", r.Source);
});
Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("completed", rows[1].Message, StringComparison.OrdinalIgnoreCase);
}, TimeSpan.FromSeconds(5));
}
[Fact]
public void FailingRun_EmitsStartedInfoThenErrorEvent()
{
var siteLog = new FakeSiteEventLogger();
var actor = BuildScriptActor(
CompileScript("throw new InvalidOperationException(\"boom\");"),
Options(), new SingleServiceProvider(siteLog));
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-evt-err"), caller.Ref);
caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
AwaitAssert(() =>
{
var rows = siteLog.OfType("script");
Assert.Equal(2, rows.Count);
Assert.Equal("Info", rows[0].Severity);
Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal("Error", rows[1].Severity);
}, TimeSpan.FromSeconds(5));
}
[Fact]
public void FireAndForgetRun_NeedsNoReplyTarget()
{
var siteLog = new FakeSiteEventLogger();
var actor = BuildScriptActor(
CompileScript("return 1;"), Options(), new SingleServiceProvider(siteLog));
// Trigger-driven spawns pass ActorRefs.NoSender as replyTo; drive that path via an
// interval-free Call script by telling the actor to run with no sender.
actor.Tell(new ScriptCallRequest("Runner", null, 0, "corr-nobody"), ActorRefs.NoSender);
AwaitAssert(
() => Assert.Contains(siteLog.OfType("script"),
r => r.Message.Contains("completed", StringComparison.OrdinalIgnoreCase)),
TimeSpan.FromSeconds(10));
}
}
/// <summary>
/// Hooks a compiled test script can reach from inside a script body: a gate to block a
/// worker thread deterministically, and capture slots for the run's
/// <see cref="ScriptRuntimeContext"/> and <see cref="AlarmContext"/> (both of which the run
/// otherwise exposes to nobody).
/// </summary>
public static class RunLauncherHooks
{
/// <summary>Gate a blocking test script waits on; reset per test.</summary>
public static SemaphoreSlim Gate = new(0);
/// <summary>Set by a test script once its body has run to completion.</summary>
public static ManualResetEventSlim Finished = new(false);
/// <summary>Whether the script observed a cancellation request at the end of its body.</summary>
public static bool? ObservedCancellation;
/// <summary>The runtime context handed to the last captured run.</summary>
public static ScriptRuntimeContext? CapturedContext;
/// <summary>The <c>Alarm</c> global handed to the last captured on-trigger run.</summary>
public static AlarmContext? CapturedAlarm;
}
@@ -0,0 +1,199 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 6 — the per-script in-flight cap and its shed policy.
///
/// <para>Before WP3.1 every trigger spawned another run unconditionally: a trigger firing
/// faster than its script completes produced unbounded fan-out onto a bounded thread pool.
/// The cap (<see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>, default 4) sheds the
/// NEWEST run instead. Keeping the four already queued/running — which are closest to their
/// own deadlines and already charged against them — is the policy that never reorders runs
/// and needs no queue at all: the scheduler's FIFO already IS the queue.</para>
///
/// <para>A shed is always counted on the health collector, emits a site event rate-limited to
/// one per script per minute (so a hot trigger cannot flood <c>site_events</c>), and — for an
/// Ask-based <c>CallScript</c> — replies with an explicit error rather than letting a nested
/// call or inbound-API route hang to its Ask timeout.</para>
/// </summary>
public class ScriptRunShedTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly ScriptExecutionScheduler _scheduler = new(8);
public ScriptRunShedTests()
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
ShedHooks.Gate = new SemaphoreSlim(0);
}
void IDisposable.Dispose()
{
ShedHooks.Gate.Release(64);
Shutdown();
_scheduler.Dispose();
}
private static Script<object?> BlockingScript() => CompileRaw(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.ShedHooks.Gate.Wait(); return null;");
private static Script<object?> CompileRaw(string code)
{
var options = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
typeof(ShedHooks).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, options, typeof(ScriptGlobals));
script.Compile();
return script;
}
private static SiteRuntimeOptions Options() => new()
{
MaxConcurrentRunsPerScript = 4,
// Long enough that nothing times out inside the test window — the cap, not the
// deadline, must be what refuses the fifth run.
ScriptExecutionTimeoutSeconds = 120,
StuckScriptGraceMs = 120_000
};
[Fact]
public void FifthConcurrentRun_IsShed_Counted_EventedOnce_AndAnsweredWithAnError()
{
var siteLog = new FakeSiteEventLogger();
var health = new SiteHealthCollector();
var instance = CreateTestProbe().Ref;
var options = Options();
var actor = ActorOfAsTestActorRef<ScriptActor>(
Props.Create(() => new ScriptActor(
"Hot", "Inst1", instance, BlockingScript(),
new ResolvedScript { CanonicalName = "Hot", TriggerType = "Call" },
_sharedLibrary, options, NullLogger<ScriptActor>.Instance,
null, null, health, new SingleServiceProvider(siteLog), _scheduler, null)),
"shed-" + Guid.NewGuid().ToString("N"));
// Fill the cap: four runs, all blocked in their bodies.
for (var i = 0; i < 4; i++)
actor.Tell(new ScriptCallRequest("Hot", null, 0, $"corr-{i}"), ActorRefs.NoSender);
AwaitAssert(() =>
{
Assert.Equal(4, actor.UnderlyingActor.RunsInFlight);
Assert.Equal(4, _scheduler.BusyThreadCount);
}, TimeSpan.FromSeconds(15));
// Fifth: shed. The Ask caller is answered explicitly instead of hanging.
var caller = CreateTestProbe();
actor.Tell(new ScriptCallRequest("Hot", null, 0, "corr-shed-1"), caller.Ref);
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("corr-shed-1", result.CorrelationId);
Assert.Contains("shed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
Assert.Contains("in flight", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
// Still exactly four in flight — the shed run was never launched.
Assert.Equal(4, actor.UnderlyingActor.RunsInFlight);
// Sixth: counted again, but the Warning site event is rate-limited to one per minute.
actor.Tell(new ScriptCallRequest("Hot", null, 0, "corr-shed-2"), caller.Ref);
var second = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(second.Success);
AwaitAssert(() =>
{
var shedEvents = siteLog.OfType("script")
.Where(r => r.Severity == "Warning" && r.Message.Contains("shed"))
.ToArray();
Assert.Single(shedEvents);
Assert.Equal("ScriptActor:Hot", shedEvents[0].Source);
Assert.Equal("Inst1", shedEvents[0].InstanceId);
}, TimeSpan.FromSeconds(5));
// Both sheds were counted on the health report even though only one was evented.
Assert.Equal(2, health.CollectReport("site-1").ScriptRunShedCount);
// One completion frees a slot, and the next trigger launches again.
ShedHooks.Gate.Release();
AwaitAssert(() => Assert.Equal(3, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
actor.Tell(new ScriptCallRequest("Hot", null, 0, "corr-after"), ActorRefs.NoSender);
AwaitAssert(() => Assert.Equal(4, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
}
[Fact]
public void AlarmOnTriggerRuns_ShedAtTheSameCap_WithAnAlarmScopedSiteEvent()
{
var siteLog = new FakeSiteEventLogger();
var health = new SiteHealthCollector();
var instanceProbe = CreateTestProbe();
var options = Options();
var alarm = ActorOfAsTestActorRef<AlarmActor>(
Props.Create(() => new AlarmActor(
"Flapper", "Inst1", instanceProbe.Ref,
new ResolvedAlarm
{
CanonicalName = "Flapper",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
PriorityLevel = 100
},
BlockingScript(), _sharedLibrary, options, NullLogger<AlarmActor>.Instance,
null, null, health, new SingleServiceProvider(siteLog), null, _scheduler, null)),
"alarm-shed-" + Guid.NewGuid().ToString("N"));
// Each raise edge spawns one on-trigger run; clear between raises to re-arm the edge.
void Flap(int cycle)
{
alarm.Tell(new AttributeValueChanged(
"Inst1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow.AddSeconds(cycle)));
alarm.Tell(new AttributeValueChanged(
"Inst1", "Status", "Status", "Normal", "Good", DateTimeOffset.UtcNow.AddSeconds(cycle)));
}
for (var i = 0; i < 4; i++) Flap(i);
AwaitAssert(() => Assert.Equal(4, alarm.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
// Fifth raise is shed — there is no Ask caller on this path, so it surfaces purely as
// a counter plus the rate-limited Warning event.
Flap(4);
Flap(5);
AwaitAssert(() =>
{
var shedEvents = siteLog.OfType("script")
.Where(r => r.Severity == "Warning" && r.Message.Contains("shed"))
.ToArray();
Assert.Single(shedEvents);
Assert.Equal("AlarmActor:Flapper", shedEvents[0].Source);
}, TimeSpan.FromSeconds(10));
Assert.Equal(4, alarm.UnderlyingActor.RunsInFlight);
Assert.Equal(2, health.CollectReport("site-1").ScriptRunShedCount);
}
}
/// <summary>Test hook used to hold script runs in flight while the cap is exercised.</summary>
public static class ShedHooks
{
/// <summary>Gate the blocking test scripts wait on; reset per test.</summary>
public static SemaphoreSlim Gate = new(0);
}
@@ -0,0 +1,195 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 4 — the stuck-script watchdog now REPLACES the thread a wedged script is
/// holding, not just names it.
///
/// <para>Before WP3.1 the watchdog was observability only: a script blocked in synchronous,
/// uninterruptible I/O never observes the cooperative cancellation the timeout requests, so
/// the pool simply lost that dedicated thread — permanently, and silently apart from one log
/// line. Eight such scripts left the site with no script execution at all. The watchdog now
/// detaches the worker (it exits when its body finally returns) and starts a replacement, and
/// the count of live detached workers is surfaced on the site health report as
/// <c>DetachedScriptThreads</c>. Replacement is capped at the pool size, because bounded
/// starvation beats unbounded thread growth when scripts wedge en masse.</para>
/// </summary>
public class StuckScriptWatchdogTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
public StuckScriptWatchdogTests()
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
WatchdogHooks.Gate = new SemaphoreSlim(0);
}
void IDisposable.Dispose()
{
// Free any still-wedged worker before tearing down, so no test leaves a blocked thread.
WatchdogHooks.Gate.Release(8);
Shutdown();
}
private static Script<object?> CompileRaw(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
typeof(WatchdogHooks).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
/// <summary>A body that blocks its worker thread outright — cooperative cancellation is never observed.</summary>
private static Script<object?> WedgeScript() => CompileRaw(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.WatchdogHooks.Gate.Wait(); return null;");
private IActorRef BuildScriptActor(
string name,
Script<object?> compiled,
SiteRuntimeOptions options,
ScriptExecutionScheduler scheduler,
IServiceProvider? serviceProvider)
{
var instance = CreateTestProbe().Ref;
var config = new ResolvedScript { CanonicalName = name, TriggerType = "Call" };
return ActorOf(Props.Create(() => new ScriptActor(
name, "Inst1", instance, compiled, config, _sharedLibrary, options,
NullLogger<ScriptActor>.Instance, null, null, null, serviceProvider, scheduler, null)));
}
private static SiteRuntimeOptions WatchdogOptions() => new()
{
ScriptExecutionTimeoutSeconds = 1,
StuckScriptGraceMs = 200,
MaxScriptCallDepth = 10
};
[Fact]
public void WedgedScript_DetachesItsWorker_StartsAReplacement_AndDrainsWhenFreed()
{
using var scheduler = new ScriptExecutionScheduler(1);
var siteLog = new FakeSiteEventLogger();
var options = WatchdogOptions();
var wedged = BuildScriptActor("Wedged", WedgeScript(), options, scheduler,
new SingleServiceProvider(siteLog));
wedged.Tell(new ScriptCallRequest("Wedged", null, 0, "corr-wedge"), ActorRefs.NoSender);
// Timeout (1 s) + grace (200 ms) later the watchdog fires: the worker is detached and
// a replacement thread starts.
AwaitAssert(
() => Assert.Equal(1, scheduler.DetachedThreadCount),
TimeSpan.FromSeconds(15));
AwaitAssert(
() => Assert.Contains(siteLog.OfType("script"), r =>
r.Severity == "Error" &&
r.Message.Contains("still executing", StringComparison.OrdinalIgnoreCase) &&
r.Message.Contains("Wedged") &&
r.Message.Contains("DETACHED")),
TimeSpan.FromSeconds(5));
// The replacement worker is live: a fresh script runs even though the pool's only
// original thread is still blocked. Before WP3.1 this reply never came.
var healthy = BuildScriptActor("Healthy", CompileRaw("return 7;"), options, scheduler, null);
var caller = CreateTestProbe();
healthy.Tell(new ScriptCallRequest("Healthy", null, 0, "corr-healthy"), caller.Ref);
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(15));
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal(7, result.ReturnValue);
// Freeing the wedged body lets the detached worker exit rather than pull more work,
// so the pool does not silently end up double-sized.
WatchdogHooks.Gate.Release();
AwaitAssert(
() => Assert.Equal(0, scheduler.DetachedThreadCount),
TimeSpan.FromSeconds(15));
Assert.Equal(1, scheduler.MaximumConcurrencyLevel);
}
[Fact]
public void AtTheDetachCap_NoFurtherReplacementIsStarted_AndAnErrorEventIsEmitted()
{
// A one-thread pool caps live detached workers at one, so the SECOND wedge cannot be
// replaced — the deliberate "bounded starvation beats unbounded thread growth" trade.
using var scheduler = new ScriptExecutionScheduler(1);
var siteLog = new FakeSiteEventLogger();
var options = WatchdogOptions();
var first = BuildScriptActor("WedgeOne", WedgeScript(), options, scheduler,
new SingleServiceProvider(siteLog));
first.Tell(new ScriptCallRequest("WedgeOne", null, 0, "corr-w1"), ActorRefs.NoSender);
AwaitAssert(() => Assert.Equal(1, scheduler.DetachedThreadCount), TimeSpan.FromSeconds(15));
var second = BuildScriptActor("WedgeTwo", WedgeScript(), options, scheduler,
new SingleServiceProvider(siteLog));
second.Tell(new ScriptCallRequest("WedgeTwo", null, 0, "corr-w2"), ActorRefs.NoSender);
AwaitAssert(
() => Assert.Contains(siteLog.OfType("script"), r =>
r.Severity == "Error" &&
r.Message.Contains("WedgeTwo") &&
r.Message.Contains("at cap", StringComparison.OrdinalIgnoreCase)),
TimeSpan.FromSeconds(20));
// Still exactly one detached worker: the second wedge was NOT replaced.
Assert.Equal(1, scheduler.DetachedThreadCount);
WatchdogHooks.Gate.Release(2);
}
[Fact]
public async Task DetachedThreadCount_IsSurfacedOnTheSiteHealthReport()
{
using var scheduler = new ScriptExecutionScheduler(1);
var collector = new SiteHealthCollector();
var wedged = BuildScriptActor("GaugeWedge", WedgeScript(), WatchdogOptions(), scheduler, null);
wedged.Tell(new ScriptCallRequest("GaugeWedge", null, 0, "corr-gauge"), ActorRefs.NoSender);
AwaitAssert(() => Assert.Equal(1, scheduler.DetachedThreadCount), TimeSpan.FromSeconds(15));
using var reporter = new ScriptSchedulerStatsReporter(
collector, WatchdogOptions(), NullLogger<ScriptSchedulerStatsReporter>.Instance,
pollInterval: TimeSpan.FromMilliseconds(50), scheduler: scheduler);
await reporter.StartAsync(CancellationToken.None);
try
{
AwaitAssert(
() => Assert.Equal(1, collector.CollectReport("site-1").DetachedScriptThreads),
TimeSpan.FromSeconds(10));
}
finally
{
await reporter.StopAsync(CancellationToken.None);
WatchdogHooks.Gate.Release();
}
}
}
/// <summary>
/// Test hook the stuck-script watchdog tests use to block a script-execution thread
/// deterministically: the compiled body waits on <see cref="Gate"/>, which the test releases
/// once it has observed the detach.
/// </summary>
public static class WatchdogHooks
{
/// <summary>Gate a blocking test script waits on; reset per test.</summary>
public static SemaphoreSlim Gate = new(0);
}
@@ -0,0 +1,196 @@
using System.Diagnostics;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test groups 1 and 2 — the regression pin for arch-review finding #4 (High).
///
/// <para><b>The finding.</b> Trigger-expression evaluation used to be queued onto the same
/// fixed 8-thread <see cref="ScriptExecutionScheduler"/> that runs blocking script bodies. Eight
/// scripts blocked in synchronous I/O therefore stalled EVERY Expression trigger on the node —
/// scripts and alarms alike — for an unbounded time. Worse, the evaluation's 2 s timeout was
/// constructed INSIDE the queued body, so it did not start until the evaluation was dequeued:
/// the operator saw neither a raise nor a timeout, just silence.</para>
///
/// <para><b>The fix these tests pin.</b> Evaluations are non-blocking by construction
/// (<see cref="TriggerExpressionGlobals"/> exposes only reads over an in-memory snapshot, and
/// the trust gate has already denied I/O), so they run as plain async work on the shared .NET
/// thread pool behind <see cref="TriggerEvalGate"/> — never on the blocking pool. And their
/// deadline is armed at ENQUEUE, so gate-wait time burns the same budget.</para>
/// </summary>
public class TriggerEvalStarvationTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
public TriggerEvalStarvationTests()
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
StarvationHooks.Gate = new SemaphoreSlim(0);
}
void IDisposable.Dispose()
{
StarvationHooks.Gate.Release(32);
Shutdown();
}
private static Script<object?> CompileRaw(string code)
{
var options = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
typeof(StarvationHooks).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, options, typeof(ScriptGlobals));
script.Compile();
return script;
}
private static Script<object?> CompileTriggerExpression(string expression)
{
var options = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(expression, options, typeof(TriggerExpressionGlobals));
script.Compile();
return script;
}
/// <summary>
/// GROUP 1 — the finding-#4 regression pin. Eight script bodies blocked on a semaphore
/// occupy every thread of an 8-thread pool; an Expression-triggered alarm must still raise
/// in well under 2 s.
///
/// <para>On the pre-WP3.1 wiring this test does not merely fail slowly — it never
/// completes: the evaluation sits in the same FIFO behind eight bodies that only unblock
/// after the assertion window, and its 2 s timeout has not even started ticking.</para>
/// </summary>
[Fact]
public void EightBlockedScripts_DoNotDelayAnAlarmsExpressionEvaluation()
{
using var scheduler = new ScriptExecutionScheduler(8);
using var evalGate = new TriggerEvalGate(4);
// Occupy every thread of the blocking pool with a real script run.
var wedge = CompileRaw(
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.StarvationHooks.Gate.Wait(); return null;");
for (var i = 0; i < 8; i++)
{
var name = $"Blocker{i}";
var config = new ResolvedScript { CanonicalName = name, TriggerType = "Call" };
var instance = CreateTestProbe().Ref;
var actor = ActorOf(Props.Create(() => new ScriptActor(
name, "Inst1", instance, wedge, config, _sharedLibrary, new SiteRuntimeOptions(),
NullLogger<ScriptActor>.Instance, null, null, null, null, scheduler, evalGate)));
actor.Tell(new ScriptCallRequest(name, null, 0, $"corr-block-{i}"), ActorRefs.NoSender);
}
AwaitAssert(
() => Assert.Equal(8, scheduler.BusyThreadCount),
TimeSpan.FromSeconds(15));
// Now fire an Expression-triggered alarm through the SAME scheduler seam.
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"ExprAlarm", "Inst1", instanceProbe.Ref,
new ResolvedAlarm
{
CanonicalName = "ExprAlarm",
TriggerType = "Expression",
TriggerConfiguration = "{\"expression\":\"true\"}",
PriorityLevel = 900
},
null, _sharedLibrary, new SiteRuntimeOptions(), NullLogger<AlarmActor>.Instance,
CompileTriggerExpression("true"), null, null, null, null, scheduler, evalGate)));
var stopwatch = Stopwatch.StartNew();
alarm.Tell(new AttributeValueChanged(
"Inst1", "Temp", "Temp", 99.0, "Good", DateTimeOffset.UtcNow));
var raised = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(2));
stopwatch.Stop();
Assert.Equal(AlarmState.Active, raised.State);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2),
$"alarm raised only after {stopwatch.Elapsed} — the evaluation queued behind blocked script bodies");
// The pool really was saturated for the whole window; the eval simply never used it.
Assert.Equal(8, scheduler.BusyThreadCount);
}
/// <summary>
/// GROUP 2 — the evaluation deadline is measured from ENQUEUE, so time spent waiting on a
/// saturated <see cref="TriggerEvalGate"/> burns the same budget. With the gate fully
/// occupied for the whole assertion window, a queued evaluation must still resolve (as
/// false) at its timeout rather than stalling indefinitely — and the trigger must drain
/// rather than park, so the next change still evaluates.
/// </summary>
[Fact]
public async Task SaturatedEvalGate_ResolvesTheQueuedEvaluationAtItsEnqueueAnchoredDeadline()
{
using var scheduler = new ScriptExecutionScheduler(1);
using var evalGate = new TriggerEvalGate(1);
var options = new SiteRuntimeOptions { TriggerEvalTimeoutSeconds = 1 };
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"ExprAlarm", "Inst1", instanceProbe.Ref,
new ResolvedAlarm
{
CanonicalName = "ExprAlarm",
TriggerType = "Expression",
TriggerConfiguration = "{\"expression\":\"true\"}",
PriorityLevel = 500
},
null, _sharedLibrary, options, NullLogger<AlarmActor>.Instance,
CompileTriggerExpression("true"), null, null, null, null, scheduler, evalGate)));
// 1. Free gate: the expression evaluates true and the alarm raises.
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 1, "Good", DateTimeOffset.UtcNow));
var raised = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
Assert.Equal(AlarmState.Active, raised.State);
// 2. Occupy the only permit for the whole of the next step.
await evalGate.WaitAsync(CancellationToken.None);
Assert.Equal(0, evalGate.AvailablePermits);
// 3. The next evaluation can never acquire the gate. Its deadline was armed at
// enqueue, so it cancels at ~1 s, is treated as false, and clears the alarm.
// Pre-WP3.1 the clock started at dequeue, so this would hang forever.
var stopwatch = Stopwatch.StartNew();
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 2, "Good", DateTimeOffset.UtcNow));
var cleared = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
stopwatch.Stop();
Assert.Equal(AlarmState.Normal, cleared.State);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(6),
$"queued evaluation took {stopwatch.Elapsed} to resolve — the deadline is not enqueue-anchored");
Assert.Equal(0, evalGate.AvailablePermits); // still held: it genuinely never ran
// 4. Not parked: releasing the gate and sending another change evaluates again.
evalGate.Release();
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 3, "Good", DateTimeOffset.UtcNow));
var reRaised = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
Assert.Equal(AlarmState.Active, reRaised.State);
}
}
/// <summary>Test hook used to block script-execution worker threads deterministically.</summary>
public static class StarvationHooks
{
/// <summary>Gate the blocking test scripts wait on; reset per test.</summary>
public static SemaphoreSlim Gate = new(0);
}
@@ -20,7 +20,6 @@ public class AlarmsAccessorTests : TestKit, IDisposable
{
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
new(
instanceActor,
instanceActor,
sharedScriptLibrary: null!,
currentCallDepth: 0,
@@ -75,7 +75,6 @@ public class ExecutionCorrelationContextTests
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
@@ -83,7 +83,6 @@ public class ParentExecutionTreeTests : TestKit
{
return new ScriptRuntimeContext(
instanceActor,
ActorRefs.Nobody,
library,
currentCallDepth: 0,
maxCallDepth: 10,
@@ -32,7 +32,6 @@ public class RecursionLimitSiteEventTests
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: maxCallDepth, // already AT the limit
@@ -156,7 +156,6 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
{
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
new(
instanceActor,
instanceActor,
sharedScriptLibrary: null!,
currentCallDepth: 0,
@@ -0,0 +1,103 @@
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// WP3.1 test group 5 — the blocking script pool is no longer a fixed 8 threads forever. It
/// scales with the number of running instances between a configured floor and ceiling, and it
/// is deliberately GROW-ONLY: undeploying instances leaves idle threads (which cost nothing
/// measurable) rather than paying for drain/steal complexity.
/// </summary>
public class ScriptPoolSizingTests
{
private static SiteRuntimeOptions Options(int floor = 8, int ceiling = 32) => new()
{
ScriptExecutionThreadCount = floor,
ScriptExecutionMaxThreadCount = ceiling
};
[Theory]
// At or below floor * 8 instances the result is exactly the pre-WP3.1 fixed size —
// existing configurations are byte-for-byte unchanged in behaviour.
[InlineData(0, 8)]
[InlineData(1, 8)]
[InlineData(64, 8)]
// Past that, one thread per 8 instances, rounding up.
[InlineData(65, 9)]
[InlineData(72, 9)]
[InlineData(200, 25)]
// …clamped at the ceiling.
[InlineData(256, 32)]
[InlineData(10_000, 32)]
public void ComputeTargetThreads_AppliesFloorRatioAndCeiling(int instances, int expected)
=> Assert.Equal(expected, ScriptExecutionScheduler.ComputeTargetThreads(instances, Options()));
[Fact]
public void ComputeTargetThreads_HonoursAnOverriddenFloorAndCeiling()
{
var options = Options(floor: 2, ceiling: 4);
Assert.Equal(2, ScriptExecutionScheduler.ComputeTargetThreads(0, options));
Assert.Equal(2, ScriptExecutionScheduler.ComputeTargetThreads(16, options));
Assert.Equal(3, ScriptExecutionScheduler.ComputeTargetThreads(17, options));
Assert.Equal(4, ScriptExecutionScheduler.ComputeTargetThreads(1000, options));
}
[Fact]
public void ComputeTargetThreads_NeverReturnsLessThanOne_EvenWithADegenerateFloor()
{
// The validator rejects these, but a directly-constructed options object must still
// not produce a zero-thread scheduler.
var options = new SiteRuntimeOptions { ScriptExecutionThreadCount = 0, ScriptExecutionMaxThreadCount = 0 };
Assert.Equal(1, ScriptExecutionScheduler.ComputeTargetThreads(0, options));
Assert.Equal(1, ScriptExecutionScheduler.ComputeTargetThreads(500, options));
}
[Fact]
public void EnsureCapacity_GrowsOnce_IsIdempotent_AndNeverShrinks()
{
using var scheduler = new ScriptExecutionScheduler(2);
Assert.Equal(2, scheduler.MaximumConcurrencyLevel);
Assert.Equal(5, scheduler.EnsureCapacity(5));
Assert.Equal(5, scheduler.MaximumConcurrencyLevel);
// Idempotent: asking for the same target again changes nothing.
Assert.Equal(5, scheduler.EnsureCapacity(5));
Assert.Equal(5, scheduler.MaximumConcurrencyLevel);
// Grow-only: a smaller target is a no-op, not a shrink.
Assert.Equal(5, scheduler.EnsureCapacity(1));
Assert.Equal(5, scheduler.MaximumConcurrencyLevel);
}
[Fact]
public async Task EnsureCapacity_WidensTheGauges_SoTheWholePoolIsObservable()
{
using var scheduler = new ScriptExecutionScheduler(1);
scheduler.EnsureCapacity(3);
using var gate = new ManualResetEventSlim(false);
var blocking = Enumerable.Range(0, 3)
.Select(_ => Task.Factory.StartNew(() => gate.Wait(),
CancellationToken.None, TaskCreationOptions.None, scheduler))
.ToArray();
// All three grown workers report busy — the bookkeeping widened with the pool.
await WaitUntilAsync(() => scheduler.BusyThreadCount == 3);
Assert.Equal(0, scheduler.QueueDepth);
Assert.NotNull(scheduler.OldestBusyAge);
gate.Set();
await Task.WhenAll(blocking);
await WaitUntilAsync(() => scheduler.BusyThreadCount == 0);
Assert.Null(scheduler.OldestBusyAge);
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
for (var i = 0; i < 200 && !condition(); i++)
await Task.Delay(25);
Assert.True(condition(), "condition not met within timeout");
}
}
@@ -43,7 +43,7 @@ public class SiteScriptCompileCacheTests
}
[Fact]
public void Overflow_ClearsWholesale()
public void Overflow_StaysWithinTheBound()
{
SiteScriptCompileCache.Clear();
for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++)
@@ -51,4 +51,88 @@ public class SiteScriptCompileCacheTests
Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries);
}
// ── WP3.1: approximate LRU replaced the wholesale-Clear overflow cliff ──────────
/// <summary>
/// WP3.1 (test group 8): overflow must evict only the OLDEST batch, keeping hot entries.
/// The old behaviour cleared all 1024 entries, so the very next deploy or Instance-Actor
/// start paid a full recompile storm on actor threads.
/// </summary>
[Fact]
public void Overflow_EvictsOldestBatch_AndKeepsRecentlyTouchedEntries()
{
// Note: the cache is process-wide static and other test classes compile into it
// concurrently, so the assertions below are phrased as properties of the eviction
// policy (what survives, what does not, nothing is wiped) rather than as exact
// counts, which no test can own here.
SiteScriptCompileCache.Clear();
// Fill to the bound. Entries 1..N are inserted oldest-first.
for (var i = 0; i < SiteScriptCompileCache.MaxEntries; i++)
SiteScriptCompileCache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok);
// Touch entry 0 so it carries the NEWEST access stamp despite being inserted first.
var hot = SiteScriptCompileCache.GetOrAdd("return 0;", typeof(ScriptGlobals), Ok);
var hitsAfterTouch = SiteScriptCompileCache.Hits;
// Push well past the bound so at least one eviction sweep definitely runs.
for (var i = 0; i < 200; i++)
SiteScriptCompileCache.GetOrAdd($"return overflow{i};", typeof(ScriptGlobals), Ok);
// Nothing was cleared wholesale: the cache is still most of the way full and the hit
// counter survived (Clear() would have reset it to 0).
Assert.True(SiteScriptCompileCache.Count > SiteScriptCompileCache.MaxEntries / 2,
$"cache collapsed to {SiteScriptCompileCache.Count} entries — this looks like a wholesale clear");
Assert.True(SiteScriptCompileCache.Hits >= hitsAfterTouch);
// The touched entry survived the sweep — a hit, not a recompile. This is the LRU
// property: recency, not insertion order, decides what stays.
var hitsBefore = SiteScriptCompileCache.Hits;
var again = SiteScriptCompileCache.GetOrAdd("return 0;", typeof(ScriptGlobals),
() => throw new InvalidOperationException("hot entry was evicted — LRU is not keeping recently-used entries"));
Assert.Same(hot, again);
Assert.Equal(hitsBefore + 1, SiteScriptCompileCache.Hits);
// …and untouched old entries genuinely were evicted, so the sweep really ran.
var recomputed = 0;
for (var i = 1; i <= 16; i++)
{
var code = $"return {i};";
SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
() => { recomputed++; return Ok(); });
}
Assert.True(recomputed > 0, "no old entry was evicted — the overflow sweep did not run");
}
/// <summary>
/// WP3.1: concurrent inserts crossing the bound together must not blow past it — the
/// eviction sweep is double-checked under its own lock precisely so a stampede performs
/// one sweep rather than one per racing thread.
/// </summary>
[Fact]
public async Task ConcurrentGetOrAddStorm_KeepsTheBound()
{
SiteScriptCompileCache.Clear();
const int perTask = 400;
var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() =>
{
for (var i = 0; i < perTask; i++)
SiteScriptCompileCache.GetOrAdd($"return {t}_{i};", typeof(ScriptGlobals), Ok);
}));
await Task.WhenAll(tasks);
// A small transient overshoot is by design: threads that lose the double-checked
// eviction race still insert their own entry afterwards, so the ceiling is "bounded",
// not "never exceeded by one". The property under test is that 3200 distinct inserts
// do not accumulate — before WP3.1 this was a wholesale Clear(), and a per-entry
// eviction bug would show up here as unbounded growth, not as a handful of extra rows.
// (Other test classes compile into this process-wide cache concurrently, which is the
// other reason the bound is asserted with slack rather than exactly.)
Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries + 512,
$"cache grew past its bound: {SiteScriptCompileCache.Count} vs max {SiteScriptCompileCache.MaxEntries} " +
$"after {8 * perTask} distinct inserts");
}
}
@@ -84,4 +84,64 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("AlarmPublishQueueCapacity", result.FailureMessage);
}
// ── WP3.1 options ──────────────────────────────────────────────────────────────
/// <summary>
/// The ceiling is the upper bound of the instance-scaled pool; below the floor it would
/// silently shrink the configured pool instead of growing it.
/// </summary>
[Fact]
public void ScriptExecutionMaxThreadCountBelowThreadCount_IsRejected()
{
var result = Validate(new SiteRuntimeOptions
{
ScriptExecutionThreadCount = 8,
ScriptExecutionMaxThreadCount = 4
});
Assert.True(result.Failed);
Assert.Contains("ScriptExecutionMaxThreadCount", result.FailureMessage);
}
[Fact]
public void EqualScriptExecutionThreadCountAndMax_IsAccepted()
{
var result = Validate(new SiteRuntimeOptions
{
ScriptExecutionThreadCount = 8,
ScriptExecutionMaxThreadCount = 8
});
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>A zero gate would park every Expression trigger on the node forever.</summary>
[Fact]
public void ZeroTriggerEvalMaxConcurrency_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { TriggerEvalMaxConcurrency = 0 });
Assert.True(result.Failed);
Assert.Contains("TriggerEvalMaxConcurrency", result.FailureMessage);
}
[Fact]
public void ZeroTriggerEvalTimeoutSeconds_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { TriggerEvalTimeoutSeconds = 0 });
Assert.True(result.Failed);
Assert.Contains("TriggerEvalTimeoutSeconds", result.FailureMessage);
}
/// <summary>A zero cap would shed every trigger, silently disabling all scripts.</summary>
[Fact]
public void ZeroMaxConcurrentRunsPerScript_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { MaxConcurrentRunsPerScript = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentRunsPerScript", result.FailureMessage);
}
}
@@ -0,0 +1,48 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
/// <summary>
/// WP3.1 parity pin 3: an <see cref="IServiceProvider"/> that counts
/// <see cref="IServiceScopeFactory.CreateScope"/> calls and each scope's
/// <see cref="IDisposable.Dispose"/>, so a test can assert that one script run creates
/// exactly one DI scope and disposes it exactly once — on the success, failure, AND timeout
/// paths alike. Removing the per-run execution actor moved the scope's <c>finally</c> into
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher"/>; a scope leaked
/// there would silently leak every scoped service a script touches.
/// </summary>
public sealed class ScopeSpyServiceProvider(ISiteEventLogger? logger = null)
: IServiceProvider, IServiceScopeFactory
{
private int _scopesCreated;
private int _scopesDisposed;
/// <summary>Scopes created so far.</summary>
public int ScopesCreated => Volatile.Read(ref _scopesCreated);
/// <summary>Scope disposals observed so far (double-disposal is counted twice, deliberately).</summary>
public int ScopesDisposed => Volatile.Read(ref _scopesDisposed);
/// <inheritdoc />
public object? GetService(Type serviceType)
{
if (serviceType == typeof(ISiteEventLogger)) return logger;
if (serviceType == typeof(IServiceScopeFactory)) return this;
return null;
}
/// <inheritdoc />
public IServiceScope CreateScope()
{
Interlocked.Increment(ref _scopesCreated);
return new SpyScope(this);
}
private sealed class SpyScope(ScopeSpyServiceProvider owner) : IServiceScope
{
public IServiceProvider ServiceProvider => owner;
public void Dispose() => Interlocked.Increment(ref owner._scopesDisposed);
}
}