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:
@@ -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
|
||||
|
||||
@@ -71,6 +71,16 @@ public interface ICachedCallLifecycleObserver
|
||||
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
|
||||
/// rows already 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 through the store-and-forward buffer alongside
|
||||
/// <paramref name="ExecutionId"/>. The audit bridge stamps it onto the
|
||||
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
|
||||
/// <c>CachedResolve</c> rows so they correlate back to the spawning run.
|
||||
/// <c>null</c> for a non-routed run and for rows buffered before Task 6
|
||||
/// (back-compat).
|
||||
/// </param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Coarse outcome of one cached-call delivery attempt, observed from inside
|
||||
|
||||
@@ -40,6 +40,14 @@ public interface IDatabaseGateway
|
||||
/// threaded onto the buffered S&F message alongside
|
||||
/// <paramref name="executionId"/>. <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.
|
||||
/// When the write is buffered on a transient failure this is threaded onto
|
||||
/// the S&F message alongside <paramref name="executionId"/> so the
|
||||
/// retry-loop cached-write audit rows carry it. <c>null</c> for a
|
||||
/// non-routed run.
|
||||
/// </param>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ public interface IExternalSystemClient
|
||||
/// threaded onto the buffered S&F message alongside
|
||||
/// <paramref name="executionId"/>. <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.
|
||||
/// When the call is buffered on a transient failure this is threaded onto
|
||||
/// the S&F message alongside <paramref name="executionId"/> so the
|
||||
/// retry-loop cached-call audit rows carry it. <c>null</c> for a non-routed
|
||||
/// run.
|
||||
/// </param>
|
||||
Task<ExternalCallResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user