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:
Joseph Doherty
2026-03-16 20:57:25 -04:00
parent a3bf0c43f3
commit 389f5a0378
97 changed files with 8308 additions and 127 deletions

View File

@@ -0,0 +1,305 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Scripts;
using System.Text.Json;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// WP-16: Alarm Actor — coordinator actor, child of Instance Actor, peer to Script Actors.
/// Subscribes to attribute change notifications from Instance Actor.
///
/// Evaluates alarm conditions:
/// - ValueMatch: attribute equals a specific value
/// - RangeViolation: attribute outside min/max range
/// - RateOfChange: attribute rate exceeds threshold (configurable window, default per-second)
///
/// State (active/normal) is in memory only, NOT persisted.
/// On restart: starts normal, re-evaluates from incoming values.
///
/// WP-21: AlarmExecutionActor CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no Instance.CallAlarmScript API).
///
/// Supervision: Resume on exception; AlarmExecutionActor stopped on exception.
/// </summary>
public class AlarmActor : ReceiveActor
{
private readonly string _alarmName;
private readonly string _instanceName;
private readonly IActorRef _instanceActor;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private AlarmState _currentState = AlarmState.Normal;
private readonly AlarmTriggerType _triggerType;
private readonly AlarmEvalConfig _evalConfig;
private readonly int _priority;
private readonly string? _onTriggerScriptName;
private readonly Script<object?>? _onTriggerCompiledScript;
// Rate of change tracking
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
private readonly TimeSpan _rateOfChangeWindowDuration;
private int _executionCounter;
public AlarmActor(
string alarmName,
string instanceName,
IActorRef instanceActor,
ResolvedAlarm alarmConfig,
Script<object?>? onTriggerCompiledScript,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger)
{
_alarmName = alarmName;
_instanceName = instanceName;
_instanceActor = instanceActor;
_sharedScriptLibrary = sharedScriptLibrary;
_options = options;
_logger = logger;
_priority = alarmConfig.PriorityLevel;
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
_onTriggerCompiledScript = onTriggerCompiledScript;
// Parse trigger type
_triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt)
? tt : AlarmTriggerType.ValueMatch;
_evalConfig = ParseEvalConfig(alarmConfig.TriggerConfiguration);
_rateOfChangeWindowDuration = _evalConfig is RateOfChangeEvalConfig roc
? roc.WindowDuration
: TimeSpan.FromSeconds(1);
// Handle attribute value changes
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle alarm execution completion
Receive<AlarmExecutionCompleted>(_ =>
_logger.LogDebug("Alarm {Alarm} execution completed on {Instance}", _alarmName, _instanceName));
}
protected override void PreStart()
{
base.PreStart();
_logger.LogInformation(
"AlarmActor {Alarm} started on instance {Instance}, trigger={TriggerType}",
_alarmName, _instanceName, _triggerType);
}
/// <summary>
/// Supervision: Resume on exception; AlarmExecutionActor stopped on exception.
/// </summary>
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
_logger.LogWarning(ex,
"AlarmExecutionActor for {Alarm} on {Instance} failed, stopping",
_alarmName, _instanceName);
return Directive.Stop;
}));
}
/// <summary>
/// Evaluates alarm condition on attribute change. Alarm evaluation errors are logged,
/// actor continues (does not crash).
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// Only evaluate if this change is for an attribute we're monitoring
if (!IsMonitoredAttribute(changed.AttributeName))
return;
try
{
var isTriggered = _triggerType switch
{
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
_ => false
};
if (isTriggered && _currentState == AlarmState.Normal)
{
// Transition: Normal → Active
_currentState = AlarmState.Active;
_logger.LogInformation(
"Alarm {Alarm} ACTIVATED on instance {Instance}",
_alarmName, _instanceName);
// Notify Instance Actor of alarm state change
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// Spawn AlarmExecutionActor if on-trigger script defined
if (_onTriggerCompiledScript != null)
{
SpawnAlarmExecution();
}
}
else if (!isTriggered && _currentState == AlarmState.Active)
{
// Transition: Active → Normal (no script on clear)
_currentState = AlarmState.Normal;
_logger.LogInformation(
"Alarm {Alarm} CLEARED on instance {Instance}",
_alarmName, _instanceName);
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
}
}
catch (Exception ex)
{
// Alarm evaluation errors logged, actor continues
_logger.LogError(ex,
"Alarm {Alarm} evaluation error on {Instance}",
_alarmName, _instanceName);
}
}
private bool IsMonitoredAttribute(string attributeName)
{
return _evalConfig.MonitoredAttributeName == attributeName;
}
private bool EvaluateValueMatch(object? value)
{
if (_evalConfig is not ValueMatchEvalConfig config) return false;
if (value == null) return config.MatchValue == null;
return string.Equals(value.ToString(), config.MatchValue, StringComparison.Ordinal);
}
private bool EvaluateRangeViolation(object? value)
{
if (_evalConfig is not RangeViolationEvalConfig config) return false;
if (value == null) return false;
try
{
var numericValue = Convert.ToDouble(value);
return numericValue < config.Min || numericValue > config.Max;
}
catch
{
return false;
}
}
private bool EvaluateRateOfChange(object? value, DateTimeOffset timestamp)
{
if (_evalConfig is not RateOfChangeEvalConfig config) return false;
if (value == null) return false;
try
{
var numericValue = Convert.ToDouble(value);
// Add to window
_rateOfChangeWindow.Enqueue((timestamp, numericValue));
// Remove old entries outside the window
var cutoff = timestamp - _rateOfChangeWindowDuration;
while (_rateOfChangeWindow.Count > 0 && _rateOfChangeWindow.Peek().Timestamp < cutoff)
{
_rateOfChangeWindow.Dequeue();
}
if (_rateOfChangeWindow.Count < 2) return false;
var oldest = _rateOfChangeWindow.Peek();
var timeDelta = (timestamp - oldest.Timestamp).TotalSeconds;
if (timeDelta <= 0) return false;
var rate = Math.Abs(numericValue - oldest.Value) / timeDelta;
return rate > config.ThresholdPerSecond;
}
catch
{
return false;
}
}
/// <summary>
/// Spawns an AlarmExecutionActor to run the on-trigger script.
/// </summary>
private void SpawnAlarmExecution()
{
if (_onTriggerCompiledScript == null) return;
var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}";
// NOTE: In production, configure a dedicated blocking I/O dispatcher via HOCON.
var props = Props.Create(() => new AlarmExecutionActor(
_alarmName,
_instanceName,
_onTriggerCompiledScript,
_instanceActor,
_sharedScriptLibrary,
_options,
_logger));
Context.ActorOf(props, executionId);
}
private AlarmEvalConfig ParseEvalConfig(string? triggerConfigJson)
{
if (string.IsNullOrEmpty(triggerConfigJson))
return new ValueMatchEvalConfig("", null);
try
{
var doc = JsonDocument.Parse(triggerConfigJson);
var attr = doc.RootElement.TryGetProperty("attributeName", out var attrEl)
? attrEl.GetString() ?? "" : "";
return _triggerType switch
{
AlarmTriggerType.ValueMatch => new ValueMatchEvalConfig(
attr,
doc.RootElement.TryGetProperty("matchValue", out var mv) ? mv.GetString() : null),
AlarmTriggerType.RangeViolation => new RangeViolationEvalConfig(
attr,
doc.RootElement.TryGetProperty("min", out var minEl) ? minEl.GetDouble() : double.MinValue,
doc.RootElement.TryGetProperty("max", out var maxEl) ? maxEl.GetDouble() : double.MaxValue),
AlarmTriggerType.RateOfChange => new RateOfChangeEvalConfig(
attr,
doc.RootElement.TryGetProperty("thresholdPerSecond", out var tps) ? tps.GetDouble() : 10.0,
doc.RootElement.TryGetProperty("windowSeconds", out var ws)
? TimeSpan.FromSeconds(ws.GetDouble())
: TimeSpan.FromSeconds(1)),
_ => new ValueMatchEvalConfig(attr, null)
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse alarm trigger config for {Alarm}", _alarmName);
return new ValueMatchEvalConfig("", null);
}
}
// ── Internal messages ──
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
}
// ── Alarm evaluation config types ──
internal abstract record AlarmEvalConfig(string MonitoredAttributeName);
internal record ValueMatchEvalConfig(string MonitoredAttributeName, string? MatchValue) : AlarmEvalConfig(MonitoredAttributeName);
internal record RangeViolationEvalConfig(string MonitoredAttributeName, double Min, double Max) : AlarmEvalConfig(MonitoredAttributeName);
internal record RateOfChangeEvalConfig(string MonitoredAttributeName, double ThresholdPerSecond, TimeSpan WindowDuration) : AlarmEvalConfig(MonitoredAttributeName);

