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

@@ -76,4 +76,19 @@ public class StoreAndForwardMessage
/// known (non-cached categories, pre-migration rows).
/// </summary>
public string? SourceScript { get; set; }
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution,
/// threaded alongside <see cref="ExecutionId"/> 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
/// (<c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted, <c>CachedResolve</c>),
/// keeping them correlated with the cross-execution chain. <c>null</c> 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).
/// </summary>
public Guid? ParentExecutionId { get; set; }
}

View File

@@ -187,6 +187,14 @@ public class StoreAndForwardService
/// so the retry-loop audit rows carry the same provenance the script-side
/// cached rows do. <c>null</c> when not known.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution.
/// Threaded onto the buffered row alongside <paramref name="executionId"/>
/// so the retry-loop cached-call audit rows carry it. <c>null</c> for a
/// non-routed run and for callers (notifications, pre-Task-6 callers) that
/// do not supply one.
/// </param>
public async Task<StoreAndForwardResult> 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)
{

View File

@@ -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;
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): defensively reads the
/// <c>execution_id</c> column. A <c>null</c> value (legacy pre-migration
/// Audit Log #23 (ExecutionId Task 4 / ParentExecutionId Task 6):
/// defensively reads a nullable GUID column (<c>execution_id</c> or
/// <c>parent_execution_id</c>). A <c>null</c> value (legacy pre-migration
/// rows) and a malformed non-null value both yield <c>null</c> — a corrupt
/// id must not throw and abort the retry sweep, which reads many rows.
/// </summary>
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;
}
}