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 readonly Script<object?>? _compiledTriggerExpression;
|
||||||
private bool _lastExpressionResult;
|
private bool _lastExpressionResult;
|
||||||
private readonly Dictionary<string, object?> _attributeSnapshot = new();
|
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
|
// WhileTrue trigger state: the most recent truth value of a Conditional
|
||||||
// trigger's comparison, used to detect false->true / true->false edges.
|
// 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)
|
// Handle execution completion (for logging/metrics)
|
||||||
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
|
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
|
||||||
|
|
||||||
|
// Handle the off-dispatcher trigger-expression evaluation result (P1).
|
||||||
|
Receive<ExpressionEvalResult>(HandleExpressionEvalResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -254,56 +263,74 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
else if (_triggerConfig is ExpressionTriggerConfig)
|
else if (_triggerConfig is ExpressionTriggerConfig)
|
||||||
{
|
{
|
||||||
EvaluateExpressionTrigger();
|
StartExpressionEvaluation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Evaluates the compiled trigger expression against the current attribute
|
/// Starts an off-dispatcher evaluation of the compiled trigger expression (P1).
|
||||||
/// snapshot. In <see cref="TriggerMode.OnTrue"/> mode the script runs once
|
/// The expression previously ran synchronously on the actor's dispatcher thread
|
||||||
/// per false→true transition; in <see cref="TriggerMode.WhileTrue"/> mode it
|
/// via <c>RunAsync(...).GetAwaiter().GetResult()</c>, blocking the dispatcher on
|
||||||
/// fires on the edge and the re-fire timer is started/stopped with the
|
/// every attribute change. It now runs on the dedicated script-execution
|
||||||
/// expression's truth value. A throwing or non-bool expression is treated as
|
/// scheduler against a point-in-time snapshot; the boolean result is piped back
|
||||||
/// false and logged as a script error; the actor never crashes.
|
/// 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>
|
/// </summary>
|
||||||
private void EvaluateExpressionTrigger()
|
private void StartExpressionEvaluation()
|
||||||
{
|
{
|
||||||
if (_compiledTriggerExpression == null) return;
|
if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return;
|
||||||
if (_triggerConfig is not ExpressionTriggerConfig exprConfig) return;
|
if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending
|
||||||
|
_evalInFlight = true;
|
||||||
|
|
||||||
bool result;
|
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
|
||||||
|
var expression = _compiledTriggerExpression;
|
||||||
|
var self = Self;
|
||||||
|
Task.Factory.StartNew(async () =>
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
|
// Bound evaluation with a short timeout. The CancellationToken covers
|
||||||
// Bound evaluation with a short timeout. The CancellationToken
|
// cooperative/async cases; a pathological CPU-bound expression is not
|
||||||
// covers cooperative/async cases; a pathological CPU-bound
|
// fully interruptible — acceptable because trigger expressions are
|
||||||
// expression is not fully interruptible. Acceptable because
|
// authored by trusted Design-role users and compile-checked pre-deploy.
|
||||||
// trigger expressions are authored by trusted Design-role users
|
|
||||||
// and are compile-checked pre-deployment.
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||||
var state = _compiledTriggerExpression
|
var state = await expression.RunAsync(new TriggerExpressionGlobals(snapshot), cancellationToken: cts.Token);
|
||||||
.RunAsync(globals, cancellationToken: cts.Token)
|
return state.ReturnValue is bool b && b;
|
||||||
.GetAwaiter().GetResult();
|
|
||||||
result = state.ReturnValue is bool b && b;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// OperationCanceledException (timeout) falls through here too,
|
// OperationCanceledException (timeout) falls through here too and is
|
||||||
// and is correctly treated as false.
|
// 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);
|
LogExpressionError(ex);
|
||||||
result = false;
|
return false;
|
||||||
|
}
|
||||||
|
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
|
||||||
|
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
|
||||||
|
success: r => new ExpressionEvalResult(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
if (exprConfig.Mode == TriggerMode.WhileTrue)
|
if (exprConfig.Mode == TriggerMode.WhileTrue)
|
||||||
{
|
HandleWhileTrueTransition(msg.Result, _lastExpressionResult);
|
||||||
HandleWhileTrueTransition(result, _lastExpressionResult);
|
else if (msg.Result && !_lastExpressionResult)
|
||||||
}
|
|
||||||
else if (result && !_lastExpressionResult)
|
|
||||||
{
|
|
||||||
TrySpawnExecution(null);
|
TrySpawnExecution(null);
|
||||||
|
_lastExpressionResult = msg.Result;
|
||||||
}
|
}
|
||||||
|
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
|
||||||
_lastExpressionResult = result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -576,6 +603,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error);
|
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 ──
|
// ── Trigger config types ──
|
||||||
|
|||||||
@@ -303,6 +303,43 @@ public class ScriptActorTests : TestKit, IDisposable
|
|||||||
private Script<object?> CompileTriggerExpression(string expression) =>
|
private Script<object?> CompileTriggerExpression(string expression) =>
|
||||||
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
|
_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]
|
[Fact]
|
||||||
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
|
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user