diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs
index e5427ed..52b0e44 100644
--- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs
+++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs
@@ -38,12 +38,22 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _executionId;
+
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
+ /// run was inbound-API-routed; null for non-routed runs. Threaded
+ /// alongside and stamped onto the DbWrite
+ /// audit row.
+ ///
+ private readonly Guid? _parentExecutionId;
+
private readonly ILogger _logger;
private DbConnection? _wrappingConnection;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
- // DatabaseHelper, AuditingDbConnection).
+ // DatabaseHelper, AuditingDbConnection). parentExecutionId is a trailing
+ // optional param so existing positional callers stay source-compatible.
public AuditingDbCommand(
DbCommand inner,
IAuditWriter auditWriter,
@@ -52,7 +62,8 @@ internal sealed class AuditingDbCommand : DbCommand
string instanceName,
string? sourceScript,
ILogger logger,
- Guid executionId)
+ Guid executionId,
+ Guid? parentExecutionId = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -62,6 +73,7 @@ internal sealed class AuditingDbCommand : DbCommand
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
+ _parentExecutionId = parentExecutionId;
}
// -- Forwarded surface ------------------------------------------------
@@ -438,6 +450,9 @@ internal sealed class AuditingDbCommand : DbCommand
// trust-boundary rows from the same script run.
CorrelationId = null,
ExecutionId = _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's id;
+ // null for non-routed runs.
+ ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs
index c5a52e1..9ac91c9 100644
--- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs
+++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs
@@ -37,11 +37,21 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _executionId;
+
+ ///
+ /// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
+ /// run was inbound-API-routed; null for non-routed runs. Threaded
+ /// alongside into the
+ /// so its DbWrite row stamps it.
+ ///
+ private readonly Guid? _parentExecutionId;
+
private readonly ILogger _logger;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
- // DatabaseHelper, AuditingDbCommand).
+ // DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing
+ // optional param so existing positional callers stay source-compatible.
public AuditingDbConnection(
DbConnection inner,
IAuditWriter auditWriter,
@@ -50,7 +60,8 @@ internal sealed class AuditingDbConnection : DbConnection
string instanceName,
string? sourceScript,
ILogger logger,
- Guid executionId)
+ Guid executionId,
+ Guid? parentExecutionId = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -60,6 +71,7 @@ internal sealed class AuditingDbConnection : DbConnection
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
+ _parentExecutionId = parentExecutionId;
}
// ConnectionString is settable on DbConnection — forward both halves.
@@ -99,7 +111,10 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName,
_sourceScript,
_logger,
- _executionId);
+ _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's id,
+ // threaded alongside _executionId. Null for non-routed runs.
+ _parentExecutionId);
}
protected override void Dispose(bool disposing)
diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
index a112928..57e6c11 100644
--- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
+++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
@@ -626,6 +626,9 @@ public class ScriptRuntimeContext
// per-execution id shared across this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning
+ // execution's id; null for non-routed runs.
+ ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -739,6 +742,9 @@ public class ScriptRuntimeContext
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning
+ // execution's id; null for non-routed runs.
+ ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -803,6 +809,9 @@ public class ScriptRuntimeContext
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning
+ // execution's id; null for non-routed runs.
+ ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -980,6 +989,9 @@ public class ScriptRuntimeContext
// one script run can be correlated together.
CorrelationId = null,
ExecutionId = _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's
+ // id; null for non-routed runs.
+ ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -1127,7 +1139,10 @@ public class ScriptRuntimeContext
instanceName: _instanceName,
sourceScript: _sourceScript,
logger: _logger,
- executionId: _executionId);
+ executionId: _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning execution's
+ // id, threaded alongside _executionId. Null for non-routed runs.
+ parentExecutionId: _parentExecutionId);
}
///
@@ -1203,6 +1218,9 @@ public class ScriptRuntimeContext
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning
+ // execution's id; null for non-routed runs.
+ ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -1571,6 +1589,9 @@ public class ScriptRuntimeContext
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId,
ExecutionId = _executionId,
+ // Audit Log #23 (ParentExecutionId): the spawning
+ // execution's id; null for non-routed runs.
+ ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
index 405b38e..e10216c 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
@@ -47,7 +47,8 @@ public class DatabaseCachedWriteEmissionTests
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
- ICachedCallTelemetryForwarder? forwarder)
+ ICachedCallTelemetryForwarder? forwarder,
+ Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
@@ -59,7 +60,8 @@ public class DatabaseCachedWriteEmissionTests
TestExecutionId,
siteId: SiteId,
sourceScript: SourceScript,
- cachedForwarder: forwarder);
+ cachedForwarder: forwarder,
+ parentExecutionId: parentExecutionId);
}
[Fact]
@@ -91,6 +93,8 @@ public class DatabaseCachedWriteEmissionTests
// ExecutionId is the per-execution id from the runtime context.
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
+ // Audit Log #23 (ParentExecutionId): null for a non-routed run.
+ Assert.Null(packet.Audit.ParentExecutionId);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("DbOutbound", packet.Operational.Channel);
@@ -126,6 +130,32 @@ public class DatabaseCachedWriteEmissionTests
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
+ [Fact]
+ public async Task CachedWrite_RoutedRun_StampsParentExecutionId_OnSubmitTelemetry()
+ {
+ // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
+ // carries the spawning execution's id; the CachedSubmit telemetry row
+ // must stamp it in ParentExecutionId.
+ var parentExecutionId = Guid.NewGuid();
+ var gateway = new Mock();
+ gateway
+ .Setup(g => g.CachedWriteAsync(
+ It.IsAny(), It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ var forwarder = new CapturingForwarder();
+
+ var helper = CreateHelper(gateway.Object, forwarder, parentExecutionId);
+ await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
+
+ var packet = Assert.Single(forwarder.Telemetry);
+ Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
+ }
+
[Fact]
public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway()
{
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
index 021cae5..f610e80 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
@@ -63,7 +63,8 @@ public class DatabaseSyncEmissionTests
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter,
- Guid executionId)
+ Guid executionId,
+ Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
@@ -73,7 +74,8 @@ public class DatabaseSyncEmissionTests
auditWriter: auditWriter,
siteId: SiteId,
sourceScript: SourceScript,
- cachedForwarder: null);
+ cachedForwarder: null,
+ parentExecutionId: parentExecutionId);
}
///
@@ -287,9 +289,62 @@ public class DatabaseSyncEmissionTests
// a sync one-shot call has no operation lifecycle.
Assert.Equal(TestExecutionId, evt.ExecutionId);
Assert.Null(evt.CorrelationId);
+ // Audit Log #23 (ParentExecutionId): null for a non-routed run — the
+ // default CreateHelper supplies no parentExecutionId.
+ Assert.Null(evt.ParentExecutionId);
Assert.NotEqual(Guid.Empty, evt.EventId);
}
+ [Fact]
+ public async Task SyncDbWrite_RoutedRun_StampsParentExecutionId_FromContext()
+ {
+ // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
+ // carries the spawning execution's id; the sync DbWrite row must stamp
+ // it in ParentExecutionId alongside its own fresh ExecutionId.
+ using var keepAlive = new SqliteConnection("Data Source=kp;Mode=Memory;Cache=Shared");
+ var inner = NewInMemoryDb(out var _);
+ var gateway = new Mock();
+ gateway
+ .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny()))
+ .ReturnsAsync(inner);
+ var writer = new CapturingAuditWriter();
+ var executionId = Guid.NewGuid();
+ var parentExecutionId = Guid.NewGuid();
+
+ var helper = CreateHelper(gateway.Object, writer, executionId, parentExecutionId);
+ await using var conn = await helper.Connection(ConnectionName);
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "INSERT INTO t (id, name) VALUES (9, 'theta')";
+ await cmd.ExecuteNonQueryAsync();
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Equal(parentExecutionId, evt.ParentExecutionId);
+ Assert.Equal(executionId, evt.ExecutionId);
+ }
+
+ [Fact]
+ public async Task SyncDbWrite_NonRoutedRun_ParentExecutionIdIsNull()
+ {
+ // A normal (tag/timer) run is not routed — no parent id supplied, so
+ // the emitted DbWrite row's ParentExecutionId stays null.
+ using var keepAlive = new SqliteConnection("Data Source=kn;Mode=Memory;Cache=Shared");
+ var inner = NewInMemoryDb(out var _);
+ var gateway = new Mock();
+ gateway
+ .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny()))
+ .ReturnsAsync(inner);
+ var writer = new CapturingAuditWriter();
+
+ var helper = CreateHelper(gateway.Object, writer);
+ await using var conn = await helper.Connection(ConnectionName);
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "INSERT INTO t (id, name) VALUES (10, 'iota')";
+ await cmd.ExecuteNonQueryAsync();
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Null(evt.ParentExecutionId);
+ }
+
[Fact]
public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId()
{
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
index 280830d..ab7412f 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
@@ -92,22 +92,6 @@ public class ExecutionCorrelationContextTests
parentExecutionId: parentExecutionId);
}
- ///
- /// Reads a private / field off a
- /// . 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.
- ///
- 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);
- }
-
///
/// Spin up a fresh in-memory SQLite database with a tiny single-table
/// schema. The keep-alive root must outlive any auditing wrapper the test
@@ -203,52 +187,99 @@ public class ExecutionCorrelationContextTests
}
[Fact]
- public void ParentExecutionIdSupplied_StoredVerbatim_AndOwnExecutionIdIsFreshAndDistinct()
+ public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId()
{
- // Audit Log #23 (ParentExecutionId, Task 4): an inbound-API-routed call
+ // Audit Log #23 (ParentExecutionId, Task 5): 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.
+ // ParentExecutionId. Every audit row the routed script emits must carry
+ // that value in AuditEvent.ParentExecutionId — and still carry its OWN
+ // fresh ExecutionId, distinct from the parent (the routed script is a
+ // new execution, it does not inherit the parent's id).
var parentExecutionId = Guid.NewGuid();
+ var client = new Mock();
+ client
+ .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{}", null));
+ var writer = new CapturingAuditWriter();
+
var context = CreateContext(
- externalSystemClient: null,
+ client.Object,
databaseGateway: null,
- auditWriter: null,
+ writer,
// executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs.
parentExecutionId: parentExecutionId);
+ await context.ExternalSystem.Call("ERP", "GetOrder");
- var storedParent = ReadPrivateField(context, "_parentExecutionId");
- var ownExecutionId = ReadPrivateField(context, "_executionId");
-
- // The parent id is carried through untouched.
- Assert.Equal(parentExecutionId, storedParent);
-
+ var evt = Assert.Single(writer.Events);
+ // The parent id is stamped on the emitted row untouched.
+ Assert.Equal(parentExecutionId, evt.ParentExecutionId);
// 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(ownExecutionId);
- Assert.NotEqual(Guid.Empty, ownId);
- Assert.NotEqual(parentExecutionId, ownId);
+ Assert.NotNull(evt.ExecutionId);
+ Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
+ Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value);
}
[Fact]
- public void NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNull()
+ public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow()
{
// 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.
+ // no ParentExecutionId is supplied, so every emitted audit row carries
+ // a null ParentExecutionId while the run still gets its own fresh
+ // ExecutionId.
+ var client = new Mock();
+ client
+ .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{}", null));
+ var writer = new CapturingAuditWriter();
+
+ var context = CreateContext(client.Object, databaseGateway: null, writer);
+ await context.ExternalSystem.Call("ERP", "GetOrder");
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Null(evt.ParentExecutionId);
+ Assert.NotNull(evt.ExecutionId);
+ Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
+ }
+
+ [Fact]
+ public async Task ParentExecutionIdSupplied_StampedOnApiAndDbRows_FromSameContext()
+ {
+ // The execution-wide contract extends to ParentExecutionId: an
+ // ExternalSystem.Call and a sync Database write performed through ONE
+ // routed context both carry the identical ParentExecutionId.
+ var parentExecutionId = Guid.NewGuid();
+ using var keepAlive = new SqliteConnection("Data Source=ecc-parent;Mode=Memory;Cache=Shared");
+ var innerDb = NewInMemoryDb(out var _);
+
+ var client = new Mock();
+ client
+ .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{}", null));
+
+ var gateway = new Mock();
+ gateway
+ .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny()))
+ .ReturnsAsync(innerDb);
+
+ var writer = new CapturingAuditWriter();
var context = CreateContext(
- externalSystemClient: null,
- databaseGateway: null,
- auditWriter: null);
+ client.Object, gateway.Object, writer, parentExecutionId: parentExecutionId);
- var storedParent = ReadPrivateField(context, "_parentExecutionId");
- var ownExecutionId = ReadPrivateField(context, "_executionId");
+ await context.ExternalSystem.Call("ERP", "GetOrder");
- Assert.Null(storedParent);
- var ownId = Assert.IsType(ownExecutionId);
- Assert.NotEqual(Guid.Empty, ownId);
+ await using (var conn = await context.Database.Connection(ConnectionName))
+ await using (var cmd = conn.CreateCommand())
+ {
+ cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ Assert.Equal(2, writer.Events.Count);
+ var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
+ var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);
+ Assert.Equal(parentExecutionId, apiRow.ParentExecutionId);
+ Assert.Equal(parentExecutionId, dbRow.ParentExecutionId);
}
}
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
index ce392cb..4bf31c3 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
@@ -49,7 +49,8 @@ public class ExternalSystemCachedCallEmissionTests
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
- ICachedCallTelemetryForwarder? forwarder)
+ ICachedCallTelemetryForwarder? forwarder,
+ Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
@@ -62,7 +63,8 @@ public class ExternalSystemCachedCallEmissionTests
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
- cachedForwarder: forwarder);
+ cachedForwarder: forwarder,
+ parentExecutionId: parentExecutionId);
}
[Fact]
@@ -386,6 +388,40 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal("Delivered", resolve.Operational.Status);
// Terminal row carries TerminalAtUtc.
Assert.NotNull(resolve.Operational.TerminalAtUtc);
+
+ // Audit Log #23 (ParentExecutionId): null on every script-side cached
+ // row for a non-routed run.
+ Assert.Null(submit.Audit.ParentExecutionId);
+ Assert.Null(attempted.Audit.ParentExecutionId);
+ Assert.Null(resolve.Audit.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task CachedCall_RoutedRun_StampsParentExecutionId_OnAllScriptSideRows()
+ {
+ // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
+ // carries the spawning execution's id; every script-side cached row
+ // (CachedSubmit, ApiCallCached, CachedResolve) must stamp it in
+ // ParentExecutionId.
+ var parentExecutionId = Guid.NewGuid();
+ var client = new Mock();
+ client
+ .Setup(c => c.CachedCallAsync(
+ "ERP", "GetOrder",
+ It.IsAny?>(),
+ InstanceName,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
+ var forwarder = new CapturingForwarder();
+
+ var helper = CreateHelper(client.Object, forwarder, parentExecutionId);
+ await helper.CachedCall("ERP", "GetOrder");
+
+ Assert.Equal(3, forwarder.Telemetry.Count);
+ Assert.All(forwarder.Telemetry, t =>
+ Assert.Equal(parentExecutionId, t.Audit.ParentExecutionId));
}
///
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs
index 6632783..b9e7948 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs
@@ -60,7 +60,8 @@ public class ExternalSystemCallAuditEmissionTests
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter,
- Guid executionId)
+ Guid executionId,
+ Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
@@ -69,7 +70,9 @@ public class ExternalSystemCallAuditEmissionTests
executionId,
auditWriter,
SiteId,
- SourceScript);
+ SourceScript,
+ cachedForwarder: null,
+ parentExecutionId: parentExecutionId);
}
[Fact]
@@ -230,6 +233,48 @@ public class ExternalSystemCallAuditEmissionTests
// a sync one-shot call has no operation lifecycle.
Assert.Equal(TestExecutionId, evt.ExecutionId);
Assert.Null(evt.CorrelationId);
+ // Audit Log #23 (ParentExecutionId): null for a non-routed run — the
+ // default CreateHelper supplies no parentExecutionId.
+ Assert.Null(evt.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task Call_RoutedRun_StampsParentExecutionId_FromContext()
+ {
+ // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
+ // carries the spawning execution's id; the sync ApiCall row must stamp
+ // it in ParentExecutionId alongside its own fresh ExecutionId.
+ var client = new Mock();
+ client
+ .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{}", null));
+ var writer = new CapturingAuditWriter();
+ var parentExecutionId = Guid.NewGuid();
+
+ var helper = CreateHelper(client.Object, writer, TestExecutionId, parentExecutionId);
+ await helper.Call("ERP", "GetOrder");
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Equal(parentExecutionId, evt.ParentExecutionId);
+ Assert.Equal(TestExecutionId, evt.ExecutionId);
+ }
+
+ [Fact]
+ public async Task Call_NonRoutedRun_ParentExecutionIdIsNull()
+ {
+ // A normal (tag/timer) run is not routed — no parent id supplied, so
+ // the emitted ApiCall row's ParentExecutionId stays null.
+ var client = new Mock();
+ client
+ .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, "{}", null));
+ var writer = new CapturingAuditWriter();
+
+ var helper = CreateHelper(client.Object, writer);
+ await helper.Call("ERP", "GetOrder");
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Null(evt.ParentExecutionId);
}
[Fact]
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs
index 402821b..89fdbea 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs
@@ -95,7 +95,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
private ScriptRuntimeContext.NotifyHelper CreateHelper(
IAuditWriter? auditWriter,
- string? sourceScript = SourceScript)
+ string? sourceScript = SourceScript,
+ Guid? parentExecutionId = null)
{
// siteCommunicationActor is unused by Send — pass a probe so the helper
// is fully constructed.
@@ -109,7 +110,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
TimeSpan.FromSeconds(3),
NullLogger.Instance,
TestExecutionId,
- auditWriter);
+ auditWriter,
+ parentExecutionId: parentExecutionId);
}
[Fact]
@@ -229,6 +231,39 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
Assert.NotNull(evt.CorrelationId);
Assert.Equal(expected, evt.CorrelationId);
Assert.Equal(TestExecutionId, evt.ExecutionId);
+ // Audit Log #23 (ParentExecutionId): null for a non-routed run.
+ Assert.Null(evt.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task Send_RoutedRun_StampsParentExecutionId_OnNotifySendRow()
+ {
+ // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
+ // carries the spawning execution's id; the NotifySend row must stamp
+ // it in ParentExecutionId alongside its own ExecutionId.
+ var parentExecutionId = Guid.NewGuid();
+ var writer = new CapturingAuditWriter();
+ var notify = CreateHelper(writer, parentExecutionId: parentExecutionId);
+
+ await notify.To(ListName).Send(Subject, Body);
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Equal(parentExecutionId, evt.ParentExecutionId);
+ Assert.Equal(TestExecutionId, evt.ExecutionId);
+ }
+
+ [Fact]
+ public async Task Send_NonRoutedRun_ParentExecutionIdIsNull()
+ {
+ // A normal (tag/timer) run is not routed — the NotifySend row's
+ // ParentExecutionId stays null.
+ var writer = new CapturingAuditWriter();
+ var notify = CreateHelper(writer);
+
+ await notify.To(ListName).Send(Subject, Body);
+
+ var evt = Assert.Single(writer.Events);
+ Assert.Null(evt.ParentExecutionId);
}
[Fact]