Site: inject INodeIdentityProvider where NotificationSubmit is built; stamp SourceNode = NodeName at construction. Central: NotificationOutboxActor.HandleSubmit copies submit.SourceNode onto the Notification row; the repository INSERT persists it (EF tracked-entity insert flows it through automatically; raw-SQL extension if not). After this commit, every Notifications row carries the originating site node-a/node-b in SourceNode. Existing notifications submitted pre-feature remain NULL.
257 lines
13 KiB
C#
257 lines
13 KiB
C#
using Akka.Actor;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using ScadaLink.Commons.Interfaces;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.HealthMonitoring;
|
|
using ScadaLink.SiteEventLogging;
|
|
using ScadaLink.SiteRuntime.Scripts;
|
|
using ScadaLink.StoreAndForward;
|
|
|
|
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.
|
|
/// Executes the script via Script Runtime API, returns result, then stops.
|
|
///
|
|
/// The actor itself and its mailbox run on the default Akka dispatcher; only the
|
|
/// script body is dispatched off the actor thread, onto the dedicated
|
|
/// <see cref="ScadaLink.SiteRuntime.Scripts.ScriptExecutionScheduler"/>
|
|
/// (SiteRuntime-009), so blocking script I/O cannot starve the shared thread pool
|
|
/// or stall other Akka dispatchers.
|
|
///
|
|
/// 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,
|
|
// Audit Log #23 (ParentExecutionId): the spawning execution's
|
|
// ExecutionId for an inbound-API-routed call. Null for normal
|
|
// (tag-change / timer) runs and nested Script.Call invocations.
|
|
Guid? parentExecutionId = 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,
|
|
parentExecutionId);
|
|
}
|
|
|
|
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,
|
|
Guid? parentExecutionId)
|
|
{
|
|
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
|
|
|
// SiteRuntime-009: run the script body on the dedicated script-execution
|
|
// scheduler, not the shared .NET thread pool, so blocking script I/O cannot
|
|
// starve the global pool and stall Akka dispatchers / HTTP handling.
|
|
var scheduler = ScriptExecutionScheduler.Shared(options);
|
|
|
|
// Notification Outbox: the site communication actor that Notify.Status queries
|
|
// central through. Resolved by actor path so the Notify helper does not need an
|
|
// IActorRef threaded all the way down from the host wiring.
|
|
var siteCommunicationActor = Context.System.ActorSelection("/user/site-communication");
|
|
|
|
// CTS must be created inside the async lambda so it outlives this method
|
|
_ = Task.Factory.StartNew(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;
|
|
// Notification Outbox: the S&F engine is a singleton; the site identity
|
|
// provider supplies the site id stamped on enqueued notifications.
|
|
StoreAndForwardService? storeAndForward = null;
|
|
var siteId = string.Empty;
|
|
// Audit Log #23 (M2 Bundle F): the writer is a singleton (FallbackAuditWriter
|
|
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
|
|
// that haven't called AddAuditLog, which the helper handles as a no-op.
|
|
IAuditWriter? auditWriter = null;
|
|
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
|
|
// backing Tracking.Status(id). Singleton; null in tests / hosts
|
|
// that haven't wired the store, which the helper handles by
|
|
// throwing on access.
|
|
IOperationTrackingStore? operationTrackingStore = null;
|
|
// Audit Log #23 (M3 Bundle F — Task F1): site-side cached-call
|
|
// telemetry forwarder. Singleton bound to the AuditLog
|
|
// composition root; null in tests / hosts that haven't called
|
|
// AddAuditLog, in which case the cached-call helpers degrade
|
|
// to the no-emission path (the underlying S&F handoff still
|
|
// happens and a TrackedOperationId is still returned).
|
|
ICachedCallTelemetryForwarder? cachedForwarder = null;
|
|
// SourceNode-stamping (Tasks 13/14): the local node name
|
|
// resolved from INodeIdentityProvider — node-a/node-b on site
|
|
// hosts. Null in tests / hosts that haven't registered the
|
|
// provider, in which case NotificationSubmit.SourceNode and
|
|
// SiteCallOperational.SourceNode stay null and central
|
|
// persists the rows with SourceNode NULL.
|
|
string? sourceNode = null;
|
|
|
|
if (serviceProvider != null)
|
|
{
|
|
serviceScope = serviceProvider.CreateScope();
|
|
externalSystemClient = serviceScope.ServiceProvider.GetService<IExternalSystemClient>();
|
|
databaseGateway = serviceScope.ServiceProvider.GetService<IDatabaseGateway>();
|
|
storeAndForward = serviceScope.ServiceProvider.GetService<StoreAndForwardService>();
|
|
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
|
|
?? string.Empty;
|
|
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
|
|
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
|
|
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
|
|
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
|
|
}
|
|
|
|
var context = new ScriptRuntimeContext(
|
|
instanceActor,
|
|
self,
|
|
sharedScriptLibrary,
|
|
callDepth,
|
|
options.MaxScriptCallDepth,
|
|
timeout,
|
|
instanceName,
|
|
logger,
|
|
externalSystemClient,
|
|
databaseGateway,
|
|
storeAndForward,
|
|
siteCommunicationActor,
|
|
siteId,
|
|
// Notification Outbox (FU3): stamp the executing script onto outbound
|
|
// notifications using the Site Event Logging "Source" convention.
|
|
sourceScript: $"ScriptActor:{scriptName}",
|
|
// Audit Log #23 (M2 Bundle F): emit one ApiOutbound/ApiCall row per
|
|
// ExternalSystem.Call. Writer is best-effort; failures are logged
|
|
// and swallowed inside the helper so the script's call path is
|
|
// never aborted by an audit failure.
|
|
auditWriter: auditWriter,
|
|
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
|
|
// backing Tracking.Status(id). Authoritative source of truth for
|
|
// cached-call status — read directly by the script API.
|
|
operationTrackingStore: operationTrackingStore,
|
|
// Audit Log #23 (M3 Bundle F — Task F1): cached-call telemetry
|
|
// forwarder for ExternalSystem.CachedCall / Database.CachedWrite
|
|
// CachedSubmit emission + the immediate-success terminal-row
|
|
// emission. Best-effort: null degrades the helpers to a
|
|
// no-emission path; the S&F handoff and TrackedOperationId
|
|
// return are unaffected.
|
|
cachedForwarder: cachedForwarder,
|
|
// Audit Log #23 (ParentExecutionId): the spawning execution's
|
|
// id for an inbound-API-routed call. The routed script still
|
|
// mints its own fresh ExecutionId — this records the spawner.
|
|
// Null for normal (tag-change / timer) runs.
|
|
parentExecutionId: parentExecutionId,
|
|
// SourceNode-stamping (Tasks 13/14): the local node name
|
|
// (node-a/node-b on a site) — threaded down so Notify.Send
|
|
// and the four cached-call telemetry constructors can stamp
|
|
// it onto NotificationSubmit.SourceNode and
|
|
// SiteCallOperational.SourceNode respectively.
|
|
sourceNode: sourceNode);
|
|
|
|
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);
|
|
}
|
|
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
|
|
}
|
|
}
|