feat(auditlog): thread ParentExecutionId through S&F for retry-loop cached rows

The store-and-forward retry loop emits the per-attempt and terminal cached
audit rows (ApiCallCached/DbWriteCached Attempted, CachedResolve) via
CachedCallLifecycleBridge from a CachedCallAttemptContext, not from the
script context. The ExecutionId rollout (Task 4) already threaded ExecutionId
and SourceScript through this path; ParentExecutionId — the spawning
inbound-API request's ExecutionId — was not, so those retry-loop rows had
ParentExecutionId = null even for an inbound-API-routed run.

Thread it additively as a sibling at every carry point ExecutionId passes
through:

- StoreAndForwardMessage gains ParentExecutionId (Guid?).
- StoreAndForwardStorage adds a nullable parent_execution_id column via the
  same idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by an
  older build read back null (back-compat). The defensive Guid.TryParse read
  helper (ParseExecutionId) is renamed ParseGuidColumn and reused for both
  columns so a corrupt value cannot abort the retry sweep.
- StoreAndForwardService.EnqueueAsync gains an optional parentExecutionId
  param, stamped onto the buffered message and surfaced on the
  CachedCallAttemptContext built in the retry loop.
- CachedCallAttemptContext gains ParentExecutionId.
- CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ParentExecutionId
  from the context, beside the existing ExecutionId.
- IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync
  gain an optional parentExecutionId param; ScriptRuntimeContext's CachedCall
  / CachedWrite helpers pass _parentExecutionId.

All threading is additive — ParentExecutionId is Guid? everywhere, null for
non-routed runs, and old buffered S&F rows still deserialize with the new
field null.
This commit is contained in:
Joseph Doherty
2026-05-21 17:58:11 -04:00
parent 150ba5e63f
commit c00603e2a4
15 changed files with 581 additions and 51 deletions

View File

@@ -78,7 +78,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -121,7 +121,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
@@ -158,7 +158,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -178,7 +178,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
id1,
It.IsAny<Guid?>(), It.IsAny<string?>()),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
@@ -186,7 +186,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
id2,
It.IsAny<Guid?>(), It.IsAny<string?>()),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
@@ -210,7 +210,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -226,7 +226,79 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.Is<Guid?>(id => id == TestExecutionId),
It.Is<string?>(s => s == SourceScript)),
It.Is<string?>(s => s == SourceScript),
It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for
/// <c>ParentExecutionId</c>. A cached call enqueued from an inbound-API-
/// routed script run must forward the runtime context's
/// <c>ParentExecutionId</c> verbatim into
/// <see cref="IExternalSystemClient.CachedCallAsync"/> so the buffered
/// retry loop later stamps it onto its audit rows.
/// </summary>
[Fact]
public async Task CachedCall_ThreadsParentExecutionId_IntoClient()
{
var parentExecutionId = Guid.NewGuid();
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.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<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == parentExecutionId)),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
/// <c>null</c> ParentExecutionId into the client — the additive default.
/// </summary>
[Fact]
public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.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<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == null)),
Times.Once);
}
@@ -241,7 +313,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder
{
@@ -261,7 +333,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>()),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
@@ -276,7 +348,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -303,7 +375,7 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.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<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>()),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
@@ -346,7 +418,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// 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<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
@@ -442,7 +514,7 @@ public class ExternalSystemCachedCallEmissionTests
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.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<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// 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));