feat(siteruntime): thread ParentExecutionId into the routed script's ScriptRuntimeContext

This commit is contained in:
Joseph Doherty
2026-05-21 17:35:49 -04:00
parent dc2c73b07d
commit 6af2607a50
8 changed files with 288 additions and 25 deletions

View File

@@ -1,7 +1,17 @@
namespace ScadaLink.Commons.Messages.ScriptExecution; namespace ScadaLink.Commons.Messages.ScriptExecution;
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>.
/// For an inbound-API-routed call this is the inbound request's per-request
/// execution id (carried in from <c>RouteToCallRequest.ParentExecutionId</c>);
/// the routed script execution records it as its <c>ParentExecutionId</c> so a
/// spawned execution points back at its spawner. Additive trailing member —
/// null for normal (tag-change / timer-triggered) runs, nested <c>Script.Call</c>
/// invocations, and any request built before the field existed.
/// </param>
public record ScriptCallRequest( public record ScriptCallRequest(
string ScriptName, string ScriptName,
IReadOnlyDictionary<string, object?>? Parameters, IReadOnlyDictionary<string, object?>? Parameters,
int CurrentCallDepth, int CurrentCallDepth,
string CorrelationId); string CorrelationId,
Guid? ParentExecutionId = null);

View File

@@ -735,9 +735,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{ {
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
{ {
// Convert to ScriptCallRequest and Ask the Instance Actor // Convert to ScriptCallRequest and Ask the Instance Actor.
// Audit Log #23 (ParentExecutionId): carry the inbound request's
// ExecutionId down as ParentExecutionId so the routed script
// execution can record its spawner.
var scriptCall = new ScriptCallRequest( var scriptCall = new ScriptCallRequest(
request.ScriptName, request.Parameters, 0, request.CorrelationId); request.ScriptName, request.Parameters, 0, request.CorrelationId,
ParentExecutionId: request.ParentExecutionId);
var sender = Sender; var sender = Sender;
instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30)) instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30))
.ContinueWith(t => .ContinueWith(t =>

View File

@@ -320,7 +320,10 @@ public class InstanceActor : ReceiveActor
{ {
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor)) if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
{ {
// Forward the request to the Script Actor, preserving the original sender // Forward the request to the Script Actor, preserving the original
// sender. The whole record is forwarded unchanged, so any
// ParentExecutionId (Audit Log #23) set by an inbound-API-routed
// call is carried through to the Script Actor verbatim.
scriptActor.Forward(request); scriptActor.Forward(request);
} }
else else

View File

@@ -184,7 +184,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
return; return;
} }
SpawnExecution(request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId); // Audit Log #23 (ParentExecutionId): carry any inbound-routed
// ParentExecutionId through to the ScriptExecutionActor so the routed
// script's ScriptRuntimeContext can record its spawner. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
SpawnExecution(
request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
request.ParentExecutionId);
} }
/// <summary> /// <summary>
@@ -379,7 +385,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
IReadOnlyDictionary<string, object?>? parameters, IReadOnlyDictionary<string, object?>? parameters,
int callDepth, int callDepth,
IActorRef replyTo, IActorRef replyTo,
string correlationId) string correlationId,
Guid? parentExecutionId = null)
{ {
var executionId = $"{_scriptName}-exec-{_executionCounter++}"; var executionId = $"{_scriptName}-exec-{_executionCounter++}";
@@ -401,7 +408,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
_logger, _logger,
_scope, _scope,
_healthCollector, _healthCollector,
_serviceProvider)); _serviceProvider,
// Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
// an inbound-API-routed call supplies the inbound request's id.
parentExecutionId));
Context.ActorOf(props, executionId); Context.ActorOf(props, executionId);
} }

View File

