perf(site-runtime): ScriptActor trigger expressions evaluate on the script scheduler, coalesced, edge state actor-confined (P1)

This commit is contained in:
Joseph Doherty
2026-07-08 23:50:21 -04:00
parent 467edd74ac
commit 5aee90d155
2 changed files with 110 additions and 39 deletions
@@ -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 ──
@@ -303,6 +303,43 @@ public class ScriptActorTests : TestKit, IDisposable
private Script<object?> CompileTriggerExpression(string expression) =>
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
/// <summary>
/// Compiles a trigger expression WITHOUT the trust validator so a test can use
/// <c>System.Threading.Thread</c> (forbidden in real scripts) to observe which
/// thread the evaluation runs on. Mirrors the real compile against
/// <see cref="TriggerExpressionGlobals"/>.
/// </summary>
private static Script<object?> CompileRawTriggerExpression(string expression)
{
var opts = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var s = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
s.Compile();
return s;
}
[Fact]
public void ExpressionTrigger_EvaluatesOnScriptSchedulerThread_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.
var expr = CompileRawTriggerExpression(
"System.Threading.Thread.CurrentThread.Name != null && " +
"System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
var (actor, instance) = CreateTriggeredActor(
"ExprThread",
"Expression",
"{\"expression\":\"true\",\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null,
expr);
actor.Tell(Change("Any", "1"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off-dispatcher
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
{