feat(auditlog): thread ExecutionId 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. ExecutionId (and SourceScript) were not threaded through the
S&F buffer, so those rows had ExecutionId = null and SourceScript = null.

Thread both, additively, from the cached-call enqueue path:

- StoreAndForwardMessage gains ExecutionId (Guid?) / SourceScript (string?).
- StoreAndForwardStorage adds nullable execution_id / source_script columns
  via an idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by
  an older build read back null (back-compat).
- StoreAndForwardService.EnqueueAsync gains optional executionId /
  sourceScript params, stamped onto the buffered message and surfaced on the
  CachedCallAttemptContext built in the retry loop.
- CachedCallAttemptContext gains ExecutionId / SourceScript.
- CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ExecutionId and
  AuditEvent.SourceScript from the context (replacing the hard-coded
  SourceScript = null and its now-stale comment).
- IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync
  gain optional executionId / sourceScript params; ScriptRuntimeContext's
  CachedCall / CachedWrite helpers pass _executionId / _sourceScript.

Script-side cached rows (CachedSubmit, immediate Attempted+Resolve) are
unchanged. All threading is additive — old buffered S&F rows still
deserialize and process with the new fields null.
This commit is contained in:
Joseph Doherty
2026-05-21 15:18:35 -04:00
parent 0149ce6180
commit 6f5a35f222
15 changed files with 505 additions and 40 deletions

View File

@@ -55,4 +55,25 @@ public class StoreAndForwardMessage
/// WP-13: Messages are NOT cleared when instance is deleted.
/// </summary>
public string? OriginInstanceName { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded from <c>ScriptRuntimeContext</c> through
/// 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>).
/// <c>null</c> 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? ExecutionId { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <see cref="ExecutionId"/> from the cached-call enqueue
/// path so the retry-loop audit rows carry the same <c>SourceScript</c>
/// provenance the script-side cached rows already carry. <c>null</c> when not
/// known (non-cached categories, pre-migration rows).
/// </summary>
public string? SourceScript { get; set; }
}

View File

@@ -175,6 +175,18 @@ public class StoreAndForwardService
/// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried
/// inside the payload, and it is the id the forwarder submits to central.
/// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. Threaded onto the buffered row so the retry-loop
/// cached-call audit rows carry it. <c>null</c> for callers (notifications,
/// pre-Task-4 callers) that do not supply one.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered row alongside <paramref name="executionId"/>
/// so the retry-loop audit rows carry the same provenance the script-side
/// cached rows do. <c>null</c> when not known.
/// </param>
public async Task<StoreAndForwardResult> EnqueueAsync(
StoreAndForwardCategory category,
string target,
@@ -183,7 +195,9 @@ public class StoreAndForwardService
int? maxRetries = null,
TimeSpan? retryInterval = null,
bool attemptImmediateDelivery = true,
string? messageId = null)
string? messageId = null,
Guid? executionId = null,
string? sourceScript = null)
{
var message = new StoreAndForwardMessage
{
@@ -196,7 +210,9 @@ public class StoreAndForwardService
RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
OriginInstanceName = originInstanceName
OriginInstanceName = originInstanceName,
ExecutionId = executionId,
SourceScript = sourceScript
};
// Attempt immediate delivery — unless the caller has already made a
@@ -492,7 +508,14 @@ public class StoreAndForwardService
CreatedAtUtc: message.CreatedAt.UtcDateTime,
OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
DurationMs: durationMs,
SourceInstanceId: message.OriginInstanceName);
SourceInstanceId: message.OriginInstanceName,
// Audit Log #23 (ExecutionId Task 4): the buffered message
// carries the originating script execution's ExecutionId +
// SourceScript; surface them on the context so the bridge can
// stamp the retry-loop cached audit rows. Null on rows buffered
// before Task 4 (back-compat).
ExecutionId: message.ExecutionId,
SourceScript: message.SourceScript);
}
catch (Exception buildEx)
{

View File

@@ -65,9 +65,45 @@ public class StoreAndForwardStorage
";
await command.ExecuteNonQueryAsync();
// Audit Log #23 (ExecutionId Task 4): additively add the execution_id /
// source_script columns. CREATE TABLE IF NOT EXISTS above does NOT add
// columns to a table that already exists from before these fields, so a
// databases created by an older build needs the columns ALTER-ed in.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. Both columns
// are nullable with no default, so any row buffered before this
// migration reads back ExecutionId/SourceScript = null (back-compat).
await AddColumnIfMissingAsync(connection, "execution_id", "TEXT");
await AddColumnIfMissingAsync(connection, "source_script", "TEXT");
_logger.LogInformation("Store-and-forward SQLite storage initialized");
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): adds a column to <c>sf_messages</c>
/// only when it is not already present. SQLite lacks <c>ADD COLUMN IF NOT
/// EXISTS</c>, so the schema is probed via <c>PRAGMA table_info</c> first.
/// Idempotent — safe to run on every <see cref="InitializeAsync"/>.
/// </summary>
private static async Task AddColumnIfMissingAsync(
SqliteConnection connection, string columnName, string columnType)
{
await using var probe = connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = @name";
probe.Parameters.AddWithValue("@name", columnName);
var exists = Convert.ToInt32(await probe.ExecuteScalarAsync()) > 0;
if (exists)
{
return;
}
await using var alter = connection.CreateCommand();
// Column name + type are caller-controlled constants, never user input —
// safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE sf_messages ADD COLUMN {columnName} {columnType}";
await alter.ExecuteNonQueryAsync();
}
/// <summary>
/// Ensures the directory for a file-backed SQLite database exists. SQLite creates
/// the database file on demand but not its parent directory, so a configured path
@@ -105,9 +141,11 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
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)
retry_interval_ms, created_at, last_attempt_at, status, last_error,
origin_instance, execution_id, source_script)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, @origin)";
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
@origin, @executionId, @sourceScript)";
cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -122,6 +160,12 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value);
// Audit Log #23 (ExecutionId Task 4): the execution id is stored as its
// canonical string form ("D") so it round-trips cleanly through the
// TEXT column; null when not a cached call / not threaded.
cmd.Parameters.AddWithValue("@executionId",
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
@@ -137,7 +181,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
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
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages
WHERE status = @pending
AND (last_attempt_at IS NULL
@@ -268,7 +313,8 @@ public class StoreAndForwardStorage
var categoryFilter = category.HasValue ? " AND category = @category" : "";
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
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages
WHERE status = @parked{categoryFilter}
ORDER BY created_at ASC
@@ -389,7 +435,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
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
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages
WHERE id = @id";
cmd.Parameters.AddWithValue("@id", messageId);
@@ -446,7 +493,12 @@ public class StoreAndForwardStorage
LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)),
Status = (StoreAndForwardMessageStatus)reader.GetInt32(9),
LastError = reader.IsDBNull(10) ? null : reader.GetString(10),
OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11)
OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11),
// Audit Log #23 (ExecutionId Task 4): rows persisted before the
// additive migration have no execution_id / source_script value;
// IsDBNull guards keep those reading back as null (back-compat).
ExecutionId = reader.IsDBNull(12) ? null : Guid.Parse(reader.GetString(12)),
SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13)
});
}
return results;