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;