Script execution failures were only written to Serilog, never to the site event log — SiteRuntime did not reference the SiteEventLogging project. ScriptExecutionActor now resolves ISiteEventLogger and emits a 'script'/'Error' event on timeout and exception. The event-log query handler was a per-node actor bound to that node's local SQLite. A ClusterClient query could land on the standby (which records no events) and return nothing. The handler is now a cluster singleton with a proxy, so queries always reach the active node.
170 lines
6.9 KiB
C#
170 lines
6.9 KiB
C#
using Akka.Actor;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.HealthMonitoring;
|
|
using ScadaLink.SiteEventLogging;
|
|
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,
|
|
Commons.Types.Scripts.ScriptScope scope,
|
|
ISiteHealthCollector? healthCollector = null,
|
|
IServiceProvider? serviceProvider = null)
|
|
{
|
|
// Immediately begin execution
|
|
var self = Self;
|
|
var parent = Context.Parent;
|
|
|
|
ExecuteScript(
|
|
scriptName, instanceName, compiledScript, parameters, callDepth,
|
|
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
|
self, parent, logger, scope, healthCollector, serviceProvider);
|
|
}
|
|
|
|
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,
|
|
Commons.Types.Scripts.ScriptScope scope,
|
|
ISiteHealthCollector? healthCollector,
|
|
IServiceProvider? serviceProvider)
|
|
{
|
|
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
|
|
|
// CTS must be created inside the async lambda so it outlives this method
|
|
_ = Task.Run(async () =>
|
|
{
|
|
IServiceScope? serviceScope = null;
|
|
// ISiteEventLogger is a singleton; resolve from the root provider so
|
|
// it is available to the catch blocks regardless of scope state.
|
|
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
|
|
using var cts = new CancellationTokenSource(timeout);
|
|
try
|
|
{
|
|
// Resolve integration services from DI (scoped lifetime)
|
|
IExternalSystemClient? externalSystemClient = null;
|
|
IDatabaseGateway? databaseGateway = null;
|
|
INotificationDeliveryService? notificationService = null;
|
|
|
|
if (serviceProvider != null)
|
|
{
|
|
serviceScope = serviceProvider.CreateScope();
|
|
externalSystemClient = serviceScope.ServiceProvider.GetService<IExternalSystemClient>();
|
|
databaseGateway = serviceScope.ServiceProvider.GetService<IDatabaseGateway>();
|
|
notificationService = serviceScope.ServiceProvider.GetService<INotificationDeliveryService>();
|
|
}
|
|
|
|
var context = new ScriptRuntimeContext(
|
|
instanceActor,
|
|
self,
|
|
sharedScriptLibrary,
|
|
callDepth,
|
|
options.MaxScriptCallDepth,
|
|
timeout,
|
|
instanceName,
|
|
logger,
|
|
externalSystemClient,
|
|
databaseGateway,
|
|
notificationService);
|
|
|
|
var globals = new ScriptGlobals
|
|
{
|
|
Instance = context,
|
|
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
|
|
CancellationToken = cts.Token,
|
|
Scope = scope
|
|
};
|
|
|
|
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)
|
|
{
|
|
healthCollector?.IncrementScriptError();
|
|
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
|
|
logger.LogWarning(errorMsg);
|
|
|
|
// WP-32: Failures recorded to site event log; script NOT disabled after failure.
|
|
_ = siteEventLogger?.LogEventAsync(
|
|
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
|
|
|
|
if (!replyTo.IsNobody())
|
|
{
|
|
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
|
|
}
|
|
|
|
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
healthCollector?.IncrementScriptError();
|
|
// WP-32: Failures recorded 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);
|
|
|
|
_ = siteEventLogger?.LogEventAsync(
|
|
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
|
|
|
|
if (!replyTo.IsNobody())
|
|
{
|
|
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
|
|
}
|
|
|
|
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
|
|
}
|
|
finally
|
|
{
|
|
// Dispose the DI scope (and scoped services) after script execution completes
|
|
serviceScope?.Dispose();
|
|
// Stop self after execution completes
|
|
self.Tell(PoisonPill.Instance);
|
|
}
|
|
});
|
|
}
|
|
}
|