perf(site-runtime): ScriptActor trigger expressions evaluate on the script scheduler, coalesced, edge state actor-confined (P1)
This commit is contained in:
@@ -59,6 +59,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
private readonly Script<object?>? _compiledTriggerExpression;
|
||||
private bool _lastExpressionResult;
|
||||
private readonly Dictionary<string, object?> _attributeSnapshot = new();
|
||||
// Coalescing guard for off-dispatcher expression evaluation (P1): at most one
|
||||
// evaluation runs on the script scheduler at a time; a change arriving while
|
||||
// one is in flight sets _evalPending so exactly one more runs afterwards
|
||||
// against the latest snapshot. Both flags are touched only on the actor thread.
|
||||
private bool _evalInFlight;
|
||||
private bool _evalPending;
|
||||
|
||||
// WhileTrue trigger state: the most recent truth value of a Conditional
|
||||
// trigger's comparison, used to detect false->true / true->false edges.
|
||||
@@ -150,6 +156,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
|
||||
// Handle execution completion (for logging/metrics)
|
||||
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
|
||||
|
||||
// Handle the off-dispatcher trigger-expression evaluation result (P1).
|
||||
Receive<ExpressionEvalResult>(HandleExpressionEvalResult);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -254,56 +263,74 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
else if (_triggerConfig is ExpressionTriggerConfig)
|
||||
{
|
||||
EvaluateExpressionTrigger();
|
||||
StartExpressionEvaluation();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the compiled trigger expression against the current attribute
|
||||
/// snapshot. In <see cref="TriggerMode.OnTrue"/> mode the script runs once
|
||||
/// per false→true transition; in <see cref="TriggerMode.WhileTrue"/> mode it
|
||||
/// fires on the edge and the re-fire timer is started/stopped with the
|
||||
/// expression's truth value. A throwing or non-bool expression is treated as
|
||||
/// false and logged as a script error; the actor never crashes.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void EvaluateExpressionTrigger()
|
||||
private void StartExpressionEvaluation()
|
||||
{
|
||||
if (_compiledTriggerExpression == null) return;
|
||||
if (_triggerConfig is not ExpressionTriggerConfig exprConfig) return;
|
||||
if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return;
|
||||
if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending
|
||||
_evalInFlight = true;
|
||||
|
||||
bool result;
|
||||
try
|
||||
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
|
||||
var expression = _compiledTriggerExpression;
|
||||
var self = Self;
|
||||
Task.Factory.StartNew(async () =>
|
||||
{
|
||||
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
|
||||
// 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 are compile-checked pre-deployment.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
var state = _compiledTriggerExpression
|
||||
.RunAsync(globals, cancellationToken: cts.Token)
|
||||
.GetAwaiter().GetResult();
|
||||
result = state.ReturnValue is bool b && b;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// OperationCanceledException (timeout) falls through here too,
|
||||
// and is correctly treated as false.
|
||||
LogExpressionError(ex);
|
||||
result = false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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.
|
||||
LogExpressionError(ex);
|
||||
return false;
|
||||
}
|
||||
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
|
||||
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
|
||||
success: r => new ExpressionEvalResult(r));
|
||||
}
|
||||
|
||||
if (exprConfig.Mode == TriggerMode.WhileTrue)
|
||||
/// <summary>
|
||||
/// Applies an off-dispatcher trigger-expression result on the actor thread:
|
||||
/// runs the OnTrue / WhileTrue edge logic against <see cref="_lastExpressionResult"/>,
|
||||
/// clears the in-flight flag, and drains a coalesced pending evaluation (against
|
||||
/// the now-current snapshot) if one was requested while this one ran.
|
||||
/// </summary>
|
||||
private void HandleExpressionEvalResult(ExpressionEvalResult msg)
|
||||
{
|
||||
_evalInFlight = false;
|
||||
if (_triggerConfig is ExpressionTriggerConfig exprConfig)
|
||||
{
|
||||
HandleWhileTrueTransition(result, _lastExpressionResult);
|
||||
if (exprConfig.Mode == TriggerMode.WhileTrue)
|
||||
HandleWhileTrueTransition(msg.Result, _lastExpressionResult);
|
||||
else if (msg.Result && !_lastExpressionResult)
|
||||
TrySpawnExecution(null);
|
||||
_lastExpressionResult = msg.Result;
|
||||
}
|
||||
else if (result && !_lastExpressionResult)
|
||||
{
|
||||
TrySpawnExecution(null);
|
||||
}
|
||||
|
||||
_lastExpressionResult = result;
|
||||
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -576,6 +603,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Piped back to self from an off-dispatcher trigger-expression evaluation (P1);
|
||||
/// carries the boolean truth value so the OnTrue/WhileTrue edge logic runs on
|
||||
/// the actor thread.
|
||||
/// </summary>
|
||||
private sealed record ExpressionEvalResult(bool Result);
|
||||
}
|
||||
|
||||
// ── Trigger config types ──
|
||||
|
||||
Reference in New Issue
Block a user