View File

@@ -0,0 +1,96 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// WP-16: Alarm Execution Actor -- short-lived child of Alarm Actor.
/// Same pattern as ScriptExecutionActor.
/// WP-21: CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no API for it).
/// Supervision: Stop on unhandled exception.
/// </summary>
public class AlarmExecutionActor : ReceiveActor
{
public AlarmExecutionActor(
string alarmName,
string instanceName,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger)
{
var self = Self;
var parent = Context.Parent;
ExecuteAlarmScript(
alarmName, instanceName, compiledScript, instanceActor,
sharedScriptLibrary, options, self, parent, logger);
}
private static void ExecuteAlarmScript(
string alarmName,
string instanceName,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef self,
IActorRef parent,
ILogger logger)
{
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
_ = Task.Run(async () =>
{
using var cts = new CancellationTokenSource(timeout);
try
{
// WP-21: AlarmExecutionActor can call Instance.CallScript()
// via the ScriptRuntimeContext injected into globals
var context = new ScriptRuntimeContext(
instanceActor,
self,
sharedScriptLibrary,
currentCallDepth: 0,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new Dictionary<string, object?>(),
CancellationToken = cts.Token
};
await compiledScript.RunAsync(globals, cts.Token);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, true));
}
catch (OperationCanceledException)
{
logger.LogWarning(
"Alarm on-trigger script for {Alarm} on {Instance} timed out",
alarmName, instanceName);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
catch (Exception ex)
{
// WP-32: Failures logged, alarm continues
logger.LogError(ex,
"Alarm on-trigger script for {Alarm} on {Instance} failed",
alarmName, instanceName);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
finally
{
self.Tell(PoisonPill.Instance);
}
});
}
}

