diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
index 258bbaa..121370f 100644
--- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
+++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
@@ -137,6 +137,12 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
ExecutionId = context.ExecutionId,
+ // Audit Log #23 (ParentExecutionId Task 6): the spawning
+ // inbound-API request's ExecutionId, threaded through the S&F
+ // buffer alongside ExecutionId so the retry-loop cached rows
+ // correlate back to the cross-execution chain. Null for a
+ // non-routed run and on rows buffered before Task 6.
+ ParentExecutionId = context.ParentExecutionId,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs
index 6188f0c..cbc365c 100644
--- a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs
+++ b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs
@@ -71,6 +71,16 @@ public interface ICachedCallLifecycleObserver
/// rows carry the same SourceScript provenance the script-side cached
/// rows already do. null when not known.
///
+///
+/// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the
+/// inbound-API request that spawned the originating script execution,
+/// threaded through the store-and-forward buffer alongside
+/// . The audit bridge stamps it onto the
+/// retry-loop ApiCallCached/DbWriteCached Attempted and
+/// CachedResolve rows so they correlate back to the spawning run.
+/// null for a non-routed run and for rows buffered before Task 6
+/// (back-compat).
+///
public sealed record CachedCallAttemptContext(
TrackedOperationId TrackedOperationId,
string Channel,
@@ -85,7 +95,8 @@ public sealed record CachedCallAttemptContext(
int? DurationMs,
string? SourceInstanceId,
Guid? ExecutionId = null,
- string? SourceScript = null);
+ string? SourceScript = null,
+ Guid? ParentExecutionId = null);
///
/// Coarse outcome of one cached-call delivery attempt, observed from inside
diff --git a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs
index 0271bec..c7a89fd 100644
--- a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs
+++ b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs
@@ -40,6 +40,14 @@ public interface IDatabaseGateway
/// threaded onto the buffered S&F message alongside
/// . null when not known.
///
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the
+ /// inbound-API request that spawned the originating script execution.
+ /// When the write is buffered on a transient failure this is threaded onto
+ /// the S&F message alongside so the
+ /// retry-loop cached-write audit rows carry it. null for a
+ /// non-routed run.
+ ///
Task CachedWriteAsync(
string connectionName,
string sql,
@@ -48,5 +56,6 @@ public interface IDatabaseGateway
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
- string? sourceScript = null);
+ string? sourceScript = null,
+ Guid? parentExecutionId = null);
}
diff --git a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs
index d4f855c..107156f 100644
--- a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs
+++ b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs
@@ -41,6 +41,14 @@ public interface IExternalSystemClient
/// threaded onto the buffered S&F message alongside
/// . null when not known.
///
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the
+ /// inbound-API request that spawned the originating script execution.
+ /// When the call is buffered on a transient failure this is threaded onto
+ /// the S&F message alongside so the
+ /// retry-loop cached-call audit rows carry it. null for a non-routed
+ /// run.
+ ///
Task CachedCallAsync(
string systemName,
string methodName,
@@ -49,7 +57,8 @@ public interface IExternalSystemClient
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
- string? sourceScript = null);
+ string? sourceScript = null,
+ Guid? parentExecutionId = null);
}
///
diff --git a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs
index efa29c4..3ac3794 100644
--- a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs
+++ b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs
@@ -86,7 +86,8 @@ public class DatabaseGateway : IDatabaseGateway
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
- string? sourceScript = null)
+ string? sourceScript = null,
+ Guid? parentExecutionId = null)
{
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
if (definition == null)
@@ -132,7 +133,12 @@ public class DatabaseGateway : IDatabaseGateway
// the retry-loop cached-write audit rows carry the same provenance
// the script-side cached rows do.
executionId: executionId,
- sourceScript: sourceScript);
+ sourceScript: sourceScript,
+ // Audit Log #23 (ParentExecutionId Task 6): thread the spawning
+ // inbound-API request's ExecutionId onto the buffered row so the
+ // retry-loop cached-write audit rows correlate back to the
+ // cross-execution chain. Null for a non-routed run.
+ parentExecutionId: parentExecutionId);
}
///
diff --git a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs
index dfe042b..6db1093 100644
--- a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs
+++ b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs
@@ -88,7 +88,8 @@ public class ExternalSystemClient : IExternalSystemClient
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
- string? sourceScript = null)
+ string? sourceScript = null,
+ Guid? parentExecutionId = null)
{
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
if (system == null || method == null)
@@ -152,7 +153,12 @@ public class ExternalSystemClient : IExternalSystemClient
// buffered row so the retry-loop cached-call audit rows carry
// the same provenance the script-side cached rows do.
executionId: executionId,
- sourceScript: sourceScript);
+ sourceScript: sourceScript,
+ // Audit Log #23 (ParentExecutionId Task 6): thread the spawning
+ // inbound-API request's ExecutionId onto the buffered row so
+ // the retry-loop cached-call audit rows correlate back to the
+ // cross-execution chain. Null for a non-routed run.
+ parentExecutionId: parentExecutionId);
return new ExternalCallResult(true, null, null, WasBuffered: true);
}
diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
index 57e6c11..f581645 100644
--- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
+++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
@@ -564,7 +564,12 @@ public class ScriptRuntimeContext
// execution's ExecutionId + SourceScript so a buffered
// cached call's retry-loop audit rows carry them.
executionId: _executionId,
- sourceScript: _sourceScript).ConfigureAwait(false);
+ sourceScript: _sourceScript,
+ // Audit Log #23 (ParentExecutionId Task 6): thread the
+ // spawning inbound-API request's ExecutionId so a buffered
+ // cached call's retry-loop audit rows carry it too. Null
+ // for a non-routed run.
+ parentExecutionId: _parentExecutionId).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -1178,7 +1183,12 @@ public class ScriptRuntimeContext
// execution's ExecutionId + SourceScript so a buffered
// cached write's retry-loop audit rows carry them.
executionId: _executionId,
- sourceScript: _sourceScript)
+ sourceScript: _sourceScript,
+ // Audit Log #23 (ParentExecutionId Task 6): thread the
+ // spawning inbound-API request's ExecutionId so a buffered
+ // cached write's retry-loop audit rows carry it too. Null
+ // for a non-routed run.
+ parentExecutionId: _parentExecutionId)
.ConfigureAwait(false);
}
catch (Exception ex)
diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs
index 3e799ac..ba521af 100644
--- a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs
+++ b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs
@@ -76,4 +76,19 @@ public class StoreAndForwardMessage
/// known (non-cached categories, pre-migration rows).
///
public string? SourceScript { get; set; }
+
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the
+ /// inbound-API request that spawned the originating script execution,
+ /// threaded alongside from the cached-call enqueue
+ /// path. Carried so the store-and-forward retry loop can stamp it onto the
+ /// per-attempt / terminal cached-call audit rows
+ /// (ApiCallCached/DbWriteCached Attempted, CachedResolve),
+ /// keeping them correlated with the cross-execution chain. null for a
+ /// non-routed run, for non-cached-call categories (notifications), and for
+ /// rows buffered before this field existed — back-compat with old persisted
+ /// rows (the column is added by an additive migration and read as null when
+ /// absent).
+ ///
+ public Guid? ParentExecutionId { get; set; }
}
diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs
index fe27528..a8d6245 100644
--- a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs
+++ b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs
@@ -187,6 +187,14 @@ public class StoreAndForwardService
/// so the retry-loop audit rows carry the same provenance the script-side
/// cached rows do. null when not known.
///
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the
+ /// inbound-API request that spawned the originating script execution.
+ /// Threaded onto the buffered row alongside
+ /// so the retry-loop cached-call audit rows carry it. null for a
+ /// non-routed run and for callers (notifications, pre-Task-6 callers) that
+ /// do not supply one.
+ ///
public async Task EnqueueAsync(
StoreAndForwardCategory category,
string target,
@@ -197,7 +205,8 @@ public class StoreAndForwardService
bool attemptImmediateDelivery = true,
string? messageId = null,
Guid? executionId = null,
- string? sourceScript = null)
+ string? sourceScript = null,
+ Guid? parentExecutionId = null)
{
var message = new StoreAndForwardMessage
{
@@ -212,7 +221,8 @@ public class StoreAndForwardService
Status = StoreAndForwardMessageStatus.Pending,
OriginInstanceName = originInstanceName,
ExecutionId = executionId,
- SourceScript = sourceScript
+ SourceScript = sourceScript,
+ ParentExecutionId = parentExecutionId
};
// Attempt immediate delivery — unless the caller has already made a
@@ -515,7 +525,13 @@ public class StoreAndForwardService
// stamp the retry-loop cached audit rows. Null on rows buffered
// before Task 4 (back-compat).
ExecutionId: message.ExecutionId,
- SourceScript: message.SourceScript);
+ SourceScript: message.SourceScript,
+ // Audit Log #23 (ParentExecutionId Task 6): the buffered
+ // message also carries the spawning inbound-API request's
+ // ExecutionId; surface it so the bridge stamps it onto the
+ // retry-loop cached rows. Null for a non-routed run and on
+ // rows buffered before Task 6 (back-compat).
+ ParentExecutionId: message.ParentExecutionId);
}
catch (Exception buildEx)
{
diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs
index f7564fa..6328f96 100644
--- a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs
+++ b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs
@@ -76,6 +76,12 @@ public class StoreAndForwardStorage
await AddColumnIfMissingAsync(connection, "execution_id", "TEXT");
await AddColumnIfMissingAsync(connection, "source_script", "TEXT");
+ // Audit Log #23 (ParentExecutionId Task 6): additively add the
+ // parent_execution_id column the same way — a sibling to execution_id.
+ // Nullable with no default, so any row buffered before this migration
+ // reads back ParentExecutionId = null (back-compat).
+ await AddColumnIfMissingAsync(connection, "parent_execution_id", "TEXT");
+
_logger.LogInformation("Store-and-forward SQLite storage initialized");
}
@@ -142,10 +148,10 @@ public class StoreAndForwardStorage
cmd.CommandText = @"
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error,
- origin_instance, execution_id, source_script)
+ origin_instance, execution_id, source_script, parent_execution_id)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
- @origin, @executionId, @sourceScript)";
+ @origin, @executionId, @sourceScript, @parentExecutionId)";
cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -166,6 +172,11 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@executionId",
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
+ // Audit Log #23 (ParentExecutionId Task 6): the parent execution id is
+ // stored as its canonical string form ("D") so it round-trips cleanly
+ // through the TEXT column; null when not a routed cached call.
+ cmd.Parameters.AddWithValue("@parentExecutionId",
+ message.ParentExecutionId.HasValue ? message.ParentExecutionId.Value.ToString("D") : DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
@@ -182,7 +193,7 @@ public class StoreAndForwardStorage
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
- execution_id, source_script
+ execution_id, source_script, parent_execution_id
FROM sf_messages
WHERE status = @pending
AND (last_attempt_at IS NULL
@@ -314,7 +325,7 @@ public class StoreAndForwardStorage
pageCmd.CommandText = $@"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
- execution_id, source_script
+ execution_id, source_script, parent_execution_id
FROM sf_messages
WHERE status = @parked{categoryFilter}
ORDER BY created_at ASC
@@ -436,7 +447,7 @@ public class StoreAndForwardStorage
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
- execution_id, source_script
+ execution_id, source_script, parent_execution_id
FROM sf_messages
WHERE id = @id";
cmd.Parameters.AddWithValue("@id", messageId);
@@ -500,28 +511,35 @@ public class StoreAndForwardStorage
// Guid.TryParse (not Parse) guards the retry sweep: a corrupt
// non-null execution_id is treated as "no execution id" rather
// than throwing FormatException and aborting the whole sweep.
- ExecutionId = ParseExecutionId(reader, 12),
- SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13)
+ ExecutionId = ParseGuidColumn(reader, 12),
+ SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13),
+ // Audit Log #23 (ParentExecutionId Task 6): rows persisted
+ // before the additive migration have no parent_execution_id
+ // value; the IsDBNull guard inside ParseGuidColumn keeps those
+ // reading back as null (back-compat). Guid.TryParse (not Parse)
+ // guards the retry sweep against a corrupt non-null value.
+ ParentExecutionId = ParseGuidColumn(reader, 14)
});
}
return results;
}
///
- /// Audit Log #23 (ExecutionId Task 4): defensively reads the
- /// execution_id column. A null value (legacy pre-migration
+ /// Audit Log #23 (ExecutionId Task 4 / ParentExecutionId Task 6):
+ /// defensively reads a nullable GUID column (execution_id or
+ /// parent_execution_id). A null value (legacy pre-migration
/// rows) and a malformed non-null value both yield null — a corrupt
/// id must not throw and abort the retry sweep, which reads many rows.
///
- private static Guid? ParseExecutionId(System.Data.Common.DbDataReader reader, int ordinal)
+ private static Guid? ParseGuidColumn(System.Data.Common.DbDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return null;
}
- return Guid.TryParse(reader.GetString(ordinal), out var executionId)
- ? executionId
+ return Guid.TryParse(reader.GetString(ordinal), out var value)
+ ? value
: null;
}
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs
index 1438cfe..4185ab1 100644
--- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs
@@ -33,7 +33,8 @@ public class CachedCallLifecycleBridgeTests
string? lastError = null,
int? httpStatus = null,
Guid? executionId = null,
- string? sourceScript = null) =>
+ string? sourceScript = null,
+ Guid? parentExecutionId = null) =>
new(
TrackedOperationId: _id,
Channel: channel,
@@ -48,7 +49,8 @@ public class CachedCallLifecycleBridgeTests
DurationMs: 42,
SourceInstanceId: "Plant.Pump42",
ExecutionId: executionId,
- SourceScript: sourceScript);
+ SourceScript: sourceScript,
+ ParentExecutionId: parentExecutionId);
[Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
@@ -259,4 +261,70 @@ public class CachedCallLifecycleBridgeTests
Assert.Null(captured!.Audit.ExecutionId);
Assert.Null(captured.Audit.SourceScript);
}
+
+ // ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
+
+ [Fact]
+ public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext()
+ {
+ // Task 6: the ParentExecutionId threaded through the S&F buffer (the
+ // inbound-API run that spawned the originating script) arrives on the
+ // CachedCallAttemptContext; the bridge must stamp it onto the
+ // per-attempt ApiCallCached row beside ExecutionId.
+ var parentExecutionId = Guid.NewGuid();
+ var captured = new List();
+ _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ var sut = CreateSut();
+ await sut.OnAttemptCompletedAsync(Ctx(
+ CachedCallAttemptOutcome.TransientFailure,
+ parentExecutionId: parentExecutionId));
+
+ var packet = Assert.Single(captured);
+ Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
+ Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
+ {
+ // The terminal CachedResolve row must also carry the threaded
+ // ParentExecutionId so the whole retry-loop lifecycle correlates back
+ // to the spawning inbound-API execution.
+ var parentExecutionId = Guid.NewGuid();
+ var captured = new List();
+ _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ var sut = CreateSut();
+ await sut.OnAttemptCompletedAsync(Ctx(
+ CachedCallAttemptOutcome.Delivered,
+ channel: "DbOutbound",
+ parentExecutionId: parentExecutionId));
+
+ Assert.Equal(2, captured.Count);
+ var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
+ Assert.Equal(parentExecutionId, resolve.Audit.ParentExecutionId);
+
+ var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
+ Assert.Equal(parentExecutionId, attempted.Audit.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
+ {
+ // Back-compat / non-routed run: the originating script was not spawned
+ // by an inbound-API request, so ParentExecutionId is null; the bridge
+ // must leave the audit row's ParentExecutionId null rather than throwing.
+ CachedCallTelemetry? captured = null;
+ _forwarder.ForwardAsync(Arg.Do(t => captured = t), Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ var sut = CreateSut();
+ await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
+
+ Assert.NotNull(captured);
+ Assert.Null(captured!.Audit.ParentExecutionId);
+ }
}
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
index e10216c..b993083 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
@@ -75,7 +75,7 @@ public class DatabaseCachedWriteEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -116,7 +116,7 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -145,7 +145,7 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -167,7 +167,7 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -181,7 +181,7 @@ public class DatabaseCachedWriteEmissionTests
InstanceName,
It.IsAny(),
trackedId,
- It.IsAny(), It.IsAny()),
+ It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
@@ -205,7 +205,7 @@ public class DatabaseCachedWriteEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -221,7 +221,79 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny(),
It.IsAny(),
It.Is(id => id == TestExecutionId),
- It.Is(s => s == SourceScript)),
+ It.Is(s => s == SourceScript),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for
+ /// ParentExecutionId. A cached write enqueued from an inbound-API-
+ /// routed script run must forward the runtime context's
+ /// ParentExecutionId verbatim into
+ /// so the buffered retry
+ /// loop later stamps it onto its audit rows.
+ ///
+ [Fact]
+ public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway()
+ {
+ var parentExecutionId = Guid.NewGuid();
+ var gateway = new Mock();
+ gateway
+ .Setup(g => g.CachedWriteAsync(
+ "myDb", "INSERT INTO t VALUES (1)",
+ It.IsAny?>(),
+ InstanceName,
+ 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)");
+
+ gateway.Verify(g => g.CachedWriteAsync(
+ "myDb", "INSERT INTO t VALUES (1)",
+ It.IsAny?>(),
+ InstanceName,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(),
+ It.Is(id => id == parentExecutionId)),
+ Times.Once);
+ }
+
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
+ /// null ParentExecutionId into the gateway — the additive default.
+ ///
+ [Fact]
+ public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway()
+ {
+ var gateway = new Mock();
+ gateway
+ .Setup(g => g.CachedWriteAsync(
+ "myDb", "INSERT INTO t VALUES (1)",
+ It.IsAny?>(),
+ InstanceName,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ var forwarder = new CapturingForwarder();
+
+ var helper = CreateHelper(gateway.Object, forwarder);
+ await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
+
+ gateway.Verify(g => g.CachedWriteAsync(
+ "myDb", "INSERT INTO t VALUES (1)",
+ It.IsAny?>(),
+ InstanceName,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(),
+ It.Is(id => id == null)),
Times.Once);
}
@@ -236,7 +308,7 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder
{
@@ -253,7 +325,7 @@ public class DatabaseCachedWriteEmissionTests
InstanceName,
It.IsAny(),
trackedId,
- It.IsAny(), It.IsAny()),
+ It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
}
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
index 4bf31c3..7ed9725 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
@@ -78,7 +78,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -121,7 +121,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
@@ -158,7 +158,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -178,7 +178,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
id1,
- It.IsAny(), It.IsAny()),
+ It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
@@ -186,7 +186,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
id2,
- It.IsAny(), It.IsAny()),
+ It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
@@ -210,7 +210,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -226,7 +226,79 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny(),
It.IsAny(),
It.Is(id => id == TestExecutionId),
- It.Is(s => s == SourceScript)),
+ It.Is(s => s == SourceScript),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for
+ /// ParentExecutionId. A cached call enqueued from an inbound-API-
+ /// routed script run must forward the runtime context's
+ /// ParentExecutionId verbatim into
+ /// so the buffered
+ /// retry loop later stamps it onto its audit rows.
+ ///
+ [Fact]
+ public async Task CachedCall_ThreadsParentExecutionId_IntoClient()
+ {
+ 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(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
+ var forwarder = new CapturingForwarder();
+
+ var helper = CreateHelper(client.Object, forwarder, parentExecutionId);
+ await helper.CachedCall("ERP", "GetOrder");
+
+ client.Verify(c => c.CachedCallAsync(
+ "ERP", "GetOrder",
+ It.IsAny?>(),
+ InstanceName,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(),
+ It.Is(id => id == parentExecutionId)),
+ Times.Once);
+ }
+
+ ///
+ /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
+ /// null ParentExecutionId into the client — the additive default.
+ ///
+ [Fact]
+ public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient()
+ {
+ var client = new Mock();
+ client
+ .Setup(c => c.CachedCallAsync(
+ "ERP", "GetOrder",
+ It.IsAny?>(),
+ InstanceName,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
+ var forwarder = new CapturingForwarder();
+
+ var helper = CreateHelper(client.Object, forwarder);
+ await helper.CachedCall("ERP", "GetOrder");
+
+ client.Verify(c => c.CachedCallAsync(
+ "ERP", "GetOrder",
+ It.IsAny?>(),
+ InstanceName,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(),
+ It.Is(id => id == null)),
Times.Once);
}
@@ -241,7 +313,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder
{
@@ -261,7 +333,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
trackedId,
- It.IsAny(), It.IsAny()),
+ It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
@@ -276,7 +348,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -303,7 +375,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny(),
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var helper = CreateHelper(client.Object, forwarder: null);
@@ -316,7 +388,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
trackedId,
- It.IsAny(), It.IsAny()),
+ It.IsAny(), It.IsAny(), It.IsAny()),
Times.Once);
}
@@ -346,7 +418,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
// is bypassed entirely.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
@@ -412,7 +484,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
@@ -442,7 +514,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ExternalCallResult(
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
var forwarder = new CapturingForwarder();
@@ -485,7 +557,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny(),
It.IsAny(),
- It.IsAny(), It.IsAny()))
+ It.IsAny(), It.IsAny(), It.IsAny()))
// S&F took ownership — Attempted + Resolve come from the
// CachedCallLifecycleBridge driven by the retry loop, not the helper.
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
diff --git a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs
index 501bc3b..145ac83 100644
--- a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs
+++ b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs
@@ -357,6 +357,83 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript);
}
+ // ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
+
+ [Fact]
+ public async Task Attempt_CarriesParentExecutionId_FromBufferedMessage()
+ {
+ // A cached call enqueued from an inbound-API-routed script run carries
+ // the spawning execution's ParentExecutionId. The retry sweep must
+ // surface it on the CachedCallAttemptContext beside ExecutionId so the
+ // audit bridge can stamp it on the retry-loop cached rows.
+ var parentExecutionId = Guid.NewGuid();
+ _service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
+ _ => throw new HttpRequestException("HTTP 503"));
+
+ var trackedId = TrackedOperationId.New();
+ await _service.EnqueueAsync(
+ StoreAndForwardCategory.ExternalSystem,
+ "ERP",
+ """{"payload":"x"}""",
+ originInstanceName: "Plant.Pump42",
+ maxRetries: 5,
+ retryInterval: TimeSpan.Zero,
+ attemptImmediateDelivery: false,
+ messageId: trackedId.ToString(),
+ parentExecutionId: parentExecutionId);
+
+ await _service.RetryPendingMessagesAsync();
+
+ var notification = Assert.Single(_observer.Notifications);
+ Assert.Equal(parentExecutionId, notification.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task Attempt_NullParentExecutionId_SurfacesAsNull()
+ {
+ // Non-routed run: the originating script was not spawned by an
+ // inbound-API request, so no ParentExecutionId is threaded. It must
+ // surface as null on the context, not throw.
+ _service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
+ _ => Task.FromResult(true));
+ var trackedId = await EnqueueBufferedAsync(
+ StoreAndForwardCategory.ExternalSystem, "ERP");
+
+ await _service.RetryPendingMessagesAsync();
+
+ var notification = Assert.Single(_observer.Notifications);
+ Assert.Null(notification.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task TerminalResolve_CarriesParentExecutionId()
+ {
+ // The terminal Delivered notification must also carry the threaded
+ // ParentExecutionId so the CachedResolve audit row correlates back to
+ // the spawning inbound-API execution.
+ var parentExecutionId = Guid.NewGuid();
+ _service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite,
+ _ => Task.FromResult(true));
+
+ var trackedId = TrackedOperationId.New();
+ await _service.EnqueueAsync(
+ StoreAndForwardCategory.CachedDbWrite,
+ "myDb",
+ """{"payload":"x"}""",
+ originInstanceName: "Plant.Tank",
+ maxRetries: 3,
+ retryInterval: TimeSpan.Zero,
+ attemptImmediateDelivery: false,
+ messageId: trackedId.ToString(),
+ parentExecutionId: parentExecutionId);
+
+ await _service.RetryPendingMessagesAsync();
+
+ var notification = Assert.Single(_observer.Notifications);
+ Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
+ Assert.Equal(parentExecutionId, notification.ParentExecutionId);
+ }
+
// ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ──
[Fact]
diff --git a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs
index 6fed58e..f6a2f0a 100644
--- a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs
+++ b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs
@@ -452,6 +452,141 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(message.ExecutionId, retrieved!.ExecutionId);
}
+ // ── Audit Log #23 (ParentExecutionId Task 6): parent_execution_id ──
+
+ [Fact]
+ public async Task EnqueueAsync_RoundTripsParentExecutionId()
+ {
+ // A cached call buffered from an inbound-API-routed script run carries
+ // the spawning execution's ParentExecutionId; it must survive a persist
+ // + read-back so the retry loop can stamp it on audit rows.
+ var parentExecutionId = Guid.NewGuid();
+ var message = CreateMessage("parent1", StoreAndForwardCategory.ExternalSystem);
+ message.ParentExecutionId = parentExecutionId;
+
+ await _storage.EnqueueAsync(message);
+
+ var retrieved = await _storage.GetMessageByIdAsync("parent1");
+ Assert.NotNull(retrieved);
+ Assert.Equal(parentExecutionId, retrieved!.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task EnqueueAsync_NullParentExecutionId_RoundTripsAsNull()
+ {
+ // A non-routed run supplies no ParentExecutionId — it must round-trip
+ // as null rather than throwing or coercing.
+ var message = CreateMessage("noparent1", StoreAndForwardCategory.ExternalSystem);
+ Assert.Null(message.ParentExecutionId);
+
+ await _storage.EnqueueAsync(message);
+
+ var retrieved = await _storage.GetMessageByIdAsync("noparent1");
+ Assert.NotNull(retrieved);
+ Assert.Null(retrieved!.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task ParentExecutionId_SurvivesRetrySweepRead()
+ {
+ // The retry sweep reads due rows via GetMessagesForRetryAsync; the new
+ // parent_execution_id field must be present on that read path too — it
+ // is the path that feeds the CachedCallAttemptContext.
+ var parentExecutionId = Guid.NewGuid();
+ var message = CreateMessage("psweep1", StoreAndForwardCategory.CachedDbWrite);
+ message.ParentExecutionId = parentExecutionId;
+ message.LastAttemptAt = null; // due immediately
+ await _storage.EnqueueAsync(message);
+
+ var due = await _storage.GetMessagesForRetryAsync();
+
+ var row = Assert.Single(due, m => m.Id == "psweep1");
+ Assert.Equal(parentExecutionId, row.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task LegacyRowWithoutParentExecutionIdColumn_ReadsBackAsNull()
+ {
+ // Back-compat: a row persisted by a build that pre-dates the
+ // parent_execution_id column must still deserialize, with
+ // ParentExecutionId reading back as null. Simulate the pre-Task-6
+ // schema (which already has execution_id / source_script from the
+ // ExecutionId rollout) by recreating the table without
+ // parent_execution_id, inserting directly, then running InitializeAsync
+ // which ALTER-adds the column.
+ await using (var setup = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
+ {
+ await setup.OpenAsync();
+ await using var drop = setup.CreateCommand();
+ drop.CommandText = @"
+ DROP TABLE IF EXISTS sf_messages;
+ CREATE TABLE sf_messages (
+ id TEXT PRIMARY KEY,
+ category INTEGER NOT NULL,
+ target TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ retry_count INTEGER NOT NULL DEFAULT 0,
+ max_retries INTEGER NOT NULL DEFAULT 50,
+ retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
+ created_at TEXT NOT NULL,
+ last_attempt_at TEXT,
+ status INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ origin_instance TEXT,
+ execution_id TEXT,
+ source_script TEXT
+ );
+ INSERT INTO sf_messages (id, category, target, payload_json, created_at, status)
+ VALUES ('plegacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);";
+ await drop.ExecuteNonQueryAsync();
+ }
+
+ // InitializeAsync must additively ALTER-in parent_execution_id without
+ // disturbing the pre-existing legacy row.
+ await _storage.InitializeAsync();
+
+ var retrieved = await _storage.GetMessageByIdAsync("plegacy1");
+ Assert.NotNull(retrieved);
+ Assert.Equal("plegacy1", retrieved!.Id);
+ Assert.Null(retrieved.ParentExecutionId);
+ }
+
+ [Fact]
+ public async Task MalformedParentExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
+ {
+ // Defensive read path: a corrupt (non-null, non-GUID) parent_execution_id
+ // must be treated as "no parent execution id" rather than throwing
+ // FormatException — a single bad row must not abort the whole
+ // GetMessagesForRetryAsync sweep.
+ var goodParent = Guid.NewGuid();
+ var good = CreateMessage("pgood1", StoreAndForwardCategory.ExternalSystem);
+ good.ParentExecutionId = goodParent;
+ good.LastAttemptAt = null; // due immediately
+ await _storage.EnqueueAsync(good);
+
+ var bad = CreateMessage("pbad1", StoreAndForwardCategory.ExternalSystem);
+ bad.ParentExecutionId = Guid.NewGuid();
+ bad.LastAttemptAt = null; // due immediately
+ await _storage.EnqueueAsync(bad);
+
+ await using (var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
+ {
+ await conn.OpenAsync();
+ await using var corrupt = conn.CreateCommand();
+ corrupt.CommandText =
+ "UPDATE sf_messages SET parent_execution_id = 'not-a-guid' WHERE id = 'pbad1';";
+ await corrupt.ExecuteNonQueryAsync();
+ }
+
+ var due = await _storage.GetMessagesForRetryAsync();
+ Assert.Null(Assert.Single(due, m => m.Id == "pbad1").ParentExecutionId);
+ Assert.Equal(goodParent, Assert.Single(due, m => m.Id == "pgood1").ParentExecutionId);
+
+ var retrieved = await _storage.GetMessageByIdAsync("pbad1");
+ Assert.NotNull(retrieved);
+ Assert.Null(retrieved!.ParentExecutionId);
+ }
+
private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category)
{
return new StoreAndForwardMessage