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
|
// execution's per-run correlation id, threaded through the S&F
|
||||||
// buffer; null on rows buffered before Task 4 (back-compat).
|
// buffer; null on rows buffered before Task 4 (back-compat).
|
||||||
ExecutionId = context.ExecutionId,
|
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,
|
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||||
SourceInstanceId = context.SourceInstanceId,
|
SourceInstanceId = context.SourceInstanceId,
|
||||||
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
|
// 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 carry the same <c>SourceScript</c> provenance the script-side cached
|
||||||
/// rows already do. <c>null</c> when not known.
|
/// rows already do. <c>null</c> when not known.
|
||||||
/// </param>
|
/// </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(
|
public sealed record CachedCallAttemptContext(
|
||||||
TrackedOperationId TrackedOperationId,
|
TrackedOperationId TrackedOperationId,
|
||||||
string Channel,
|
string Channel,
|
||||||
@@ -85,7 +95,8 @@ public sealed record CachedCallAttemptContext(
|
|||||||
int? DurationMs,
|
int? DurationMs,
|
||||||
string? SourceInstanceId,
|
string? SourceInstanceId,
|
||||||
Guid? ExecutionId = null,
|
Guid? ExecutionId = null,
|
||||||
string? SourceScript = null);
|
string? SourceScript = null,
|
||||||
|
Guid? ParentExecutionId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Coarse outcome of one cached-call delivery attempt, observed from inside
|
/// 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
|
/// threaded onto the buffered S&F message alongside
|
||||||
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||||
/// </param>
|
/// </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(
|
Task CachedWriteAsync(
|
||||||
string connectionName,
|
string connectionName,
|
||||||
string sql,
|
string sql,
|
||||||
@@ -48,5 +56,6 @@ public interface IDatabaseGateway
|
|||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null,
|
TrackedOperationId? trackedOperationId = null,
|
||||||
Guid? executionId = 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
|
/// threaded onto the buffered S&F message alongside
|
||||||
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||||
/// </param>
|
/// </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(
|
Task<ExternalCallResult> CachedCallAsync(
|
||||||
string systemName,
|
string systemName,
|
||||||
string methodName,
|
string methodName,
|
||||||
@@ -49,7 +57,8 @@ public interface IExternalSystemClient
|
|||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null,
|
TrackedOperationId? trackedOperationId = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
string? sourceScript = null);
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ public class DatabaseGateway : IDatabaseGateway
|
|||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null,
|
TrackedOperationId? trackedOperationId = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
string? sourceScript = null)
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
||||||
if (definition == null)
|
if (definition == null)
|
||||||
@@ -132,7 +133,12 @@ public class DatabaseGateway : IDatabaseGateway
|
|||||||
// the retry-loop cached-write audit rows carry the same provenance
|
// the retry-loop cached-write audit rows carry the same provenance
|
||||||
// the script-side cached rows do.
|
// the script-side cached rows do.
|
||||||
executionId: executionId,
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null,
|
TrackedOperationId? trackedOperationId = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
string? sourceScript = null)
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
||||||
if (system == null || method == null)
|
if (system == null || method == null)
|
||||||
@@ -152,7 +153,12 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
// buffered row so the retry-loop cached-call audit rows carry
|
// buffered row so the retry-loop cached-call audit rows carry
|
||||||
// the same provenance the script-side cached rows do.
|
// the same provenance the script-side cached rows do.
|
||||||
executionId: executionId,
|
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);
|
return new ExternalCallResult(true, null, null, WasBuffered: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,7 +564,12 @@ public class ScriptRuntimeContext
|
|||||||
// execution's ExecutionId + SourceScript so a buffered
|
// execution's ExecutionId + SourceScript so a buffered
|
||||||
// cached call's retry-loop audit rows carry them.
|
// cached call's retry-loop audit rows carry them.
|
||||||
executionId: _executionId,
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1178,7 +1183,12 @@ public class ScriptRuntimeContext
|
|||||||
// execution's ExecutionId + SourceScript so a buffered
|
// execution's ExecutionId + SourceScript so a buffered
|
||||||
// cached write's retry-loop audit rows carry them.
|
// cached write's retry-loop audit rows carry them.
|
||||||
executionId: _executionId,
|
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);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -76,4 +76,19 @@ public class StoreAndForwardMessage
|
|||||||
/// known (non-cached categories, pre-migration rows).
|
/// known (non-cached categories, pre-migration rows).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? SourceScript { get; set; }
|
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
|
/// so the retry-loop audit rows carry the same provenance the script-side
|
||||||
/// cached rows do. <c>null</c> when not known.
|
/// cached rows do. <c>null</c> when not known.
|
||||||
/// </param>
|
/// </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(
|
public async Task<StoreAndForwardResult> EnqueueAsync(
|
||||||
StoreAndForwardCategory category,
|
StoreAndForwardCategory category,
|
||||||
string target,
|
string target,
|
||||||
@@ -197,7 +205,8 @@ public class StoreAndForwardService
|
|||||||
bool attemptImmediateDelivery = true,
|
bool attemptImmediateDelivery = true,
|
||||||
string? messageId = null,
|
string? messageId = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
string? sourceScript = null)
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
var message = new StoreAndForwardMessage
|
var message = new StoreAndForwardMessage
|
||||||
{
|
{
|
||||||
@@ -212,7 +221,8 @@ public class StoreAndForwardService
|
|||||||
Status = StoreAndForwardMessageStatus.Pending,
|
Status = StoreAndForwardMessageStatus.Pending,
|
||||||
OriginInstanceName = originInstanceName,
|
OriginInstanceName = originInstanceName,
|
||||||
ExecutionId = executionId,
|
ExecutionId = executionId,
|
||||||
SourceScript = sourceScript
|
SourceScript = sourceScript,
|
||||||
|
ParentExecutionId = parentExecutionId
|
||||||
};
|
};
|
||||||
|
|
||||||
// Attempt immediate delivery — unless the caller has already made a
|
// 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
|
// stamp the retry-loop cached audit rows. Null on rows buffered
|
||||||
// before Task 4 (back-compat).
|
// before Task 4 (back-compat).
|
||||||
ExecutionId: message.ExecutionId,
|
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)
|
catch (Exception buildEx)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ public class StoreAndForwardStorage
|
|||||||
await AddColumnIfMissingAsync(connection, "execution_id", "TEXT");
|
await AddColumnIfMissingAsync(connection, "execution_id", "TEXT");
|
||||||
await AddColumnIfMissingAsync(connection, "source_script", "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");
|
_logger.LogInformation("Store-and-forward SQLite storage initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +148,10 @@ public class StoreAndForwardStorage
|
|||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
|
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
|
||||||
retry_interval_ms, created_at, last_attempt_at, status, last_error,
|
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,
|
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
|
||||||
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
|
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
|
||||||
@origin, @executionId, @sourceScript)";
|
@origin, @executionId, @sourceScript, @parentExecutionId)";
|
||||||
|
|
||||||
cmd.Parameters.AddWithValue("@id", message.Id);
|
cmd.Parameters.AddWithValue("@id", message.Id);
|
||||||
cmd.Parameters.AddWithValue("@category", (int)message.Category);
|
cmd.Parameters.AddWithValue("@category", (int)message.Category);
|
||||||
@@ -166,6 +172,11 @@ public class StoreAndForwardStorage
|
|||||||
cmd.Parameters.AddWithValue("@executionId",
|
cmd.Parameters.AddWithValue("@executionId",
|
||||||
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
|
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
|
||||||
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? 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();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
@@ -182,7 +193,7 @@ public class StoreAndForwardStorage
|
|||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
SELECT id, category, target, payload_json, retry_count, max_retries,
|
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
|
execution_id, source_script, parent_execution_id
|
||||||
FROM sf_messages
|
FROM sf_messages
|
||||||
WHERE status = @pending
|
WHERE status = @pending
|
||||||
AND (last_attempt_at IS NULL
|
AND (last_attempt_at IS NULL
|
||||||
@@ -314,7 +325,7 @@ public class StoreAndForwardStorage
|
|||||||
pageCmd.CommandText = $@"
|
pageCmd.CommandText = $@"
|
||||||
SELECT id, category, target, payload_json, retry_count, max_retries,
|
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
|
execution_id, source_script, parent_execution_id
|
||||||
FROM sf_messages
|
FROM sf_messages
|
||||||
WHERE status = @parked{categoryFilter}
|
WHERE status = @parked{categoryFilter}
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
@@ -436,7 +447,7 @@ public class StoreAndForwardStorage
|
|||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
SELECT id, category, target, payload_json, retry_count, max_retries,
|
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
|
execution_id, source_script, parent_execution_id
|
||||||
FROM sf_messages
|
FROM sf_messages
|
||||||
WHERE id = @id";
|
WHERE id = @id";
|
||||||
cmd.Parameters.AddWithValue("@id", messageId);
|
cmd.Parameters.AddWithValue("@id", messageId);
|
||||||
@@ -500,28 +511,35 @@ public class StoreAndForwardStorage
|
|||||||
// Guid.TryParse (not Parse) guards the retry sweep: a corrupt
|
// Guid.TryParse (not Parse) guards the retry sweep: a corrupt
|
||||||
// non-null execution_id is treated as "no execution id" rather
|
// non-null execution_id is treated as "no execution id" rather
|
||||||
// than throwing FormatException and aborting the whole sweep.
|
// than throwing FormatException and aborting the whole sweep.
|
||||||
ExecutionId = ParseExecutionId(reader, 12),
|
ExecutionId = ParseGuidColumn(reader, 12),
|
||||||
SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13)
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 (ExecutionId Task 4): defensively reads the
|
/// Audit Log #23 (ExecutionId Task 4 / ParentExecutionId Task 6):
|
||||||
/// <c>execution_id</c> column. A <c>null</c> value (legacy pre-migration
|
/// 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
|
/// 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.
|
/// id must not throw and abort the retry sweep, which reads many rows.
|
||||||
/// </summary>
|
/// </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))
|
if (reader.IsDBNull(ordinal))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Guid.TryParse(reader.GetString(ordinal), out var executionId)
|
return Guid.TryParse(reader.GetString(ordinal), out var value)
|
||||||
? executionId
|
? value
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
string? lastError = null,
|
string? lastError = null,
|
||||||
int? httpStatus = null,
|
int? httpStatus = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
string? sourceScript = null) =>
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null) =>
|
||||||
new(
|
new(
|
||||||
TrackedOperationId: _id,
|
TrackedOperationId: _id,
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
@@ -48,7 +49,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
DurationMs: 42,
|
DurationMs: 42,
|
||||||
SourceInstanceId: "Plant.Pump42",
|
SourceInstanceId: "Plant.Pump42",
|
||||||
ExecutionId: executionId,
|
ExecutionId: executionId,
|
||||||
SourceScript: sourceScript);
|
SourceScript: sourceScript,
|
||||||
|
ParentExecutionId: parentExecutionId);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
|
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
|
||||||
@@ -259,4 +261,70 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Null(captured!.Audit.ExecutionId);
|
Assert.Null(captured!.Audit.ExecutionId);
|
||||||
Assert.Null(captured.Audit.SourceScript);
|
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<CachedCallTelemetry>();
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||||
|
.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<CachedCallTelemetry>();
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||||
|
.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<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Null(captured!.Audit.ParentExecutionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
trackedId,
|
trackedId,
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -221,7 +221,79 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.Is<Guid?>(id => id == TestExecutionId),
|
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 write enqueued from an inbound-API-
|
||||||
|
/// routed script run must forward the runtime context's
|
||||||
|
/// <c>ParentExecutionId</c> verbatim into
|
||||||
|
/// <see cref="IDatabaseGateway.CachedWriteAsync"/> so the buffered retry
|
||||||
|
/// loop later stamps it onto its audit rows.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway()
|
||||||
|
{
|
||||||
|
var parentExecutionId = Guid.NewGuid();
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.CachedWriteAsync(
|
||||||
|
"myDb", "INSERT INTO t VALUES (1)",
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||||
|
InstanceName,
|
||||||
|
It.IsAny<CancellationToken>(),
|
||||||
|
It.IsAny<TrackedOperationId?>(),
|
||||||
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
|
.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<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 gateway — the additive default.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway()
|
||||||
|
{
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.CachedWriteAsync(
|
||||||
|
"myDb", "INSERT INTO t VALUES (1)",
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||||
|
InstanceName,
|
||||||
|
It.IsAny<CancellationToken>(),
|
||||||
|
It.IsAny<TrackedOperationId?>(),
|
||||||
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
|
.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<IReadOnlyDictionary<string, object?>?>(),
|
||||||
|
InstanceName,
|
||||||
|
It.IsAny<CancellationToken>(),
|
||||||
|
It.IsAny<TrackedOperationId?>(),
|
||||||
|
It.IsAny<Guid?>(), It.IsAny<string?>(),
|
||||||
|
It.Is<Guid?>(id => id == null)),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +308,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
var forwarder = new CapturingForwarder
|
var forwarder = new CapturingForwarder
|
||||||
{
|
{
|
||||||
@@ -253,7 +325,7 @@ public class DatabaseCachedWriteEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
trackedId,
|
trackedId,
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
id1,
|
id1,
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
client.Verify(c => c.CachedCallAsync(
|
client.Verify(c => c.CachedCallAsync(
|
||||||
"ERP", "GetOrder",
|
"ERP", "GetOrder",
|
||||||
@@ -186,7 +186,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
id2,
|
id2,
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -226,7 +226,79 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.Is<Guid?>(id => id == TestExecutionId),
|
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);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +313,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
var forwarder = new CapturingForwarder
|
var forwarder = new CapturingForwarder
|
||||||
{
|
{
|
||||||
@@ -261,7 +333,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
trackedId,
|
trackedId,
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +348,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -303,7 +375,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
It.IsAny<string?>(),
|
It.IsAny<string?>(),
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
|
|
||||||
var helper = CreateHelper(client.Object, forwarder: null);
|
var helper = CreateHelper(client.Object, forwarder: null);
|
||||||
@@ -316,7 +388,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
trackedId,
|
trackedId,
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +418,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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
|
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
|
||||||
// is bypassed entirely.
|
// is bypassed entirely.
|
||||||
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
||||||
@@ -412,7 +484,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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));
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
@@ -442,7 +514,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
It.IsAny<TrackedOperationId?>(),
|
||||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||||
.ReturnsAsync(new ExternalCallResult(
|
.ReturnsAsync(new ExternalCallResult(
|
||||||
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
|
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
|
||||||
var forwarder = new CapturingForwarder();
|
var forwarder = new CapturingForwarder();
|
||||||
@@ -485,7 +557,7 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
InstanceName,
|
InstanceName,
|
||||||
It.IsAny<CancellationToken>(),
|
It.IsAny<CancellationToken>(),
|
||||||
It.IsAny<TrackedOperationId?>(),
|
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
|
// S&F took ownership — Attempted + Resolve come from the
|
||||||
// CachedCallLifecycleBridge driven by the retry loop, not the helper.
|
// CachedCallLifecycleBridge driven by the retry loop, not the helper.
|
||||||
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
|
|||||||
@@ -357,6 +357,83 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
|
|||||||
Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript);
|
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 ──
|
// ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ──
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -452,6 +452,141 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
|
|||||||
Assert.Equal(message.ExecutionId, retrieved!.ExecutionId);
|
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)
|
private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category)
|
||||||
{
|
{
|
||||||
return new StoreAndForwardMessage
|
return new StoreAndForwardMessage
|
||||||
|
|||||||
Reference in New Issue
Block a user