View File

@@ -1,9 +1,12 @@
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.Artifacts;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming;
namespace ScadaLink.SiteRuntime.Actors;
@@ -20,6 +23,9 @@ namespace ScadaLink.SiteRuntime.Actors;
public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteStreamManager? _streamManager;
private readonly SiteRuntimeOptions _options;
private readonly ILogger<DeploymentManagerActor> _logger;
private readonly Dictionary<string, IActorRef> _instanceActors = new();
@@ -28,10 +34,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
public DeploymentManagerActor(
SiteStorageService storage,
ScriptCompilationService compilationService,
SharedScriptLibrary sharedScriptLibrary,
SiteStreamManager? streamManager,
SiteRuntimeOptions options,
ILogger<DeploymentManagerActor> logger)
{
_storage = storage;
_compilationService = compilationService;
_sharedScriptLibrary = sharedScriptLibrary;
_streamManager = streamManager;
_options = options;
_logger = logger;
@@ -41,6 +53,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<EnableInstanceCommand>(HandleEnable);
Receive<DeleteInstanceCommand>(HandleDelete);
// WP-33: Handle system-wide artifact deployment
Receive<DeployArtifactsCommand>(HandleDeployArtifacts);
// Internal startup messages
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
Receive<StartNextBatch>(HandleStartNextBatch);
@@ -317,6 +332,74 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_logger.LogInformation("Instance {Instance} deleted", instanceName);
}
/// <summary>
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
/// Persists artifacts to SiteStorageService and recompiles shared scripts.
/// </summary>
private void HandleDeployArtifacts(DeployArtifactsCommand command)
{
_logger.LogInformation(
"Deploying system artifacts, deploymentId={DeploymentId}", command.DeploymentId);
var sender = Sender;
Task.Run(async () =>
{
try
{
// WP-33: Store shared scripts and recompile
if (command.SharedScripts != null)
{
foreach (var script in command.SharedScripts)
{
await _storage.StoreSharedScriptAsync(script.Name, script.Code,
script.ParameterDefinitions, script.ReturnDefinition);
// WP-33: Shared scripts recompiled on update
_sharedScriptLibrary.CompileAndRegister(script.Name, script.Code);
}
}
// WP-33: Store external system definitions
if (command.ExternalSystems != null)
{
foreach (var es in command.ExternalSystems)
{
await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl,
es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson);
}
}
// WP-33: Store database connection definitions
if (command.DatabaseConnections != null)
{
foreach (var db in command.DatabaseConnections)
{
await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString,
db.MaxRetries, db.RetryDelay);
}
}
// WP-33: Store notification lists
if (command.NotificationLists != null)
{
foreach (var nl in command.NotificationLists)
{
await _storage.StoreNotificationListAsync(nl.Name, nl.RecipientEmails);
}
}
return new ArtifactDeploymentResponse(
command.DeploymentId, "", true, null, DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
return new ArtifactDeploymentResponse(
command.DeploymentId, "", false, ex.Message, DateTimeOffset.UtcNow);
}
}).PipeTo(sender);
}
/// <summary>
/// Creates a child Instance Actor with the given name and configuration JSON.
/// </summary>
@@ -333,6 +416,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
instanceName,
configJson,
_storage,
_compilationService,
_sharedScriptLibrary,
_streamManager,
_options,
loggerFactory.CreateLogger<InstanceActor>()));
var actorRef = Context.ActorOf(props, instanceName);