@@ -43,7 +43,11 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger, ILogger logger,
Commons.Types.Scripts.ScriptScope scope, Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null, ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = 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 // Immediately begin execution
var self = Self; var self = Self;
@@ -52,7 +56,8 @@ public class ScriptExecutionActor : ReceiveActor
ExecuteScript( ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth, scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId, instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, scope, healthCollector, serviceProvider); self, parent, logger, scope, healthCollector, serviceProvider,
parentExecutionId);
} }
private static void ExecuteScript( private static void ExecuteScript(
@@ -71,7 +76,8 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger, ILogger logger,
Commons.Types.Scripts.ScriptScope scope, Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector, ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider) IServiceProvider? serviceProvider,
Guid? parentExecutionId)
{ {
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
@@ -164,7 +170,12 @@ public class ScriptExecutionActor : ReceiveActor
// emission. Best-effort: null degrades the helpers to a // emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId // no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected. // return are unaffected.
cachedForwarder: cachedForwarder); 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);
var globals = new ScriptGlobals var globals = new ScriptGlobals
{ {

View File

@@ -116,6 +116,19 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly Guid _executionId; private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's
/// <see cref="_executionId"/> when this script run was spawned by another
/// execution — for an inbound-API-routed call this is the inbound request's
/// per-request execution id. <c>null</c> for normal (tag-change /
/// timer-triggered) runs and nested <c>CallScript</c> invocations. The
/// routed script still mints its OWN fresh <see cref="_executionId"/>; this
/// field records the spawner so a spawned execution's audit rows can point
/// back at the execution that spawned it. (Task 5 wires the emitter that
/// stamps this onto <c>AuditEvent.ParentExecutionId</c>.)
/// </summary>
private readonly Guid? _parentExecutionId;
/// <param name="executionId"> /// <param name="executionId">
/// Audit Log #23: the per-execution id for this script run. When omitted /// Audit Log #23: the per-execution id for this script run. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an /// (tag-change / timer-triggered executions) a fresh id is generated; an
@@ -123,6 +136,13 @@ public class ScriptRuntimeContext
/// request. Stamped into <c>AuditEvent.ExecutionId</c> on every /// request. Stamped into <c>AuditEvent.ExecutionId</c> on every
/// trust-boundary audit row this execution emits. /// trust-boundary audit row this execution emits.
/// </param> /// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId): the spawning execution's
/// <c>ExecutionId</c> — supplied for an inbound-API-routed call (the
/// inbound request's per-request id), <c>null</c> for normal (tag-change /
/// timer-triggered) runs. The routed script still generates its own fresh
/// <paramref name="executionId"/>; this only records the spawner.
/// </param>
public ScriptRuntimeContext( public ScriptRuntimeContext(
IActorRef instanceActor, IActorRef instanceActor,
IActorRef self, IActorRef self,
@@ -141,7 +161,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null, IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null, ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? executionId = null) Guid? executionId = null,
Guid? parentExecutionId = null)
{ {
_instanceActor = instanceActor; _instanceActor = instanceActor;
_self = self; _self = self;
@@ -161,6 +182,9 @@ public class ScriptRuntimeContext
_operationTrackingStore = operationTrackingStore; _operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_executionId = executionId ?? Guid.NewGuid(); _executionId = executionId ?? Guid.NewGuid();
// Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()`
// fallback. A non-routed run legitimately has no parent and stays null.
_parentExecutionId = parentExecutionId;
} }
/// <summary> /// <summary>
@@ -264,7 +288,10 @@ public class ScriptRuntimeContext
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript, _externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue. // on every ExternalSystem.CachedCall enqueue.
_cachedForwarder); _cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
/// <summary> /// <summary>
/// WP-13: Provides access to database operations. /// WP-13: Provides access to database operations.
@@ -285,7 +312,10 @@ public class ScriptRuntimeContext
_sourceScript, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on // Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
// every Database.CachedWrite enqueue. // every Database.CachedWrite enqueue.
_cachedForwarder); _cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
/// <summary> /// <summary>
/// Provides access to the Notification Outbox API. /// Provides access to the Notification Outbox API.
@@ -302,7 +332,10 @@ public class ScriptRuntimeContext
/// </remarks> /// </remarks>
public NotifyHelper Notify => new( public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger, _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_executionId, _auditWriter); _executionId, _auditWriter,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
/// <summary> /// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations. /// Audit Log #23 (M3): site-local tracking-status API for cached operations.
@@ -384,6 +417,15 @@ public class ScriptRuntimeContext
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _executionId; private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
@@ -398,7 +440,9 @@ public class ScriptRuntimeContext
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required // DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a // Guid cannot follow the optional provenance params without a
// required-after-optional compile error, so the post-logger slot is the // required-after-optional compile error, so the post-logger slot is the
// one consistent position that compiles cleanly everywhere. // one consistent position that compiles cleanly everywhere. The nullable
// parentExecutionId is a trailing optional param so existing positional
// callers stay source-compatible.
internal ExternalSystemHelper( internal ExternalSystemHelper(
IExternalSystemClient? client, IExternalSystemClient? client,
string instanceName, string instanceName,
@@ -407,7 +451,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null) ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
{ {
_client = client; _client = client;
_instanceName = instanceName; _instanceName = instanceName;
@@ -417,6 +462,7 @@ public class ScriptRuntimeContext
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
} }
public async Task<ExternalCallResult> Call( public async Task<ExternalCallResult> Call(
@@ -1001,6 +1047,15 @@ public class ScriptRuntimeContext
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _executionId; private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -1020,7 +1075,7 @@ public class ScriptRuntimeContext
// Parameter ordering: executionId sits immediately after the // Parameter ordering: executionId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the // ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four // post-logger slot is the one consistent position across all four
// audit-threaded ctors. // audit-threaded ctors. parentExecutionId is a trailing optional param.
internal DatabaseHelper( internal DatabaseHelper(
IDatabaseGateway? gateway, IDatabaseGateway? gateway,
string instanceName, string instanceName,
@@ -1029,7 +1084,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null) ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
{ {
_gateway = gateway; _gateway = gateway;
_instanceName = instanceName; _instanceName = instanceName;
@@ -1039,6 +1095,7 @@ public class ScriptRuntimeContext
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
} }
public async Task<System.Data.Common.DbConnection> Connection( public async Task<System.Data.Common.DbConnection> Connection(
@@ -1213,6 +1270,14 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly Guid _executionId; private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
/// <summary> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script /// <c>Notification</c>/<c>NotifySend</c> row produced when the script
@@ -1224,7 +1289,8 @@ public class ScriptRuntimeContext
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger, // Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors. // consistent with the other audit-threaded ctors. parentExecutionId is
// a trailing optional param.
internal NotifyHelper( internal NotifyHelper(
StoreAndForwardService? storeAndForward, StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor, ICanTell? siteCommunicationActor,
@@ -1234,7 +1300,8 @@ public class ScriptRuntimeContext
TimeSpan askTimeout, TimeSpan askTimeout,
ILogger logger, ILogger logger,
Guid executionId, Guid executionId,
IAuditWriter? auditWriter = null) IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null)
{ {
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
_siteCommunicationActor = siteCommunicationActor; _siteCommunicationActor = siteCommunicationActor;
@@ -1245,6 +1312,7 @@ public class ScriptRuntimeContext
_logger = logger; _logger = logger;
_executionId = executionId; _executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
} }
/// <summary> /// <summary>
@@ -1259,7 +1327,10 @@ public class ScriptRuntimeContext
_executionId, _executionId,
// Audit Log #23 (M4 Bundle C): forward the writer so Send() // Audit Log #23 (M4 Bundle C): forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission. // can emit one NotifySend(Submitted) row per accepted submission.
_auditWriter); _auditWriter,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id, threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
} }
/// <summary> /// <summary>
@@ -1340,6 +1411,14 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly Guid _executionId; private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
/// <summary> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after /// <c>Notification</c>/<c>NotifySend</c> row written immediately after
@@ -1356,7 +1435,8 @@ public class ScriptRuntimeContext
string? sourceScript, string? sourceScript,
ILogger logger, ILogger logger,
Guid executionId, Guid executionId,
IAuditWriter? auditWriter = null) IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null)
{ {
_listName = listName; _listName = listName;
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
@@ -1366,6 +1446,7 @@ public class ScriptRuntimeContext
_logger = logger; _logger = logger;
_executionId = executionId; _executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
} }
/// <summary> /// <summary>

View File

@@ -3,6 +3,7 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening; using ScadaLink.Commons.Types.Flattening;
@@ -68,6 +69,28 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
return JsonSerializer.Serialize(config); return JsonSerializer.Serialize(config);
} }
/// <summary>
/// Builds a config carrying a single callable (no-trigger) script that
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
/// to be routed end-to-end through the Instance/Script/ScriptExecution actors.
/// </summary>
private static string MakeConfigWithScriptJson(string instanceName, string scriptName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }
],
Scripts =
[
new ResolvedScript { CanonicalName = scriptName, Code = "return 7;" }
]
};
return JsonSerializer.Serialize(config);
}
[Fact] [Fact]
public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs() public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs()
{ {
@@ -240,4 +263,57 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, response.Status); Assert.Equal(DeploymentStatus.Success, response.Status);
} }
// ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ──
[Fact]
public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully()
{
// A RouteToCallRequest carrying a ParentExecutionId (the inbound
// request's ExecutionId) must be mapped to a ScriptCallRequest and
// routed end-to-end through the Instance/Script/ScriptExecution actors.
// The additive ParentExecutionId field must not break that routing.
var actor = CreateDeploymentManager();
await Task.Delay(500); // empty startup
actor.Tell(new DeployInstanceCommand(
"dep-route", "RoutedPump", "sha256:route",
MakeConfigWithScriptJson("RoutedPump", "DoWork"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000); // let the InstanceActor + ScriptActor spin up
var parentExecutionId = Guid.NewGuid();
actor.Tell(new RouteToCallRequest(
"route-corr-1", "RoutedPump", "DoWork",
Parameters: null, DateTimeOffset.UtcNow, ParentExecutionId: parentExecutionId));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("route-corr-1", response.CorrelationId);
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
}
[Fact]
public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes()
{
// A routed call with no ParentExecutionId (e.g. the Central UI sandbox)
// is the additive-default path — it must route exactly as before.
var actor = CreateDeploymentManager();
await Task.Delay(500);
actor.Tell(new DeployInstanceCommand(
"dep-route2", "RoutedPump2", "sha256:route2",
MakeConfigWithScriptJson("RoutedPump2", "DoWork"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000);
// No ParentExecutionId argument — exercises the additive `= null` default.
actor.Tell(new RouteToCallRequest(
"route-corr-2", "RoutedPump2", "DoWork",
Parameters: null, DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("route-corr-2", response.CorrelationId);
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
}
} }

View File

@@ -62,7 +62,8 @@ public class ExecutionCorrelationContextTests
IExternalSystemClient? externalSystemClient, IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway, IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter, IAuditWriter? auditWriter,
Guid? executionId = null) Guid? executionId = null,
Guid? parentExecutionId = null)
{ {
var compilationService = new ScriptCompilationService( var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance); NullLogger<ScriptCompilationService>.Instance);
@@ -87,7 +88,24 @@ public class ExecutionCorrelationContextTests
auditWriter: auditWriter, auditWriter: auditWriter,
operationTrackingStore: null, operationTrackingStore: null,
cachedForwarder: null, cachedForwarder: null,
executionId: executionId); executionId: executionId,
parentExecutionId: parentExecutionId);
}
/// <summary>
/// Reads a private <see cref="Guid"/>/<see cref="Nullable{Guid}"/> field off a
/// <see cref="ScriptRuntimeContext"/>. The ParentExecutionId plumbing (Audit
/// Log #23, Task 4) only stores the value on the context — no emitter stamps
/// it onto an audit row yet (that is Task 5) — so the field is inspected
/// directly rather than through an emitted row.
/// </summary>
private static object? ReadPrivateField(ScriptRuntimeContext context, string fieldName)
{
var field = typeof(ScriptRuntimeContext).GetField(
fieldName,
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.NotNull(field);
return field!.GetValue(context);
} }
/// <summary> /// <summary>
@@ -183,4 +201,54 @@ public class ExecutionCorrelationContextTests
Assert.Null(apiRow.CorrelationId); Assert.Null(apiRow.CorrelationId);
Assert.Null(dbRow.CorrelationId); Assert.Null(dbRow.CorrelationId);
} }
[Fact]
public void ParentExecutionIdSupplied_StoredVerbatim_AndOwnExecutionIdIsFreshAndDistinct()
{
// Audit Log #23 (ParentExecutionId, Task 4): an inbound-API-routed call
// supplies the spawning execution's ExecutionId as the routed script's
// ParentExecutionId. The context must store that value verbatim AND
// still mint its OWN fresh ExecutionId — the routed script is a new
// execution, it does not inherit the parent's id.
var parentExecutionId = Guid.NewGuid();
var context = CreateContext(
externalSystemClient: null,
databaseGateway: null,
auditWriter: null,
// executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs.
parentExecutionId: parentExecutionId);
var storedParent = ReadPrivateField(context, "_parentExecutionId");
var ownExecutionId = ReadPrivateField(context, "_executionId");
// The parent id is carried through untouched.
Assert.Equal(parentExecutionId, storedParent);
// The routed script's own ExecutionId is freshly generated, non-empty,
// and NOT the parent id — they are separate correlation values.
Assert.NotNull(ownExecutionId);
var ownId = Assert.IsType<Guid>(ownExecutionId);
Assert.NotEqual(Guid.Empty, ownId);
Assert.NotEqual(parentExecutionId, ownId);
}
[Fact]
public void NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNull()
{
// A normal (tag-change / timer) script run is not inbound-API-routed —
// no ParentExecutionId is supplied, so _parentExecutionId stays null
// while the run still gets its own fresh ExecutionId.
var context = CreateContext(
externalSystemClient: null,
databaseGateway: null,
auditWriter: null);
var storedParent = ReadPrivateField(context, "_parentExecutionId");
var ownExecutionId = ReadPrivateField(context, "_executionId");
Assert.Null(storedParent);
var ownId = Assert.IsType<Guid>(ownExecutionId);
Assert.NotEqual(Guid.Empty, ownId);
}
} }