perf(runtime): split trigger evals from the blocking pool; bounded, deadline-aware execution
This commit is contained in:
@@ -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);
|
||||
return state.ReturnValue is bool b && b;
|
||||
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,46 +534,123 @@ 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(
|
||||
_scriptName,
|
||||
_instanceName,
|
||||
_compiledScript!,
|
||||
parameters,
|
||||
callDepth,
|
||||
_instanceActor,
|
||||
_sharedScriptLibrary,
|
||||
_options,
|
||||
replyTo,
|
||||
correlationId,
|
||||
_logger,
|
||||
_scope,
|
||||
_healthCollector,
|
||||
_serviceProvider,
|
||||
// (ParentExecutionId): null for trigger-driven runs;
|
||||
// an inbound-API-routed call supplies the inbound request's id.
|
||||
parentExecutionId,
|
||||
// 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));
|
||||
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++;
|
||||
|
||||
Context.ActorOf(props, executionId);
|
||||
try
|
||||
{
|
||||
ScriptRunLauncher.LaunchScript(
|
||||
_scriptName,
|
||||
_instanceName,
|
||||
_compiledScript!,
|
||||
parameters,
|
||||
callDepth,
|
||||
_instanceActor,
|
||||
_sharedScriptLibrary,
|
||||
_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;
|
||||
// an inbound-API-routed call supplies the inbound request's id.
|
||||
parentExecutionId,
|
||||
// Per-script timeout override (null = use global).
|
||||
_executionTimeoutSeconds,
|
||||
// Scheduler seam (#18): thread this actor's scheduler override down so
|
||||
// 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);
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user