using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using System.Globalization; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; /// /// 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. /// /// Trigger types: /// - Interval: uses Akka timers to fire periodically /// - ValueChange: receives attribute change notifications from Instance Actor /// - Conditional: evaluates a threshold comparison on attribute change /// - Expression: evaluates a compiled boolean expression on attribute change /// Conditional and Expression triggers carry a : /// OnTrue fires as the condition becomes true; WhileTrue additionally re-fires /// on a timer (cadence = MinTimeBetweenRuns) while the condition stays true. /// /// Supervision strategy: Resume on exception (coordinator preserves state). /// public class ScriptActor : ReceiveActor, IWithTimers { private readonly string _scriptName; private readonly string _instanceName; private readonly IActorRef _instanceActor; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; private readonly ILogger _logger; private readonly ISiteHealthCollector? _healthCollector; private readonly IServiceProvider? _serviceProvider; private Script? _compiledScript; private ScriptTriggerConfig? _triggerConfig; private TimeSpan? _minTimeBetweenRuns; /// /// The per-script execution timeout in seconds, or null to use the /// global default. Threaded down to each spawned , /// which applies perScript ?? global (and treats ≤ 0 as "use global"). /// private readonly int? _executionTimeoutSeconds; private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue; private int _executionCounter; private readonly Commons.Types.Scripts.ScriptScope _scope; // Expression trigger state: compiled expression, edge-tracking, and the // attribute snapshot the expression evaluates against. 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. // (Expression triggers reuse _lastExpressionResult for the same purpose.) private bool _conditionState; /// Timer key for the WhileTrue re-fire timer (cadence = MinTimeBetweenRuns). private const string WhileTrueTimerKey = "whiletrue-trigger"; /// /// The exact dictionary instance this actor was seeded from /// at construction. The Instance Actor must pass a private snapshot here, not /// its live _attributes field — sharing the live dictionary lets this /// constructor enumerate it while the Instance Actor mutates it on another /// thread. Exposed for regression coverage of that isolation contract. /// internal IReadOnlyDictionary? SeedAttributesReference { get; } /// Gets or sets the Akka timer scheduler used to schedule interval and WhileTrue triggers. public ITimerScheduler Timers { get; set; } = null!; /// /// Initializes the ScriptActor with its compiled script, trigger configuration, and supporting services. /// /// Name of the script this actor manages. /// Unique name of the owning instance. /// Reference to the parent Instance Actor. /// Pre-compiled Roslyn script delegate, or null when compilation failed. /// Resolved script metadata including trigger type and configuration. /// Library of compiled shared scripts available for inline execution. /// Site runtime configuration options. /// Logger for diagnostics. /// Pre-compiled boolean trigger expression, or null when not an expression trigger. /// Initial attribute snapshot used to seed expression trigger evaluation state. /// Optional health metrics collector. /// Optional DI service provider for script execution context services. public ScriptActor( string scriptName, string instanceName, IActorRef instanceActor, Script? compiledScript, ResolvedScript scriptConfig, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, ILogger logger, Script? compiledTriggerExpression = null, IReadOnlyDictionary? initialAttributes = null, ISiteHealthCollector? healthCollector = null, IServiceProvider? serviceProvider = null) { _scriptName = scriptName; _instanceName = instanceName; _instanceActor = instanceActor; _compiledScript = compiledScript; _sharedScriptLibrary = sharedScriptLibrary; _options = options; _logger = logger; _healthCollector = healthCollector; _serviceProvider = serviceProvider; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; _executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds; _scope = scriptConfig.Scope; _compiledTriggerExpression = compiledTriggerExpression; // Seed the trigger-expression attribute snapshot from the instance's // initial attribute set so static attributes (which never re-emit an // AttributeValueChanged after deploy) evaluate correctly at startup. SeedAttributesReference = initialAttributes; if (initialAttributes != null) { foreach (var kvp in initialAttributes) _attributeSnapshot[kvp.Key] = kvp.Value; } // Parse trigger configuration _triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration); // Handle script call requests (Ask pattern from Instance Actor or ScriptRuntimeContext) Receive(HandleScriptCallRequest); // Handle attribute value changes for value-change and conditional triggers Receive(HandleAttributeValueChanged); // Handle interval tick Receive(_ => TrySpawnExecution(null)); // Handle WhileTrue re-fire tick Receive(_ => FireWhileTrueTick()); // Handle execution completion (for logging/metrics) Receive(HandleExecutionCompleted); // Handle the off-dispatcher trigger-expression evaluation result (P1). Receive(HandleExpressionEvalResult); } /// protected override void PreStart() { base.PreStart(); // Set up interval trigger if configured if (_triggerConfig is IntervalTriggerConfig interval) { Timers.StartPeriodicTimer( "interval-trigger", IntervalTick.Instance, interval.Interval, interval.Interval); _logger.LogDebug( "ScriptActor {Script} on {Instance}: interval trigger set to {Interval}", _scriptName, _instanceName, interval.Interval); } _logger.LogInformation( "ScriptActor {Script} started on instance {Instance}", _scriptName, _instanceName); } /// 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; })); } /// /// Handles CallScript ask from ScriptRuntimeContext or Instance Actor. /// Spawns a ScriptExecutionActor and forwards the sender for reply. /// private void HandleScriptCallRequest(ScriptCallRequest request) { if (_compiledScript == null) { Sender.Tell(new ScriptCallResult( request.CorrelationId, false, null, $"Script '{_scriptName}' is not compiled.")); return; } // (ParentExecutionId): carry any inbound-routed ParentExecutionId // through to the ScriptExecutionActor so the routed script's // ScriptRuntimeContext can record its spawner. Null for normal // (tag-change / timer) runs and nested Script.Call invocations. SpawnExecution( request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId, request.ParentExecutionId); } /// /// Handles attribute value changes — triggers script if configured for /// value-change, conditional, or expression. The attribute snapshot is /// updated for every change before any trigger logic runs. /// private void HandleAttributeValueChanged(AttributeValueChanged changed) { // Keep the snapshot current for every change, regardless of trigger type. _attributeSnapshot[changed.AttributeName] = changed.Value; if (_triggerConfig is ValueChangeTriggerConfig valueTrigger) { if (valueTrigger.AttributeName == changed.AttributeName) { TrySpawnExecution(null); } } else if (_triggerConfig is ConditionalTriggerConfig conditional) { if (conditional.AttributeName == changed.AttributeName) { var conditionMet = EvaluateCondition(conditional, changed.Value); if (conditional.Mode == TriggerMode.WhileTrue) { // Edge-detect against the prior truth value; the timer does // the repeated firing while the condition stays true. HandleWhileTrueTransition(conditionMet, _conditionState); _conditionState = conditionMet; } else if (conditionMet) { // OnTrue: fire on each matching change (existing behavior). TrySpawnExecution(null); } } } else if (_triggerConfig is ExpressionTriggerConfig) { StartExpressionEvaluation(); } } /// /// 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 StartExpressionEvaluation() { if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return; if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending _evalInFlight = true; var snapshot = new Dictionary(_attributeSnapshot); // point-in-time copy, actor thread var expression = _compiledTriggerExpression; var self = Self; Task.Factory.StartNew(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; } 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)); } /// /// 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) { if (exprConfig.Mode == TriggerMode.WhileTrue) HandleWhileTrueTransition(msg.Result, _lastExpressionResult); else if (msg.Result && !_lastExpressionResult) TrySpawnExecution(null); _lastExpressionResult = msg.Result; } if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); } } /// /// Applies a WhileTrue trigger's condition-state transition: on the /// false→true edge, fire once and start the re-fire timer; on the /// true→false edge, stop the timer. While the state is unchanged, the /// already-running timer continues to drive re-firing. /// private void HandleWhileTrueTransition(bool nowTrue, bool wasTrue) { if (nowTrue && !wasTrue) { TrySpawnExecution(null); StartWhileTrueTimer(); } else if (!nowTrue && wasTrue) { StopWhileTrueTimer(); } } /// /// Starts the periodic WhileTrue re-fire timer. The cadence is the script's /// MinTimeBetweenRuns; with none configured the trigger cannot /// re-fire, so it degrades to the single edge fire and logs a warning. /// private void StartWhileTrueTimer() { if (_compiledScript == null) return; if (_minTimeBetweenRuns is not { } interval) { _logger.LogWarning( "ScriptActor {Script} on {Instance}: WhileTrue trigger has no MinTimeBetweenRuns — " + "firing once on the edge only, no re-fire timer.", _scriptName, _instanceName); return; } Timers.StartPeriodicTimer(WhileTrueTimerKey, WhileTrueTick.Instance, interval, interval); } /// Cancels the WhileTrue re-fire timer (a no-op if it is not running). private void StopWhileTrueTimer() => Timers.Cancel(WhileTrueTimerKey); /// /// Fires the script for a WhileTrue re-fire tick. The timer interval is /// itself the cadence, so this spawns directly — bypassing the /// MinTimeBetweenRuns skip-check that gates change-driven spawns (which /// could otherwise drop a tick to sub-millisecond timing jitter). /// private void FireWhileTrueTick() { if (_compiledScript == null) return; _lastExecutionTime = DateTimeOffset.UtcNow; SpawnExecution(null, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString()); } /// /// Records a trigger-expression evaluation failure to the site event log, /// mirroring how ScriptExecutionActor reports script errors. /// private void LogExpressionError(Exception ex) { _healthCollector?.IncrementScriptError(); var errorMsg = $"Trigger expression for script '{_scriptName}' on instance '{_instanceName}' failed: {ex.Message}"; _logger.LogError(ex, "Trigger expression evaluation failed: {Script} on {Instance}", _scriptName, _instanceName); _ = _serviceProvider?.GetService()?.LogEventAsync( "script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString()); } /// /// Attempts to spawn a script execution, respecting MinTimeBetweenRuns. /// private void TrySpawnExecution(IReadOnlyDictionary? parameters) { if (_compiledScript == null) return; if (_minTimeBetweenRuns.HasValue) { var elapsed = DateTimeOffset.UtcNow - _lastExecutionTime; if (elapsed < _minTimeBetweenRuns.Value) { _logger.LogDebug( "Script {Script} on {Instance}: skipping execution, min time between runs not elapsed ({Elapsed} < {Min})", _scriptName, _instanceName, elapsed, _minTimeBetweenRuns.Value); return; } } _lastExecutionTime = DateTimeOffset.UtcNow; SpawnExecution(parameters, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString()); } /// /// Spawns a new ScriptExecutionActor child for this invocation. /// Multiple concurrent executions are allowed. /// private void SpawnExecution( IReadOnlyDictionary? parameters, int callDepth, IActorRef replyTo, string correlationId, Guid? parentExecutionId = null) { var executionId = $"{_scriptName}-exec-{_executionCounter++}"; // 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)); Context.ActorOf(props, executionId); } private void HandleExecutionCompleted(ScriptExecutionCompleted msg) { _logger.LogDebug( "Script {Script} execution completed on {Instance}: success={Success}", _scriptName, _instanceName, msg.Success); } private static bool EvaluateCondition(ConditionalTriggerConfig config, object? value) { if (value == null) return false; try { // Use InvariantCulture so a string attribute value like "1.5" parses // consistently regardless of the host locale. For // purely-numeric inputs the culture argument is a no-op, but it is // safe and future-proof for string-typed attribute values arriving // from scripts or the data connection layer. var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture); return config.Operator switch { ">" => numericValue > config.Threshold, ">=" => numericValue >= config.Threshold, "<" => numericValue < config.Threshold, "<=" => numericValue <= config.Threshold, "==" => Math.Abs(numericValue - config.Threshold) < 0.0001, "!=" => Math.Abs(numericValue - config.Threshold) >= 0.0001, _ => false }; } catch { return string.Equals(value.ToString(), config.Threshold.ToString(), StringComparison.Ordinal); } } private static ScriptTriggerConfig? ParseTriggerConfig(string? triggerType, string? triggerConfigJson) { if (string.IsNullOrEmpty(triggerType)) return null; return triggerType.ToLowerInvariant() switch { "interval" => ParseIntervalTrigger(triggerConfigJson), "valuechange" => ParseValueChangeTrigger(triggerConfigJson), "conditional" => ParseConditionalTrigger(triggerConfigJson), "expression" => ParseExpressionTrigger(triggerConfigJson), "call" => null, // No automatic trigger — invoked only via Instance.CallScript() _ => null }; } private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json) { var expr = TriggerExpressionGlobals.ExtractExpression(json); if (expr == null) return null; // ExtractExpression already proved the JSON parses; read the mode too. var mode = TriggerMode.OnTrue; try { using var doc = JsonDocument.Parse(json!); mode = ParseTriggerMode(doc.RootElement); } catch (JsonException) { /* keep OnTrue */ } return new ExpressionTriggerConfig(expr, mode); } /// /// Reads the optional mode field (Conditional + Expression triggers). /// An absent or unrecognized value (case-insensitive) yields /// , so pre-WhileTrue configs are unchanged. /// private static TriggerMode ParseTriggerMode(JsonElement root) { var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null; return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase) ? TriggerMode.WhileTrue : TriggerMode.OnTrue; } private static IntervalTriggerConfig? ParseIntervalTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; try { var doc = JsonDocument.Parse(json); var ms = doc.RootElement.GetProperty("intervalMs").GetInt64(); return new IntervalTriggerConfig(TimeSpan.FromMilliseconds(ms)); } catch { return null; } } private static ValueChangeTriggerConfig? ParseValueChangeTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; try { var doc = JsonDocument.Parse(json); var attr = doc.RootElement.GetProperty("attributeName").GetString()!; return new ValueChangeTriggerConfig(attr); } catch { return null; } } private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json) { if (string.IsNullOrEmpty(json)) return null; try { var doc = JsonDocument.Parse(json); var attr = doc.RootElement.GetProperty("attributeName").GetString()!; var op = doc.RootElement.GetProperty("operator").GetString()!; var threshold = doc.RootElement.GetProperty("threshold").GetDouble(); return new ConditionalTriggerConfig( attr, op, threshold, ParseTriggerMode(doc.RootElement)); } catch { return null; } } // ── Internal messages ── internal sealed class IntervalTick { public static readonly IntervalTick Instance = new(); private IntervalTick() { } } internal sealed class WhileTrueTick { public static readonly WhileTrueTick Instance = new(); private WhileTrueTick() { } } 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 ── /// /// When a Conditional/Expression trigger fires. fires once /// as the condition becomes true; additionally re-fires /// on a timer (cadence = the script's MinTimeBetweenRuns) until it goes false. /// internal enum TriggerMode { OnTrue, WhileTrue } internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig; internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig; internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold, TriggerMode Mode) : ScriptTriggerConfig; internal record ExpressionTriggerConfig(string Expression, TriggerMode Mode) : ScriptTriggerConfig; internal abstract record ScriptTriggerConfig;