Phase 3B: Site I/O & Observability — Communication, DCL, Script/Alarm actors, Health, Event Logging
Communication Layer (WP-1–5): - 8 message patterns with correlation IDs, per-pattern timeouts - Central/Site communication actors, transport heartbeat config - Connection failure handling (no central buffering, debug streams killed) Data Connection Layer (WP-6–14, WP-34): - Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting) - OPC UA + LmxProxy adapters behind IDataConnection - Auto-reconnect, bad quality propagation, transparent re-subscribe - Write-back, tag path resolution with retry, health reporting - Protocol extensibility via DataConnectionFactory Site Runtime (WP-15–25, WP-32–33): - ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher) - AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state) - SharedScriptLibrary (inline execution), ScriptRuntimeContext (API) - ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout) - Recursion limit (default 10), call direction enforcement - SiteStreamManager (per-subscriber bounded buffers, fire-and-forget) - Debug view backend (snapshot + stream), concurrency serialization - Local artifact storage (4 SQLite tables) Health Monitoring (WP-26–28): - SiteHealthCollector (thread-safe counters, connection state) - HealthReportSender (30s interval, monotonic sequence numbers) - CentralHealthAggregator (offline detection 60s, online recovery) Site Event Logging (WP-29–31): - SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC) - EventLogPurgeService (30-day retention, 1GB cap) - EventLogQueryService (filters, keyword search, keyset pagination) 541 tests pass, zero warnings.
This commit is contained in:
313
src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
Normal file
313
src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
Normal file
@@ -0,0 +1,313 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP-15: 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 condition on attribute change
|
||||
///
|
||||
/// Supervision strategy: Resume on exception (coordinator preserves state).
|
||||
/// </summary>
|
||||
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 Script<object?>? _compiledScript;
|
||||
private ScriptTriggerConfig? _triggerConfig;
|
||||
private TimeSpan? _minTimeBetweenRuns;
|
||||
private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
|
||||
private int _executionCounter;
|
||||
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
public ScriptActor(
|
||||
string scriptName,
|
||||
string instanceName,
|
||||
IActorRef instanceActor,
|
||||
Script<object?>? compiledScript,
|
||||
ResolvedScript scriptConfig,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
SiteRuntimeOptions options,
|
||||
ILogger logger)
|
||||
{
|
||||
_scriptName = scriptName;
|
||||
_instanceName = instanceName;
|
||||
_instanceActor = instanceActor;
|
||||
_compiledScript = compiledScript;
|
||||
_sharedScriptLibrary = sharedScriptLibrary;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
||||
|
||||
// Parse trigger configuration
|
||||
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
|
||||
|
||||
// Handle script call requests (Ask pattern from Instance Actor or ScriptRuntimeContext)
|
||||
Receive<ScriptCallRequest>(HandleScriptCallRequest);
|
||||
|
||||
// Handle attribute value changes for value-change and conditional triggers
|
||||
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
|
||||
|
||||
// Handle interval tick
|
||||
Receive<IntervalTick>(_ => TrySpawnExecution(null));
|
||||
|
||||
// Handle execution completion (for logging/metrics)
|
||||
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supervision: Resume on exception — coordinator preserves state.
|
||||
/// ScriptExecutionActors are stopped on unhandled exceptions.
|
||||
/// </summary>
|
||||
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;
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles CallScript ask from ScriptRuntimeContext or Instance Actor.
|
||||
/// Spawns a ScriptExecutionActor and forwards the sender for reply.
|
||||
/// </summary>
|
||||
private void HandleScriptCallRequest(ScriptCallRequest request)
|
||||
{
|
||||
if (_compiledScript == null)
|
||||
{
|
||||
Sender.Tell(new ScriptCallResult(
|
||||
request.CorrelationId,
|
||||
false,
|
||||
null,
|
||||
$"Script '{_scriptName}' is not compiled."));
|
||||
return;
|
||||
}
|
||||
|
||||
SpawnExecution(request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles attribute value changes — triggers script if configured for value-change or conditional.
|
||||
/// </summary>
|
||||
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
||||
{
|
||||
if (_triggerConfig is ValueChangeTriggerConfig valueTrigger)
|
||||
{
|
||||
if (valueTrigger.AttributeName == changed.AttributeName)
|
||||
{
|
||||
TrySpawnExecution(null);
|
||||
}
|
||||
}
|
||||
else if (_triggerConfig is ConditionalTriggerConfig conditional)
|
||||
{
|
||||
if (conditional.AttributeName == changed.AttributeName)
|
||||
{
|
||||
// Evaluate condition
|
||||
if (EvaluateCondition(conditional, changed.Value))
|
||||
{
|
||||
TrySpawnExecution(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to spawn a script execution, respecting MinTimeBetweenRuns.
|
||||
/// </summary>
|
||||
private void TrySpawnExecution(IReadOnlyDictionary<string, object?>? 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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns a new ScriptExecutionActor child for this invocation.
|
||||
/// Multiple concurrent executions are allowed.
|
||||
/// </summary>
|
||||
private void SpawnExecution(
|
||||
IReadOnlyDictionary<string, object?>? parameters,
|
||||
int callDepth,
|
||||
IActorRef replyTo,
|
||||
string correlationId)
|
||||
{
|
||||
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
|
||||
|
||||
// NOTE: In production, configure a dedicated blocking I/O dispatcher via HOCON:
|
||||
// akka.actor.script-execution-dispatcher { type = PinnedDispatcher }
|
||||
// and chain .WithDispatcher("akka.actor.script-execution-dispatcher") below.
|
||||
var props = Props.Create(() => new ScriptExecutionActor(
|
||||
_scriptName,
|
||||
_instanceName,
|
||||
_compiledScript!,
|
||||
parameters,
|
||||
callDepth,
|
||||
_instanceActor,
|
||||
_sharedScriptLibrary,
|
||||
_options,
|
||||
replyTo,
|
||||
correlationId,
|
||||
_logger));
|
||||
|
||||
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
|
||||
{
|
||||
var numericValue = Convert.ToDouble(value);
|
||||
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),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ── Internal messages ──
|
||||
|
||||
internal sealed class IntervalTick
|
||||
{
|
||||
public static readonly IntervalTick Instance = new();
|
||||
private IntervalTick() { }
|
||||
}
|
||||
|
||||
internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error);
|
||||
}
|
||||
|
||||
// ── Trigger config types ──
|
||||
|
||||
internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
|
||||
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
|
||||
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig;
|
||||
internal abstract record ScriptTriggerConfig;
|
||||
Reference in New Issue
Block a user