diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs index 5405bc19..32e6d0c1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs @@ -59,6 +59,12 @@ public class ScriptActor : ReceiveActor, IWithTimers private readonly Script? _compiledTriggerExpression; private bool _lastExpressionResult; private readonly Dictionary _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(HandleExecutionCompleted); + + // Handle the off-dispatcher trigger-expression evaluation result (P1). + Receive(HandleExpressionEvalResult); } /// @@ -254,56 +263,74 @@ public class ScriptActor : ReceiveActor, IWithTimers } else if (_triggerConfig is ExpressionTriggerConfig) { - EvaluateExpressionTrigger(); + StartExpressionEvaluation(); } } /// - /// Evaluates the compiled trigger expression against the current attribute - /// snapshot. In mode the script runs once - /// per false→true transition; in 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 RunAsync(...).GetAwaiter().GetResult(), 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 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. /// - 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(_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) + /// + /// Applies an off-dispatcher trigger-expression result on the actor thread: + /// runs the OnTrue / WhileTrue edge logic against , + /// clears the in-flight flag, and drains a coalesced pending evaluation (against + /// the now-current snapshot) if one was requested while this one ran. + /// + 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(); } } /// @@ -576,6 +603,13 @@ public class ScriptActor : ReceiveActor, IWithTimers } internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error); + + /// + /// 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. + /// + private sealed record ExpressionEvalResult(bool Result); } // ── Trigger config types ── diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs index b82bc95c..7be99989 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs @@ -303,6 +303,43 @@ public class ScriptActorTests : TestKit, IDisposable private Script CompileTriggerExpression(string expression) => _compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!; + /// + /// Compiles a trigger expression WITHOUT the trust validator so a test can use + /// System.Threading.Thread (forbidden in real scripts) to observe which + /// thread the evaluation runs on. Mirrors the real compile against + /// . + /// + private static Script 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(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(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off-dispatcher + } + [Fact] public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds() {