diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs index 258bbaa..121370f 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs @@ -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 diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs index 6188f0c..cbc365c 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs @@ -71,6 +71,16 @@ public interface ICachedCallLifecycleObserver /// rows carry the same SourceScript provenance the script-side cached /// rows already do. null when not known. /// +/// +/// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the +/// inbound-API request that spawned the originating script execution, +/// threaded through the store-and-forward buffer alongside +/// . The audit bridge stamps it onto the +/// retry-loop ApiCallCached/DbWriteCached Attempted and +/// CachedResolve rows so they correlate back to the spawning run. +/// null for a non-routed run and for rows buffered before Task 6 +/// (back-compat). +/// 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); /// /// Coarse outcome of one cached-call delivery attempt, observed from inside diff --git a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs index 0271bec..c7a89fd 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs @@ -40,6 +40,14 @@ public interface IDatabaseGateway /// threaded onto the buffered S&F message alongside /// . null when not known. /// + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId 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 so the + /// retry-loop cached-write audit rows carry it. null for a + /// non-routed run. + /// 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); } diff --git a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs index d4f855c..107156f 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs @@ -41,6 +41,14 @@ public interface IExternalSystemClient /// threaded onto the buffered S&F message alongside /// . null when not known. /// + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId 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 so the + /// retry-loop cached-call audit rows carry it. null for a non-routed + /// run. + /// Task 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); } /// diff --git a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs index efa29c4..3ac3794 100644 --- a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs +++ b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs @@ -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); } /// diff --git a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs index dfe042b..6db1093 100644 --- a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs @@ -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); } diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 57e6c11..f581645 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -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) diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs index 3e799ac..ba521af 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs @@ -76,4 +76,19 @@ public class StoreAndForwardMessage /// known (non-cached categories, pre-migration rows). /// public string? SourceScript { get; set; } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the + /// inbound-API request that spawned the originating script execution, + /// threaded alongside 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 + /// (ApiCallCached/DbWriteCached Attempted, CachedResolve), + /// keeping them correlated with the cross-execution chain. null 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). + /// + public Guid? ParentExecutionId { get; set; } } diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs index fe27528..a8d6245 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs @@ -187,6 +187,14 @@ public class StoreAndForwardService /// so the retry-loop audit rows carry the same provenance the script-side /// cached rows do. null when not known. /// + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the + /// inbound-API request that spawned the originating script execution. + /// Threaded onto the buffered row alongside + /// so the retry-loop cached-call audit rows carry it. null for a + /// non-routed run and for callers (notifications, pre-Task-6 callers) that + /// do not supply one. + /// public async Task 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) { diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs index f7564fa..6328f96 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs @@ -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; } /// - /// Audit Log #23 (ExecutionId Task 4): defensively reads the - /// execution_id column. A null value (legacy pre-migration + /// Audit Log #23 (ExecutionId Task 4 / ParentExecutionId Task 6): + /// defensively reads a nullable GUID column (execution_id or + /// parent_execution_id). A null value (legacy pre-migration /// rows) and a malformed non-null value both yield null — a corrupt /// id must not throw and abort the retry sweep, which reads many rows. /// - 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; } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs index 1438cfe..4185ab1 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs @@ -33,7 +33,8 @@ public class CachedCallLifecycleBridgeTests string? lastError = null, int? httpStatus = null, Guid? executionId = null, - string? sourceScript = null) => + string? sourceScript = null, + Guid? parentExecutionId = null) => new( TrackedOperationId: _id, Channel: channel, @@ -48,7 +49,8 @@ public class CachedCallLifecycleBridgeTests DurationMs: 42, SourceInstanceId: "Plant.Pump42", ExecutionId: executionId, - SourceScript: sourceScript); + SourceScript: sourceScript, + ParentExecutionId: parentExecutionId); [Fact] public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve() @@ -259,4 +261,70 @@ public class CachedCallLifecycleBridgeTests Assert.Null(captured!.Audit.ExecutionId); 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(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .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(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .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(t => captured = t), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure)); + + Assert.NotNull(captured); + Assert.Null(captured!.Audit.ParentExecutionId); + } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index e10216c..b993083 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -75,7 +75,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -116,7 +116,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -145,7 +145,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -167,7 +167,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -181,7 +181,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -205,7 +205,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -221,7 +221,79 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.Is(id => id == TestExecutionId), - It.Is(s => s == SourceScript)), + It.Is(s => s == SourceScript), + It.IsAny()), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for + /// ParentExecutionId. A cached write enqueued from an inbound-API- + /// routed script run must forward the runtime context's + /// ParentExecutionId verbatim into + /// so the buffered retry + /// loop later stamps it onto its audit rows. + /// + [Fact] + public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway() + { + var parentExecutionId = Guid.NewGuid(); + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .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?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == parentExecutionId)), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a + /// null ParentExecutionId into the gateway — the additive default. + /// + [Fact] + public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway() + { + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .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?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == null)), Times.Once); } @@ -236,7 +308,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder { @@ -253,7 +325,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index 4bf31c3..7ed9725 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -78,7 +78,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -121,7 +121,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -158,7 +158,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -178,7 +178,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), id1, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); client.Verify(c => c.CachedCallAsync( "ERP", "GetOrder", @@ -186,7 +186,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), id2, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -210,7 +210,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -226,7 +226,79 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.Is(id => id == TestExecutionId), - It.Is(s => s == SourceScript)), + It.Is(s => s == SourceScript), + It.IsAny()), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for + /// ParentExecutionId. A cached call enqueued from an inbound-API- + /// routed script run must forward the runtime context's + /// ParentExecutionId verbatim into + /// so the buffered + /// retry loop later stamps it onto its audit rows. + /// + [Fact] + public async Task CachedCall_ThreadsParentExecutionId_IntoClient() + { + var parentExecutionId = Guid.NewGuid(); + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .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?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == parentExecutionId)), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a + /// null ParentExecutionId into the client — the additive default. + /// + [Fact] + public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient() + { + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .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?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == null)), Times.Once); } @@ -241,7 +313,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder { @@ -261,7 +333,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -276,7 +348,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -303,7 +375,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var helper = CreateHelper(client.Object, forwarder: null); @@ -316,7 +388,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -346,7 +418,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) // WasBuffered=false — the immediate HTTP attempt succeeded; S&F // is bypassed entirely. .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); @@ -412,7 +484,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -442,7 +514,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult( false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -485,7 +557,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) // S&F took ownership — Attempted + Resolve come from the // CachedCallLifecycleBridge driven by the retry loop, not the helper. .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); diff --git a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs index 501bc3b..145ac83 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs @@ -357,6 +357,83 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable 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 ── [Fact] diff --git a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs index 6fed58e..f6a2f0a 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs @@ -452,6 +452,141 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable 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) { return new StoreAndForwardMessage