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:
305
src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs
Normal file
305
src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs
Normal 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);
|
||||
96
src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs
Normal file
96
src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
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;
|
||||
126
src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs
Normal file
126
src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,37 @@ public class SiteStorageService
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_unique_name, attribute_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_scripts (
|
||||
name TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
parameter_definitions TEXT,
|
||||
return_definition TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS external_systems (
|
||||
name TEXT PRIMARY KEY,
|
||||
endpoint_url TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL,
|
||||
auth_configuration TEXT,
|
||||
method_definitions TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS database_connections (
|
||||
name TEXT PRIMARY KEY,
|
||||
connection_string TEXT NOT NULL,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
retry_delay_ms INTEGER NOT NULL DEFAULT 1000,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_lists (
|
||||
name TEXT PRIMARY KEY,
|
||||
recipient_emails TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
|
||||
@@ -241,6 +272,150 @@ public class SiteStorageService
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
|
||||
}
|
||||
|
||||
// ── WP-33: Shared Script CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a shared script. Uses UPSERT semantics.
|
||||
/// </summary>
|
||||
public async Task StoreSharedScriptAsync(string name, string code, string? parameterDefs, string? returnDef)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO shared_scripts (name, code, parameter_definitions, return_definition, updated_at)
|
||||
VALUES (@name, @code, @paramDefs, @returnDef, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
code = excluded.code,
|
||||
parameter_definitions = excluded.parameter_definitions,
|
||||
return_definition = excluded.return_definition,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@code", code);
|
||||
command.Parameters.AddWithValue("@paramDefs", (object?)parameterDefs ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@returnDef", (object?)returnDef ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_logger.LogDebug("Stored shared script '{Name}'", name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all stored shared scripts.
|
||||
/// </summary>
|
||||
public async Task<List<StoredSharedScript>> GetAllSharedScriptsAsync()
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name, code, parameter_definitions, return_definition FROM shared_scripts";
|
||||
|
||||
var results = new List<StoredSharedScript>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(new StoredSharedScript
|
||||
{
|
||||
Name = reader.GetString(0),
|
||||
Code = reader.GetString(1),
|
||||
ParameterDefinitions = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
ReturnDefinition = reader.IsDBNull(3) ? null : reader.GetString(3)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── WP-33: External System CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates an external system definition.
|
||||
/// </summary>
|
||||
public async Task StoreExternalSystemAsync(
|
||||
string name, string endpointUrl, string authType, string? authConfig, string? methodDefs)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO external_systems (name, endpoint_url, auth_type, auth_configuration, method_definitions, updated_at)
|
||||
VALUES (@name, @url, @authType, @authConfig, @methodDefs, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
endpoint_url = excluded.endpoint_url,
|
||||
auth_type = excluded.auth_type,
|
||||
auth_configuration = excluded.auth_configuration,
|
||||
method_definitions = excluded.method_definitions,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@url", endpointUrl);
|
||||
command.Parameters.AddWithValue("@authType", authType);
|
||||
command.Parameters.AddWithValue("@authConfig", (object?)authConfig ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@methodDefs", (object?)methodDefs ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// ── WP-33: Database Connection CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a database connection definition.
|
||||
/// </summary>
|
||||
public async Task StoreDatabaseConnectionAsync(
|
||||
string name, string connectionString, int maxRetries, TimeSpan retryDelay)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO database_connections (name, connection_string, max_retries, retry_delay_ms, updated_at)
|
||||
VALUES (@name, @connStr, @maxRetries, @retryDelayMs, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
connection_string = excluded.connection_string,
|
||||
max_retries = excluded.max_retries,
|
||||
retry_delay_ms = excluded.retry_delay_ms,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@connStr", connectionString);
|
||||
command.Parameters.AddWithValue("@maxRetries", maxRetries);
|
||||
command.Parameters.AddWithValue("@retryDelayMs", (long)retryDelay.TotalMilliseconds);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// ── WP-33: Notification List CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a notification list.
|
||||
/// </summary>
|
||||
public async Task StoreNotificationListAsync(string name, IReadOnlyList<string> recipientEmails)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO notification_lists (name, recipient_emails, updated_at)
|
||||
VALUES (@name, @emails, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
recipient_emails = excluded.recipient_emails,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@emails", System.Text.Json.JsonSerializer.Serialize(recipientEmails));
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -255,3 +430,14 @@ public class DeployedInstance
|
||||
public bool IsEnabled { get; init; }
|
||||
public string DeployedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a shared script stored locally in SQLite (WP-33).
|
||||
/// </summary>
|
||||
public class StoredSharedScript
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
public string? ReturnDefinition { get; init; }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<PackageReference Include="Akka" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Cluster" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Streams" Version="1.5.62" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
|
||||
|
||||
181
src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs
Normal file
181
src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-19: Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
|
||||
/// Forbidden APIs: System.IO, Process, Threading (except async/await), Reflection,
|
||||
/// System.Net.Sockets, System.Net.Http.
|
||||
/// </summary>
|
||||
public class ScriptCompilationService
|
||||
{
|
||||
private readonly ILogger<ScriptCompilationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Namespaces that are forbidden in user scripts for security.
|
||||
/// </summary>
|
||||
private static readonly string[] ForbiddenNamespaces =
|
||||
[
|
||||
"System.IO",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading",
|
||||
"System.Reflection",
|
||||
"System.Net.Sockets",
|
||||
"System.Net.Http"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Specific types/members allowed even within forbidden namespaces.
|
||||
/// async/await is OK despite System.Threading being blocked.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExceptions =
|
||||
[
|
||||
"System.Threading.Tasks",
|
||||
"System.Threading.CancellationToken",
|
||||
"System.Threading.CancellationTokenSource"
|
||||
];
|
||||
|
||||
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the script source code does not reference forbidden APIs.
|
||||
/// Returns a list of violation messages, empty if clean.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ValidateTrustModel(string code)
|
||||
{
|
||||
var violations = new List<string>();
|
||||
var tree = CSharpSyntaxTree.ParseText(code);
|
||||
var root = tree.GetRoot();
|
||||
var text = root.ToFullString();
|
||||
|
||||
foreach (var ns in ForbiddenNamespaces)
|
||||
{
|
||||
if (text.Contains(ns, StringComparison.Ordinal))
|
||||
{
|
||||
// Check if it matches an allowed exception
|
||||
var isAllowed = AllowedExceptions.Any(allowed =>
|
||||
text.Contains(allowed, StringComparison.Ordinal) &&
|
||||
ns != allowed &&
|
||||
allowed.StartsWith(ns, StringComparison.Ordinal));
|
||||
|
||||
// More precise: check each occurrence
|
||||
var idx = 0;
|
||||
while ((idx = text.IndexOf(ns, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var remainder = text.Substring(idx);
|
||||
var matchesAllowed = AllowedExceptions.Any(a =>
|
||||
remainder.StartsWith(a, StringComparison.Ordinal));
|
||||
|
||||
if (!matchesAllowed)
|
||||
{
|
||||
violations.Add($"Forbidden API reference: '{ns}' at position {idx}");
|
||||
break;
|
||||
}
|
||||
idx += ns.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||
/// and parameters dictionary, and returns an object? result.
|
||||
/// </summary>
|
||||
public ScriptCompilationResult Compile(string scriptName, string code)
|
||||
{
|
||||
// Validate trust model
|
||||
var violations = ValidateTrustModel(code);
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} failed trust validation: {Violations}",
|
||||
scriptName, string.Join("; ", violations));
|
||||
return ScriptCompilationResult.Failed(violations);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var scriptOptions = ScriptOptions.Default
|
||||
.WithReferences(
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Math).Assembly)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
var script = CSharpScript.Create<object?>(
|
||||
code,
|
||||
scriptOptions,
|
||||
globalsType: typeof(ScriptGlobals));
|
||||
|
||||
var diagnostics = script.Compile();
|
||||
var errors = diagnostics
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.Select(d => d.GetMessage())
|
||||
.ToList();
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} compilation failed: {Errors}",
|
||||
scriptName, string.Join("; ", errors));
|
||||
return ScriptCompilationResult.Failed(errors);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Script {Script} compiled successfully", scriptName);
|
||||
return ScriptCompilationResult.Succeeded(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error compiling script {Script}", scriptName);
|
||||
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of script compilation, containing either the compiled script or error messages.
|
||||
/// </summary>
|
||||
public class ScriptCompilationResult
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public Script<object?>? CompiledScript { get; }
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
|
||||
{
|
||||
IsSuccess = success;
|
||||
CompiledScript = script;
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
|
||||
new(true, script, []);
|
||||
|
||||
public static ScriptCompilationResult Failed(IReadOnlyList<string> errors) =>
|
||||
new(false, null, errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected
|
||||
/// as the "Instance" global, and parameters are available via "Parameters".
|
||||
/// </summary>
|
||||
public class ScriptGlobals
|
||||
{
|
||||
public ScriptRuntimeContext Instance { get; set; } = null!;
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; set; } =
|
||||
new Dictionary<string, object?>();
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
}
|
||||
172
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
Normal file
172
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors.
|
||||
/// Provides the API surface that user scripts interact with:
|
||||
/// Instance.GetAttribute("name")
|
||||
/// Instance.SetAttribute("name", value)
|
||||
/// Instance.CallScript("scriptName", params)
|
||||
/// Scripts.CallShared("scriptName", params)
|
||||
///
|
||||
/// WP-20: Recursion Limit — call depth tracked and enforced.
|
||||
/// </summary>
|
||||
public class ScriptRuntimeContext
|
||||
{
|
||||
private readonly IActorRef _instanceActor;
|
||||
private readonly IActorRef _self;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly int _currentCallDepth;
|
||||
private readonly int _maxCallDepth;
|
||||
private readonly TimeSpan _askTimeout;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _instanceName;
|
||||
|
||||
public ScriptRuntimeContext(
|
||||
IActorRef instanceActor,
|
||||
IActorRef self,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
int currentCallDepth,
|
||||
int maxCallDepth,
|
||||
TimeSpan askTimeout,
|
||||
string instanceName,
|
||||
ILogger logger)
|
||||
{
|
||||
_instanceActor = instanceActor;
|
||||
_self = self;
|
||||
_sharedScriptLibrary = sharedScriptLibrary;
|
||||
_currentCallDepth = currentCallDepth;
|
||||
_maxCallDepth = maxCallDepth;
|
||||
_askTimeout = askTimeout;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current value of an attribute from the Instance Actor.
|
||||
/// Uses Ask pattern (system boundary between script execution and instance state).
|
||||
/// </summary>
|
||||
public async Task<object?> GetAttribute(string attributeName)
|
||||
{
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var request = new GetAttributeRequest(
|
||||
correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout);
|
||||
|
||||
if (!response.Found)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'",
|
||||
attributeName, _instanceName);
|
||||
}
|
||||
|
||||
return response.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute value. For data-connected attributes, forwards to DCL via Instance Actor.
|
||||
/// For static attributes, updates in-memory and persists to SQLite via Instance Actor.
|
||||
/// All mutations serialized through the Instance Actor mailbox.
|
||||
/// </summary>
|
||||
public void SetAttribute(string attributeName, string value)
|
||||
{
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var command = new SetStaticAttributeCommand(
|
||||
correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow);
|
||||
|
||||
// Tell (fire-and-forget) — mutation serialized through Instance Actor
|
||||
_instanceActor.Tell(command);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a sibling script on the same instance by name (Ask pattern).
|
||||
/// WP-20: Enforces recursion limit.
|
||||
/// WP-22: Uses Ask pattern for CallScript.
|
||||
/// </summary>
|
||||
public async Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? parameters = null)
|
||||
{
|
||||
var nextDepth = _currentCallDepth + 1;
|
||||
if (nextDepth > _maxCallDepth)
|
||||
{
|
||||
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
||||
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
|
||||
_logger.LogError(msg);
|
||||
throw new InvalidOperationException(msg);
|
||||
}
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var request = new ScriptCallRequest(
|
||||
scriptName,
|
||||
parameters,
|
||||
nextDepth,
|
||||
correlationId);
|
||||
|
||||
// Ask the Instance Actor, which routes to the appropriate Script Actor
|
||||
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"CallScript('{scriptName}') failed: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
return result.ReturnValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to shared script execution via the Scripts property.
|
||||
/// </summary>
|
||||
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for Scripts.CallShared() syntax.
|
||||
/// </summary>
|
||||
public class ScriptCallHelper
|
||||
{
|
||||
private readonly SharedScriptLibrary _library;
|
||||
private readonly ScriptRuntimeContext _context;
|
||||
private readonly int _currentCallDepth;
|
||||
private readonly int _maxCallDepth;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
internal ScriptCallHelper(
|
||||
SharedScriptLibrary library,
|
||||
ScriptRuntimeContext context,
|
||||
int currentCallDepth,
|
||||
int maxCallDepth,
|
||||
ILogger logger)
|
||||
{
|
||||
_library = library;
|
||||
_context = context;
|
||||
_currentCallDepth = currentCallDepth;
|
||||
_maxCallDepth = maxCallDepth;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Executes a shared script inline (direct method call, not actor message).
|
||||
/// WP-20: Enforces recursion limit.
|
||||
/// </summary>
|
||||
public async Task<object?> CallShared(
|
||||
string scriptName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var nextDepth = _currentCallDepth + 1;
|
||||
if (nextDepth > _maxCallDepth)
|
||||
{
|
||||
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
||||
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
|
||||
_logger.LogError(msg);
|
||||
throw new InvalidOperationException(msg);
|
||||
}
|
||||
|
||||
return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs
Normal file
114
src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Shared Script Library — stores compiled shared script delegates in memory.
|
||||
/// Shared scripts are compiled when received from central and executed inline
|
||||
/// (direct method call, not actor message). NOT available on central.
|
||||
/// WP-33: Recompiled on update when new artifacts arrive.
|
||||
/// </summary>
|
||||
public class SharedScriptLibrary
|
||||
{
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly ILogger<SharedScriptLibrary> _logger;
|
||||
private readonly Dictionary<string, Script<object?>> _compiledScripts = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SharedScriptLibrary(
|
||||
ScriptCompilationService compilationService,
|
||||
ILogger<SharedScriptLibrary> logger)
|
||||
{
|
||||
_compilationService = compilationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and registers a shared script. Replaces any existing script with the same name.
|
||||
/// Returns true if compilation succeeded, false otherwise.
|
||||
/// </summary>
|
||||
public bool CompileAndRegister(string name, string code)
|
||||
{
|
||||
var result = _compilationService.Compile(name, code);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Shared script '{Name}' failed to compile: {Errors}",
|
||||
name, string.Join("; ", result.Errors));
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_compiledScripts[name] = result.CompiledScript!;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Shared script '{Name}' compiled and registered", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a shared script from the library.
|
||||
/// </summary>
|
||||
public bool Remove(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.Remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a shared script inline with the given runtime context.
|
||||
/// This is a direct method call, not an actor message — executes on the calling thread.
|
||||
/// </summary>
|
||||
public async Task<object?> ExecuteAsync(
|
||||
string scriptName,
|
||||
ScriptRuntimeContext context,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Script<object?> script;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_compiledScripts.TryGetValue(scriptName, out script!))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared script '{scriptName}' not found in library.");
|
||||
}
|
||||
}
|
||||
|
||||
var globals = new ScriptGlobals
|
||||
{
|
||||
Instance = context,
|
||||
Parameters = parameters ?? new Dictionary<string, object?>(),
|
||||
CancellationToken = cancellationToken
|
||||
};
|
||||
|
||||
var state = await script.RunAsync(globals, cancellationToken);
|
||||
return state.ReturnValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the names of all currently registered shared scripts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetRegisteredScriptNames()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.Keys.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a script with the given name is registered.
|
||||
/// </summary>
|
||||
public bool Contains(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.ContainsKey(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime;
|
||||
|
||||
@@ -31,6 +32,12 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddHostedService<SiteStorageInitializer>();
|
||||
|
||||
// WP-19: Script compilation service
|
||||
services.AddSingleton<ScriptCompilationService>();
|
||||
|
||||
// WP-17: Shared script library
|
||||
services.AddSingleton<SharedScriptLibrary>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,4 +17,23 @@ public class SiteRuntimeOptions
|
||||
/// Default: 100ms.
|
||||
/// </summary>
|
||||
public int StartupBatchDelayMs { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum call depth for recursive script calls (CallScript/CallShared).
|
||||
/// Default: 10.
|
||||
/// </summary>
|
||||
public int MaxScriptCallDepth { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Default script execution timeout in seconds.
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public int ScriptExecutionTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscriber buffer size for the site-wide Akka stream.
|
||||
/// Slow subscribers drop oldest messages when buffer is full.
|
||||
/// Default: 1000.
|
||||
/// </summary>
|
||||
public int StreamBufferSize { get; set; } = 1000;
|
||||
}
|
||||
|
||||
176
src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs
Normal file
176
src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using Akka;
|
||||
using Akka.Actor;
|
||||
using Akka.Streams;
|
||||
using Akka.Streams.Dsl;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value
|
||||
/// and alarm state changes. Instance Actors publish events via fire-and-forget Tell.
|
||||
/// Subscribers get per-subscriber bounded buffers with drop-oldest overflow.
|
||||
///
|
||||
/// Filterable by instance name for debug view (WP-25).
|
||||
/// </summary>
|
||||
public class SiteStreamManager
|
||||
{
|
||||
private readonly ActorSystem _system;
|
||||
private readonly int _bufferSize;
|
||||
private readonly ILogger<SiteStreamManager> _logger;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private IActorRef? _sourceActor;
|
||||
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
|
||||
|
||||
public SiteStreamManager(
|
||||
ActorSystem system,
|
||||
SiteRuntimeOptions options,
|
||||
ILogger<SiteStreamManager> logger)
|
||||
{
|
||||
_system = system;
|
||||
_bufferSize = options.StreamBufferSize;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the stream source. Must be called after ActorSystem is ready.
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
var materializer = _system.Materializer();
|
||||
|
||||
var source = Source.ActorRef<ISiteStreamEvent>(
|
||||
_bufferSize,
|
||||
OverflowStrategy.DropHead);
|
||||
|
||||
var (actorRef, _) = source
|
||||
.PreMaterialize(materializer);
|
||||
|
||||
_sourceActor = actorRef;
|
||||
|
||||
_logger.LogInformation(
|
||||
"SiteStreamManager initialized with buffer size {BufferSize}", _bufferSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an attribute value change to the stream.
|
||||
/// Fire-and-forget — never blocks the calling actor.
|
||||
/// </summary>
|
||||
public void PublishAttributeValueChanged(AttributeValueChanged changed)
|
||||
{
|
||||
_sourceActor?.Tell(changed);
|
||||
|
||||
// Also forward to filtered subscribers
|
||||
ForwardToSubscribers(changed.InstanceUniqueName, changed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an alarm state change to the stream.
|
||||
/// Fire-and-forget — never blocks the calling actor.
|
||||
/// </summary>
|
||||
public void PublishAlarmStateChanged(AlarmStateChanged changed)
|
||||
{
|
||||
_sourceActor?.Tell(changed);
|
||||
|
||||
// Also forward to filtered subscribers
|
||||
ForwardToSubscribers(changed.InstanceUniqueName, changed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Subscribe to events for a specific instance (debug view).
|
||||
/// Returns a subscription ID for unsubscribing.
|
||||
/// </summary>
|
||||
public string Subscribe(string instanceName, IActorRef subscriber)
|
||||
{
|
||||
var subscriptionId = Guid.NewGuid().ToString();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_subscriptions[subscriptionId] = new SubscriptionInfo(
|
||||
instanceName, subscriber, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Subscriber {SubscriptionId} registered for instance {Instance}",
|
||||
subscriptionId, instanceName);
|
||||
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Unsubscribe from instance events.
|
||||
/// </summary>
|
||||
public bool Unsubscribe(string subscriptionId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var removed = _subscriptions.Remove(subscriptionId);
|
||||
if (removed)
|
||||
{
|
||||
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Remove all subscriptions for a specific subscriber actor.
|
||||
/// Called when connection is interrupted.
|
||||
/// </summary>
|
||||
public void RemoveSubscriber(IActorRef subscriber)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var toRemove = _subscriptions
|
||||
.Where(kvp => kvp.Value.Subscriber.Equals(subscriber))
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var id in toRemove)
|
||||
{
|
||||
_subscriptions.Remove(id);
|
||||
}
|
||||
|
||||
if (toRemove.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Removed {Count} subscriptions for disconnected subscriber", toRemove.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of active subscriptions (for diagnostics/testing).
|
||||
/// </summary>
|
||||
public int SubscriptionCount
|
||||
{
|
||||
get { lock (_lock) { return _subscriptions.Count; } }
|
||||
}
|
||||
|
||||
private void ForwardToSubscribers(string instanceName, object message)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var sub in _subscriptions.Values)
|
||||
{
|
||||
if (sub.InstanceName == instanceName)
|
||||
{
|
||||
// Fire-and-forget to subscriber
|
||||
sub.Subscriber.Tell(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record SubscriptionInfo(
|
||||
string InstanceName,
|
||||
IActorRef Subscriber,
|
||||
DateTimeOffset SubscribedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for events published to the site stream.
|
||||
/// </summary>
|
||||
public interface ISiteStreamEvent { }
|
||||
Reference in New Issue
Block a user