View File

@@ -1,9 +1,15 @@
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming;
using System.Text.Json;
namespace ScadaLink.SiteRuntime.Actors;
@@ -13,24 +19,48 @@ namespace ScadaLink.SiteRuntime.Actors;
/// (loaded from FlattenedConfiguration + static overrides from SQLite).
///
/// The Instance Actor is the single source of truth for runtime instance state.
/// All state mutations are serialized through the actor mailbox.
/// WP-24: All state mutations are serialized through the actor mailbox.
/// Multiple Script Execution Actors run concurrently; state mutations through this actor.
///
/// WP-15/16: Creates child Script Actors and Alarm Actors on startup.
/// WP-22: Tell for tag value updates, attribute notifications, stream publishing.
/// Ask for CallScript, debug snapshot.
/// WP-25: Debug view backend — snapshot + stream subscription.
/// </summary>
public class InstanceActor : ReceiveActor
{
private readonly string _instanceUniqueName;
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteStreamManager? _streamManager;
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly Dictionary<string, object?> _attributes = new();
private readonly Dictionary<string, AlarmState> _alarmStates = new();
private readonly Dictionary<string, IActorRef> _scriptActors = new();
private readonly Dictionary<string, IActorRef> _alarmActors = new();
private FlattenedConfiguration? _configuration;
// WP-25: Debug view subscribers
private readonly Dictionary<string, IActorRef> _debugSubscribers = new();
public InstanceActor(
string instanceUniqueName,
string configJson,
SiteStorageService storage,
ScriptCompilationService compilationService,
SharedScriptLibrary sharedScriptLibrary,
SiteStreamManager? streamManager,
SiteRuntimeOptions options,
ILogger logger)
{
_instanceUniqueName = instanceUniqueName;
_storage = storage;
_compilationService = compilationService;
_sharedScriptLibrary = sharedScriptLibrary;
_streamManager = streamManager;
_options = options;
_logger = logger;
// Deserialize the flattened configuration
@@ -45,7 +75,7 @@ public class InstanceActor : ReceiveActor
}
}
// Handle attribute queries (Tell pattern sender gets response)
// Handle attribute queries (Tell pattern -- sender gets response)
Receive<GetAttributeRequest>(HandleGetAttribute);
// Handle static attribute writes
@@ -55,7 +85,6 @@ public class InstanceActor : ReceiveActor
Receive<DisableInstanceCommand>(_ =>
{
_logger.LogInformation("Instance {Instance} received disable command", _instanceUniqueName);
// Disable handled by parent DeploymentManagerActor
Sender.Tell(new InstanceLifecycleResponse(
_.CommandId, _instanceUniqueName, true, null, DateTimeOffset.UtcNow));
});
@@ -67,6 +96,19 @@ public class InstanceActor : ReceiveActor
_.CommandId, _instanceUniqueName, true, null, DateTimeOffset.UtcNow));
});
// WP-15: Handle script call requests — route to appropriate Script Actor (Ask pattern)
Receive<ScriptCallRequest>(HandleScriptCallRequest);
// WP-22/23: Handle attribute value changes from DCL (Tell pattern)
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// WP-16: Handle alarm state changes from Alarm Actors (Tell pattern)
Receive<AlarmStateChanged>(HandleAlarmStateChanged);
// WP-25: Debug view subscribe/unsubscribe (Ask pattern for snapshot)
Receive<SubscribeDebugViewRequest>(HandleSubscribeDebugView);
Receive<UnsubscribeDebugViewRequest>(HandleUnsubscribeDebugView);
// Handle internal messages
Receive<LoadOverridesResult>(HandleOverridesLoaded);
}
@@ -84,6 +126,26 @@ public class InstanceActor : ReceiveActor
return new LoadOverridesResult(t.Result, null);
return new LoadOverridesResult(new Dictionary<string, string>(), t.Exception?.GetBaseException().Message);
}).PipeTo(self);
// Create child Script Actors and Alarm Actors from configuration
CreateChildActors();
}
/// <summary>
/// Supervision: Resume for child coordinator actors (Script/Alarm Actors preserve state).
/// </summary>
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
_logger.LogWarning(ex,
"Child actor on instance {Instance} threw exception, resuming",
_instanceUniqueName);
return Directive.Resume;
}));
}
/// <summary>
@@ -103,12 +165,24 @@ public class InstanceActor : ReceiveActor
/// <summary>
/// Updates a static attribute in memory and persists the override to SQLite.
/// WP-24: State mutation serialized through this actor's mailbox.
/// </summary>
private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
{
_attributes[command.AttributeName] = command.Value;
// Persist asynchronously — fire and forget since the actor is the source of truth
// Publish attribute change to stream (WP-23) and notify children
var changed = new AttributeValueChanged(
_instanceUniqueName,
command.AttributeName,
command.AttributeName,
command.Value,
"Good",
DateTimeOffset.UtcNow);
PublishAndNotifyChildren(changed);
// Persist asynchronously -- fire and forget since the actor is the source of truth
var self = Self;
var sender = Sender;
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
@@ -131,6 +205,138 @@ public class InstanceActor : ReceiveActor
}).PipeTo(sender);
}
/// <summary>
/// WP-15: Routes script call requests to the appropriate Script Actor.
/// Uses Ask pattern (WP-22).
/// </summary>
private void HandleScriptCallRequest(ScriptCallRequest request)
{
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
{
// Forward the request to the Script Actor, preserving the original sender
scriptActor.Forward(request);
}
else
{
Sender.Tell(new ScriptCallResult(
request.CorrelationId,
false,
null,
$"Script '{request.ScriptName}' not found on instance '{_instanceUniqueName}'."));
}
}
/// <summary>
/// WP-22/23: Handles attribute value changes from DCL or static writes.
/// Updates in-memory state, publishes to stream, and notifies children.
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// WP-24: State mutation serialized through this actor
_attributes[changed.AttributeName] = changed.Value;
PublishAndNotifyChildren(changed);
}
/// <summary>
/// WP-16: Handles alarm state changes from Alarm Actors.
/// Updates in-memory alarm state and publishes to stream.
/// </summary>
private void HandleAlarmStateChanged(AlarmStateChanged changed)
{
_alarmStates[changed.AlarmName] = changed.State;
// WP-23: Publish to site-wide stream
_streamManager?.PublishAlarmStateChanged(changed);
// Forward to debug subscribers
foreach (var sub in _debugSubscribers.Values)
{
sub.Tell(changed);
}
}
/// <summary>
/// WP-25: Debug view subscribe — returns snapshot and begins streaming.
/// </summary>
private void HandleSubscribeDebugView(SubscribeDebugViewRequest request)
{
var subscriptionId = request.CorrelationId;
_debugSubscribers[subscriptionId] = Sender;
// Build snapshot from current state
var attributeValues = _attributes.Select(kvp => new AttributeValueChanged(
_instanceUniqueName,
kvp.Key,
kvp.Key,
kvp.Value,
"Good",
DateTimeOffset.UtcNow)).ToList();
var alarmStates = _alarmStates.Select(kvp => new AlarmStateChanged(
_instanceUniqueName,
kvp.Key,
kvp.Value,
0, // Priority not tracked in _alarmStates; would need separate tracking
DateTimeOffset.UtcNow)).ToList();
var snapshot = new DebugViewSnapshot(
_instanceUniqueName,
attributeValues,
alarmStates,
DateTimeOffset.UtcNow);
Sender.Tell(snapshot);
// Also register with stream manager for filtered events
_streamManager?.Subscribe(_instanceUniqueName, Sender);
_logger.LogDebug(
"Debug view subscriber added for {Instance}, subscriptionId={Id}",
_instanceUniqueName, subscriptionId);
}
/// <summary>
/// WP-25: Debug view unsubscribe — removes subscription.
/// </summary>
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
{
_debugSubscribers.Remove(request.CorrelationId);
_streamManager?.RemoveSubscriber(Sender);
_logger.LogDebug(
"Debug view subscriber removed for {Instance}, correlationId={Id}",
_instanceUniqueName, request.CorrelationId);
}
/// <summary>
/// Publishes attribute change to stream and notifies child Script/Alarm actors.
/// WP-22: Tell for attribute notifications (fire-and-forget, never blocks).
/// </summary>
private void PublishAndNotifyChildren(AttributeValueChanged changed)
{
// WP-23: Publish to site-wide stream
_streamManager?.PublishAttributeValueChanged(changed);
// Notify Script Actors (for value-change and conditional triggers)
foreach (var scriptActor in _scriptActors.Values)
{
scriptActor.Tell(changed);
}
// Notify Alarm Actors (for alarm evaluation)
foreach (var alarmActor in _alarmActors.Values)
{
alarmActor.Tell(changed);
}
// Forward to debug subscribers
foreach (var sub in _debugSubscribers.Values)
{
sub.Tell(changed);
}
}
/// <summary>
/// Applies static overrides loaded from SQLite on top of default values.
/// </summary>
@@ -154,11 +360,105 @@ public class InstanceActor : ReceiveActor
result.Overrides.Count, _instanceUniqueName);
}
/// <summary>
/// Creates child Script Actors and Alarm Actors from the flattened configuration.
/// WP-15: Script Actors spawned per script definition.
/// WP-16: Alarm Actors spawned per alarm definition, as peers to Script Actors.
/// WP-32: Compilation errors reject entire instance deployment (logged but actor still starts).
/// </summary>
private void CreateChildActors()
{
if (_configuration == null) return;
// Create Script Actors
foreach (var script in _configuration.Scripts)
{
var compilationResult = _compilationService.Compile(script.CanonicalName, script.Code);
if (!compilationResult.IsSuccess)
{
_logger.LogError(
"Script '{Script}' on instance '{Instance}' failed to compile: {Errors}",
script.CanonicalName, _instanceUniqueName,
string.Join("; ", compilationResult.Errors));
continue;
}
var props = Props.Create(() => new ScriptActor(
script.CanonicalName,
_instanceUniqueName,
Self,
compilationResult.CompiledScript,
script,
_sharedScriptLibrary,
_options,
_logger));
var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
_scriptActors[script.CanonicalName] = actorRef;
}
// Create Alarm Actors
foreach (var alarm in _configuration.Alarms)
{
Microsoft.CodeAnalysis.Scripting.Script<object?>? onTriggerScript = null;
// Compile on-trigger script if defined
if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
{
var triggerScriptDef = _configuration.Scripts
.FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName);
if (triggerScriptDef != null)
{
var result = _compilationService.Compile(
$"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code);
if (result.IsSuccess)
{
onTriggerScript = result.CompiledScript;
}
else
{
_logger.LogWarning(
"Alarm trigger script for {Alarm} on {Instance} failed to compile",
alarm.CanonicalName, _instanceUniqueName);
}
}
}
var props = Props.Create(() => new AlarmActor(
alarm.CanonicalName,
_instanceUniqueName,
Self,
alarm,
onTriggerScript,
_sharedScriptLibrary,
_options,
_logger));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
_alarmActors[alarm.CanonicalName] = actorRef;
}
_logger.LogInformation(
"Instance {Instance}: created {Scripts} script actors and {Alarms} alarm actors",
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
}
/// <summary>
/// Read-only access to current attribute count (for testing/diagnostics).
/// </summary>
public int AttributeCount => _attributes.Count;
/// <summary>
/// Read-only access to script actor count (for testing/diagnostics).
/// </summary>
public int ScriptActorCount => _scriptActors.Count;
/// <summary>
/// Read-only access to alarm actor count (for testing/diagnostics).
/// </summary>
public int AlarmActorCount => _alarmActors.Count;
/// <summary>
/// Internal message for async override loading result.
/// </summary>

View 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;

View File

@@ -0,0 +1,126 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// WP-15: Script Execution Actor -- short-lived child of Script Actor.
/// Receives compiled code, params, Instance Actor ref, and call depth.
/// Runs on a dedicated blocking I/O dispatcher.
/// Executes the script via Script Runtime API, returns result, then stops.
///
/// WP-32: Script failures are logged but do not disable the script.
/// Supervision: Stop on unhandled exception (parent ScriptActor decides).
/// </summary>
public class ScriptExecutionActor : ReceiveActor
{
public ScriptExecutionActor(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
ILogger logger)
{
// Immediately begin execution
var self = Self;
var parent = Context.Parent;
ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger);
}
private static void ExecuteScript(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
IActorRef self,
IActorRef parent,
ILogger logger)
{
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
// CTS must be created inside the async lambda so it outlives this method
_ = Task.Run(async () =>
{
using var cts = new CancellationTokenSource(timeout);
try
{
var context = new ScriptRuntimeContext(
instanceActor,
self,
sharedScriptLibrary,
callDepth,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = parameters ?? new Dictionary<string, object?>(),
CancellationToken = cts.Token
};
var state = await compiledScript.RunAsync(globals, cts.Token);
// Send result to requester if this was an Ask-based call
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null));
}
// Notify parent of completion
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, true, null));
}
catch (OperationCanceledException)
{
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
logger.LogWarning(errorMsg);
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
catch (Exception ex)
{
// WP-32: Failures logged to site event log; script NOT disabled after failure
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName);
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
finally
{
// Stop self after execution completes
self.Tell(PoisonPill.Instance);
}
});
}
}