feat(triggers): runtime expression trigger evaluation for scripts and alarms
This commit is contained in:
@@ -50,6 +50,12 @@ public class AlarmActor : ReceiveActor
|
||||
private readonly string? _onTriggerScriptName;
|
||||
private readonly Script<object?>? _onTriggerCompiledScript;
|
||||
|
||||
// Expression trigger: compiled expression + the attribute snapshot it
|
||||
// evaluates against. The compiled expression is also held on the
|
||||
// ExpressionEvalConfig; this field caches it for the hot path.
|
||||
private readonly Script<object?>? _compiledTriggerExpression;
|
||||
private readonly Dictionary<string, object?> _attributeSnapshot = new();
|
||||
|
||||
// Rate of change tracking
|
||||
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
|
||||
private readonly TimeSpan _rateOfChangeWindowDuration;
|
||||
@@ -65,6 +71,7 @@ public class AlarmActor : ReceiveActor
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
SiteRuntimeOptions options,
|
||||
ILogger logger,
|
||||
Script<object?>? compiledTriggerExpression = null,
|
||||
ISiteHealthCollector? healthCollector = null)
|
||||
{
|
||||
_alarmName = alarmName;
|
||||
@@ -77,6 +84,7 @@ public class AlarmActor : ReceiveActor
|
||||
_priority = alarmConfig.PriorityLevel;
|
||||
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
|
||||
_onTriggerCompiledScript = onTriggerCompiledScript;
|
||||
_compiledTriggerExpression = compiledTriggerExpression;
|
||||
|
||||
// Parse trigger type
|
||||
_triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt)
|
||||
@@ -126,9 +134,18 @@ public class AlarmActor : ReceiveActor
|
||||
/// </summary>
|
||||
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
||||
{
|
||||
// Only evaluate if this change is for an attribute we're monitoring
|
||||
if (!IsMonitoredAttribute(changed.AttributeName))
|
||||
// Expression triggers evaluate against a snapshot of every attribute,
|
||||
// not a single monitored attribute. Keep the snapshot current for every
|
||||
// change before the IsMonitoredAttribute gate (which does not apply).
|
||||
if (_triggerType == AlarmTriggerType.Expression)
|
||||
{
|
||||
_attributeSnapshot[changed.AttributeName] = changed.Value;
|
||||
}
|
||||
else if (!IsMonitoredAttribute(changed.AttributeName))
|
||||
{
|
||||
// Only evaluate if this change is for an attribute we're monitoring
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -143,6 +160,7 @@ public class AlarmActor : ReceiveActor
|
||||
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
|
||||
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
|
||||
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
|
||||
AlarmTriggerType.Expression => EvaluateExpression(),
|
||||
_ => false
|
||||
};
|
||||
|
||||
@@ -337,6 +355,22 @@ public class AlarmActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the compiled trigger expression against the current attribute
|
||||
/// snapshot, returning the resulting bool. This bool feeds the existing
|
||||
/// binary Normal↔Active state path — the alarm is active while true. A
|
||||
/// throwing or non-bool expression is treated as false; the exception
|
||||
/// propagates to the caller's catch, which logs it and continues.
|
||||
/// </summary>
|
||||
private bool EvaluateExpression()
|
||||
{
|
||||
if (_compiledTriggerExpression == null) return false;
|
||||
|
||||
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
|
||||
var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult();
|
||||
return state.ReturnValue is bool b && b;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HiLo level evaluator: returns the most-severe matching band for the
|
||||
/// given value. Severity order checked from highest to lowest so that a
|
||||
@@ -473,6 +507,14 @@ public class AlarmActor : ReceiveActor
|
||||
HiMessage: TryReadString(root, "hiMessage"),
|
||||
HiHiMessage: TryReadString(root, "hiHiMessage")),
|
||||
|
||||
// Expression triggers have no single monitored attribute; they
|
||||
// evaluate the compiled expression (passed into the actor) over
|
||||
// the full attribute snapshot. MonitoredAttributeName is unused.
|
||||
AlarmTriggerType.Expression => new ExpressionEvalConfig(
|
||||
"",
|
||||
TryReadString(root, "expression") ?? "",
|
||||
_compiledTriggerExpression),
|
||||
|
||||
_ => new ValueMatchEvalConfig(attr, null)
|
||||
};
|
||||
}
|
||||
@@ -535,6 +577,17 @@ internal record RateOfChangeEvalConfig(
|
||||
TimeSpan WindowDuration,
|
||||
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
/// <summary>
|
||||
/// Expression evaluation config: a read-only boolean C# expression evaluated
|
||||
/// over the full attribute snapshot. Has no single monitored attribute
|
||||
/// (<see cref="AlarmEvalConfig.MonitoredAttributeName"/> is empty); the
|
||||
/// compiled expression is passed into the actor and cached here.
|
||||
/// </summary>
|
||||
internal record ExpressionEvalConfig(
|
||||
string MonitoredAttributeName,
|
||||
string Expression,
|
||||
Script<object?>? CompiledExpression) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
/// <summary>
|
||||
/// HiLo evaluation config: any subset of the four setpoints may be set; null
|
||||
/// means "don't evaluate that band". Per-setpoint priorities override the
|
||||
|
||||
@@ -515,6 +515,10 @@ public class InstanceActor : ReceiveActor
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compile the trigger expression for Expression-triggered scripts.
|
||||
var triggerExpression = CompileTriggerExpression(
|
||||
script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}");
|
||||
|
||||
var props = Props.Create(() => new ScriptActor(
|
||||
script.CanonicalName,
|
||||
_instanceUniqueName,
|
||||
@@ -524,6 +528,7 @@ public class InstanceActor : ReceiveActor
|
||||
_sharedScriptLibrary,
|
||||
_options,
|
||||
_logger,
|
||||
triggerExpression,
|
||||
_healthCollector,
|
||||
_serviceProvider));
|
||||
|
||||
@@ -559,6 +564,10 @@ public class InstanceActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
// Compile the trigger expression for Expression-triggered alarms.
|
||||
var triggerExpression = CompileTriggerExpression(
|
||||
alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}");
|
||||
|
||||
var props = Props.Create(() => new AlarmActor(
|
||||
alarm.CanonicalName,
|
||||
_instanceUniqueName,
|
||||
@@ -568,6 +577,7 @@ public class InstanceActor : ReceiveActor
|
||||
_sharedScriptLibrary,
|
||||
_options,
|
||||
_logger,
|
||||
triggerExpression,
|
||||
_healthCollector));
|
||||
|
||||
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
||||
@@ -581,6 +591,47 @@ public class InstanceActor : ReceiveActor
|
||||
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles the boolean trigger expression for an Expression-triggered
|
||||
/// script or alarm. Returns null for non-Expression triggers, a blank
|
||||
/// expression, or a compilation failure (logged) — in which case the
|
||||
/// trigger is inert and the actor still starts.
|
||||
/// </summary>
|
||||
private Microsoft.CodeAnalysis.Scripting.Script<object?>? CompileTriggerExpression(
|
||||
string? triggerType, string? triggerConfigJson, string compileName)
|
||||
{
|
||||
if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
if (string.IsNullOrEmpty(triggerConfigJson))
|
||||
return null;
|
||||
|
||||
string? expression;
|
||||
try
|
||||
{
|
||||
var doc = JsonSerializer.Deserialize<JsonElement>(triggerConfigJson);
|
||||
expression = doc.TryGetProperty("expression", out var e) ? e.GetString() : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to read trigger expression config for {Name} on {Instance}",
|
||||
compileName, _instanceUniqueName);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return null;
|
||||
|
||||
var result = _compilationService.CompileTriggerExpression(compileName, expression);
|
||||
if (result.IsSuccess)
|
||||
return result.CompiledScript;
|
||||
|
||||
_logger.LogError(
|
||||
"Trigger expression for {Name} on {Instance} failed to compile: {Errors}",
|
||||
compileName, _instanceUniqueName, string.Join("; ", result.Errors));
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to current attribute count (for testing/diagnostics).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using ScadaLink.SiteEventLogging;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -40,6 +42,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
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<object?>? _compiledTriggerExpression;
|
||||
private bool _lastExpressionResult;
|
||||
private readonly Dictionary<string, object?> _attributeSnapshot = new();
|
||||
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
public ScriptActor(
|
||||
@@ -51,6 +59,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
SiteRuntimeOptions options,
|
||||
ILogger logger,
|
||||
Script<object?>? compiledTriggerExpression = null,
|
||||
ISiteHealthCollector? healthCollector = null,
|
||||
IServiceProvider? serviceProvider = null)
|
||||
{
|
||||
@@ -65,6 +74,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
_serviceProvider = serviceProvider;
|
||||
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
||||
_scope = scriptConfig.Scope;
|
||||
_compiledTriggerExpression = compiledTriggerExpression;
|
||||
|
||||
// Parse trigger configuration
|
||||
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
|
||||
@@ -143,10 +153,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles attribute value changes — triggers script if configured for value-change or conditional.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
@@ -165,6 +180,55 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_triggerConfig is ExpressionTriggerConfig)
|
||||
{
|
||||
EvaluateExpressionTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the compiled trigger expression against the current attribute
|
||||
/// snapshot and runs the script edge-triggered — once per false→true
|
||||
/// transition. A throwing or non-bool expression is treated as false and
|
||||
/// logged as a script error; the actor never crashes.
|
||||
/// </summary>
|
||||
private void EvaluateExpressionTrigger()
|
||||
{
|
||||
if (_compiledTriggerExpression == null) return;
|
||||
|
||||
bool result;
|
||||
try
|
||||
{
|
||||
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
|
||||
var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult();
|
||||
result = state.ReturnValue is bool b && b;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogExpressionError(ex);
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (result && !_lastExpressionResult)
|
||||
{
|
||||
TrySpawnExecution(null);
|
||||
}
|
||||
|
||||
_lastExpressionResult = result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a trigger-expression evaluation failure to the site event log,
|
||||
/// mirroring how ScriptExecutionActor reports script errors.
|
||||
/// </summary>
|
||||
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<ISiteEventLogger>()?.LogEventAsync(
|
||||
"script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -264,11 +328,26 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
"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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var expr = doc.RootElement.TryGetProperty("expression", out var e)
|
||||
? e.GetString()
|
||||
: null;
|
||||
return string.IsNullOrWhiteSpace(expr) ? null : new ExpressionTriggerConfig(expr);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
@@ -323,4 +402,5 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
|
||||
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
|
||||
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig;
|
||||
internal record ExpressionTriggerConfig(string Expression) : ScriptTriggerConfig;
|
||||
internal abstract record ScriptTriggerConfig;
|
||||
|
||||
Reference in New Issue
Block a user