Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs
T

807 lines
43 KiB
C#

using System.Diagnostics;
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
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>
/// <param name="trace">Flow Debugger trace flag (design 2026-09-05 §5.2): when true the run emits <c>ScriptTrace</c> rows and returns them inline on the <see cref="ScriptCallResult"/>.</param>
/// <param name="triggerExtra">Flow Debugger: trigger detail for a traced value-change run (stamped into the <c>ScriptRun</c> row's <c>Extra</c>); null for calls.</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,
bool trace = false,
ScriptTraceExtra? triggerExtra = null)
{
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, trace, triggerExtra),
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="healthCollector">
/// Optional health collector for recording execution metrics (arch-review 2026-08-19
/// P1-3): the alarm on-trigger run shares the script-error counter with
/// <see cref="RunScriptAsync"/> so a wedged/failed alarm body is visible the same way a
/// wedged/failed instance script is.
/// </param>
/// <param name="serviceProvider">
/// Optional DI service provider (arch-review 2026-08-19 P1-3): resolves the singleton
/// <see cref="ISiteEventLogger"/> (stuck-watchdog reporting) and
/// <see cref="ScriptRunSummaryRecorder"/> (aggregate run counters), exactly like the
/// instance-script path.
/// </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,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider,
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,
healthCollector, serviceProvider, runId, parentExecutionId, timeout,
executionScheduler, 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,
bool trace = false,
ScriptTraceExtra? triggerExtra = null)
{
IServiceScope? serviceScope = null;
// Flow Debugger (design §5.4): the inline sink exists only for a traced run that has a
// caller to reply to — a trace-armed trigger run (replyTo == Nobody) writes every row to the
// audit writer for PullTraceEvents and would only keep a never-read list alive. The
// ScriptRun row itself is emitted below on every exit path of a traced run.
// Arch-review F1: the inline copy is redacted at the collector, so the collector carries
// the host's IAuditRedactor. That is a SINGLETON (AddAuditLog), resolved here from the
// ROOT provider for the same reason siteEventLogger below is — the traced ScriptRun row
// is emitted from the catch blocks too, including the shed-at-dequeue path that throws
// before the DI scope exists. Null in tests / hosts without AddAuditLog: raw, still capped.
var collector = trace && !replyTo.IsNobody()
? new FlowTraceCollector(
redactor: serviceProvider?.GetService<ZB.MOM.WW.Audit.IAuditRedactor>())
: null;
var startedUtc = DateTime.UtcNow;
ScriptRuntimeContext? context = null;
IAuditWriter? auditWriter = null;
var siteId = string.Empty;
string? sourceNode = 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.2 (site_events volume policy): both are singletons, resolved from the root
// provider up front so they too are available to the catch blocks. optionsMonitor is
// null in tests that pass no serviceProvider (SingleServiceProvider et al.) — the
// constructor-passed `options` is the fallback, exactly like siteEventLogger's null
// path degrades to "no site event log". Reading CurrentValue HERE (once per run, at
// the top) rather than re-reading it at each call site is what "hot-togglable" means
// in practice: an operator's live edit to PerRunScriptEvents/PerRunScriptEventScripts
// takes effect on the NEXT run, without a restart.
var summaryRecorder = serviceProvider?.GetService<ScriptRunSummaryRecorder>();
var optionsMonitor = serviceProvider?.GetService<IOptionsMonitor<SiteRuntimeOptions>>();
var effectiveOptions = optionsMonitor?.CurrentValue ?? options;
var emitPerRunEvents = ScriptRunEventPolicy.ShouldEmitPerRun(effectiveOptions, instanceName, scriptName);
var runStopwatch = Stopwatch.StartNew();
// 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;
// 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.
// (siteId / auditWriter / sourceNode are declared above the try so the
// traced ScriptRun emission in the catch blocks can reach them.)
// 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.
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;
}
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,
// Flow Debugger (design §5.2/§5.4): the trace flag and the inline sink.
isTraced: trace,
traceCollector: collector);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
CancellationToken = cts.Token,
Scope = scope
};
// WP3.2: the aggregate ALWAYS records this run (that's the point of the interval
// summary), but the per-run Info event itself is gated by ScriptRunEventPolicy —
// off by default, on globally via PerRunScriptEvents, or on for named scripts via
// PerRunScriptEventScripts. 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, which stays unconditional regardless of this policy.
summaryRecorder?.RecordStarted(instanceName, scriptName);
if (emitPerRunEvents)
{
_ = siteEventLogger?.LogEventAsync(
"script", "Info", instanceName, $"ScriptActor:{scriptName}",
$"Script '{scriptName}' on instance '{instanceName}' started");
}
var state = await compiledScript.RunAsync(globals, cts.Token);
// Flow Debugger: the ScriptRun row goes into the collector BEFORE the reply so the
// inline list the caller receives includes the run itself. Complete() (not Snapshot)
// closes the sink at the same instant — outbound rows finishing after this point
// (plan 2026-09-06 D5) go to the audit writer only.
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, state.ReturnValue, error: null, triggerExtra);
// Send result to requester if this was an Ask-based call
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(
correlationId, true, state.ReturnValue, null,
context.ExecutionId, collector?.Complete()));
}
// Operational `script` event — execution completed successfully. Same gating as
// the started event above.
summaryRecorder?.RecordCompleted(instanceName, scriptName, runStopwatch.ElapsedMilliseconds);
if (emitPerRunEvents)
{
_ = 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();
summaryRecorder?.RecordTimedOut(instanceName, scriptName, runStopwatch.ElapsedMilliseconds);
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. WP3.2:
// Error-level emissions are unconditional — never gated by ScriptRunEventPolicy.
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, null, errorMsg, triggerExtra);
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(
correlationId, false, null, errorMsg, context?.ExecutionId, collector?.Complete()));
}
completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
catch (Exception ex)
{
healthCollector?.IncrementScriptError();
summaryRecorder?.RecordFailed(instanceName, scriptName, runStopwatch.ElapsedMilliseconds);
// Failures recorded to site event log; script NOT disabled after failure. WP3.2:
// Error-level emissions are unconditional — never gated by ScriptRunEventPolicy.
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());
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, null, errorMsg, triggerExtra);
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(
correlationId, false, null, errorMsg, context?.ExecutionId, collector?.Complete()));
}
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();
}
}
/// <summary>
/// Arch-review F7: a traced run that never launched (shed at the in-flight cap, or
/// suppressed by <c>MinTimeBetweenRuns</c>) still leaves a <c>Failed</c>
/// <c>ScriptTrace.ScriptRun</c> row, so the Flow Debugger explains the gap with the guard
/// that refused the run instead of reporting <c>NO_TRIGGER_WITHIN_WINDOW</c> — which reads
/// as "no value-change trigger on this attribute, or no device echo" and sends the operator
/// to check the wrong things.
///
/// <para>There is no collector: a shed run has no reply to ride on (an Ask caller gets the
/// shed <c>ScriptCallResult</c> from <c>ScriptActor.ShedRun</c>, not a trace list), so the
/// row goes to the audit writer only and reaches the debugger via <c>PullTraceEvents</c> /
/// Replay. The writer and identity providers come from a fresh scope because this runs on
/// the ACTOR thread, outside any launch scope. <c>elapsedMs</c> is 0 — the run never ran.
/// Best-effort and never throws: a no-op when there is no provider or no writer.</para>
/// </summary>
internal static void EmitSuppressedRun(
IServiceProvider? serviceProvider,
ILogger logger,
string scriptName,
string instanceName,
Guid? parentExecutionId,
string reason,
ScriptTraceExtra? triggerExtra)
{
if (serviceProvider is null) return;
try
{
using var scope = serviceProvider.CreateScope();
var writer = scope.ServiceProvider.GetService<IAuditWriter>();
if (writer is null) return;
var siteId = scope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId ?? string.Empty;
var sourceNode = scope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
EmitScriptRun(
trace: true, collector: null, writer, logger, scriptName, instanceName, siteId, sourceNode,
executionId: Guid.NewGuid(), parentExecutionId, DateTime.UtcNow, elapsedMs: 0,
parameters: null, returnValue: null, error: reason, triggerExtra);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Flow trace suppressed-run emission failed for {Script}", scriptName);
}
}
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.1): emits the <c>ScriptTrace.ScriptRun</c> row for a
/// traced run — collector first (so the inline reply carries it), then the best-effort
/// audit writer. <c>OccurredAtUtc</c> is the run's START so the row sorts before the
/// writes/waits the run made. No-op when untraced; never throws.
/// </summary>
private static void EmitScriptRun(
bool trace, FlowTraceCollector? collector, IAuditWriter? auditWriter, ILogger logger,
string scriptName, string instanceName, string siteId, string? sourceNode,
Guid? executionId, Guid? parentExecutionId, DateTime startedUtc, long elapsedMs,
IReadOnlyDictionary<string, object?>? parameters, object? returnValue, string? error,
ScriptTraceExtra? triggerExtra)
{
if (!trace) return;
try
{
var sourceScript = $"ScriptActor:{scriptName}";
var evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptRun,
error is null ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
actor: sourceScript, target: scriptName, sourceNode: sourceNode,
executionId: executionId, parentExecutionId: parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(siteId) ? null : siteId,
sourceInstanceId: instanceName, sourceScript: sourceScript,
durationMs: (int)Math.Min(elapsedMs, int.MaxValue),
errorMessage: error,
requestSummary: TraceJson(parameters),
responseSummary: TraceJson(returnValue),
extra: (triggerExtra ?? new ScriptTraceExtra { TriggerKind = "call" }).ToJson());
collector?.Add(evt);
if (auditWriter is null) return;
var t = auditWriter.WriteAsync(evt, CancellationToken.None);
if (!t.IsCompletedSuccessfully)
{
_ = t.ContinueWith(
x => logger.LogWarning(x.Exception, "Flow trace ScriptRun write failed for {Script}", scriptName),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Flow trace ScriptRun emission failed for {Script}", scriptName);
}
}
private static string? TraceJson(object? value)
{
if (value is null) return null;
try { return JsonSerializer.Serialize(value, ScriptTraceExtra.JsonOptions); }
catch (Exception) { return null; }
}
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,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider,
long runId,
Guid? parentExecutionId,
TimeSpan timeout,
ScriptExecutionScheduler executionScheduler,
CancellationTokenSource cts)
{
// arch-review 2026-08-19 P1-3: singletons resolved from the root provider up front,
// exactly like RunScriptAsync, so they are available to the catch blocks regardless
// of scope state. Null in tests that pass no serviceProvider — degrades the same way
// the instance-script path does (no site event log / no aggregate counters).
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
var summaryRecorder = serviceProvider?.GetService<ScriptRunSummaryRecorder>();
// The on-trigger run has no per-script name of its own in the site-event/summary
// sense — key it under the alarm, mirroring the "AlarmActor:{name}" source
// convention already used for this alarm's other site events.
var scriptName = $"AlarmActor:{alarmName}";
var runStopwatch = Stopwatch.StartNew();
// arch-review 2026-08-19 P1-3: identify the worker this body is holding, so the
// stuck-script watchdog can detach and replace it if the body wedges — same
// mechanism, same gauge, as the instance-script path. Read in the FIRST synchronous
// segment, before any await.
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,
$"Alarm on-trigger script for '{alarmName}' on instance '{instanceName}'",
instanceName, scriptName);
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
}
};
summaryRecorder?.RecordStarted(instanceName, scriptName);
await compiledScript.RunAsync(globals, cts.Token);
summaryRecorder?.RecordCompleted(instanceName, scriptName, runStopwatch.ElapsedMilliseconds);
completionTarget.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, true));
}
catch (OperationCanceledException)
{
healthCollector?.IncrementScriptError();
summaryRecorder?.RecordTimedOut(instanceName, scriptName, runStopwatch.ElapsedMilliseconds);
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)
{
healthCollector?.IncrementScriptError();
summaryRecorder?.RecordFailed(instanceName, scriptName, runStopwatch.ElapsedMilliseconds);
// 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
{
// 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);
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);
}));
}
}