From 0149ce618095667873110881c392dc23f32ac0cc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:05:00 -0400 Subject: [PATCH] feat(auditlog): site script-side emitters stamp ExecutionId Move the per-script-execution Guid on ScriptRuntimeContext from _auditCorrelationId to _executionId, and stamp it into the dedicated AuditEvent.ExecutionId column on every script-side audit row: - Sync ApiCall / DbWrite: ExecutionId set; CorrelationId reverts to null (a sync one-shot call has no operation lifecycle). - Cached-call script-side rows (CachedSubmit, immediate-completion ApiCallCached + CachedResolve) and NotifySend: ExecutionId set; CorrelationId unchanged (per-operation TrackedOperationId / NotificationId). Renames the threaded ctor param/field across ExternalSystemHelper, DatabaseHelper, AuditingDbConnection and AuditingDbCommand, and threads the id through NotifyHelper/NotifyTarget. The S&F retry-loop cached rows (CachedCallLifecycleBridge) are out of scope here. --- .../Scripts/AuditingDbCommand.cs | 18 ++-- .../Scripts/AuditingDbConnection.cs | 10 +- .../Scripts/ScriptRuntimeContext.cs | 93 ++++++++++++++----- .../DatabaseCachedWriteEmissionTests.cs | 16 +++- .../Scripts/DatabaseSyncEmissionTests.cs | 30 +++--- .../ExecutionCorrelationContextTests.cs | 39 ++++---- .../ExternalSystemCachedCallEmissionTests.cs | 21 ++++- .../ExternalSystemCallAuditEmissionTests.cs | 47 ++++++---- .../Scripts/NotifyHelperTests.cs | 3 +- .../Scripts/NotifySendAuditEmissionTests.cs | 11 ++- 10 files changed, 193 insertions(+), 95 deletions(-) diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs index 3e59ecf..e5427ed 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs @@ -37,11 +37,11 @@ internal sealed class AuditingDbCommand : DbCommand private readonly string _siteId; private readonly string _instanceName; private readonly string? _sourceScript; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly ILogger _logger; private DbConnection? _wrappingConnection; - // Parameter ordering: auditCorrelationId sits immediately after the ILogger, + // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, // DatabaseHelper, AuditingDbConnection). public AuditingDbCommand( @@ -52,7 +52,7 @@ internal sealed class AuditingDbCommand : DbCommand string instanceName, string? sourceScript, ILogger logger, - Guid auditCorrelationId) + Guid executionId) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -61,7 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand _instanceName = instanceName ?? string.Empty; _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; } // -- Forwarded surface ------------------------------------------------ @@ -432,10 +432,12 @@ internal sealed class AuditingDbCommand : DbCommand OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.DbOutbound, Kind = AuditKind.DbWrite, - // Audit Log #23: the execution-wide correlation id, so this sync - // DbWrite row shares an id with the other sync trust-boundary rows - // from the same script run. - CorrelationId = _auditCorrelationId, + // Audit Log #23: a sync one-shot DB write has no operation + // lifecycle, so CorrelationId is null. ExecutionId carries the + // per-execution id so this row shares an id with the other sync + // trust-boundary rows from the same script run. + CorrelationId = null, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs index a8d0ee0..c5a52e1 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs @@ -36,10 +36,10 @@ internal sealed class AuditingDbConnection : DbConnection private readonly string _siteId; private readonly string _instanceName; private readonly string? _sourceScript; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly ILogger _logger; - // Parameter ordering: auditCorrelationId sits immediately after the ILogger, + // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, // DatabaseHelper, AuditingDbCommand). public AuditingDbConnection( @@ -50,7 +50,7 @@ internal sealed class AuditingDbConnection : DbConnection string instanceName, string? sourceScript, ILogger logger, - Guid auditCorrelationId) + Guid executionId) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -59,7 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection _instanceName = instanceName ?? string.Empty; _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; } // ConnectionString is settable on DbConnection — forward both halves. @@ -99,7 +99,7 @@ internal sealed class AuditingDbConnection : DbConnection _instanceName, _sourceScript, _logger, - _auditCorrelationId); + _executionId); } protected override void Dispose(bool disposing) diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index ed643ef..bfdbaa4 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -106,19 +106,22 @@ public class ScriptRuntimeContext private readonly ICachedCallTelemetryForwarder? _cachedForwarder; /// - /// Audit Log #23: the execution-wide audit correlation id. Every sync + /// Audit Log #23: the per-execution id for this script run. Every /// trust-boundary audit row emitted by this script execution - /// (ApiCall, DbWrite) is stamped with this id so all the - /// rows from one script run can be correlated together. + /// (sync ApiCall/DbWrite, cached-call lifecycle rows, + /// NotifySend) is stamped into AuditEvent.ExecutionId with + /// this value so all the rows from one script run can be correlated + /// together — independently of the per-operation + /// AuditEvent.CorrelationId. /// - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; - /// - /// Audit Log #23: the execution-wide audit correlation id. When omitted + /// + /// Audit Log #23: the per-execution id for this script run. When omitted /// (tag-change / timer-triggered executions) a fresh id is generated; an /// inbound caller may supply one to tie the execution to an upstream - /// request. Stamped on the sync ApiCall/DbWrite audit rows - /// this execution emits. + /// request. Stamped into AuditEvent.ExecutionId on every + /// trust-boundary audit row this execution emits. /// public ScriptRuntimeContext( IActorRef instanceActor, @@ -138,7 +141,7 @@ public class ScriptRuntimeContext IAuditWriter? auditWriter = null, IOperationTrackingStore? operationTrackingStore = null, ICachedCallTelemetryForwarder? cachedForwarder = null, - Guid? auditCorrelationId = null) + Guid? executionId = null) { _instanceActor = instanceActor; _self = self; @@ -157,7 +160,7 @@ public class ScriptRuntimeContext _auditWriter = auditWriter; _operationTrackingStore = operationTrackingStore; _cachedForwarder = cachedForwarder; - _auditCorrelationId = auditCorrelationId ?? Guid.NewGuid(); + _executionId = executionId ?? Guid.NewGuid(); } /// @@ -258,7 +261,7 @@ public class ScriptRuntimeContext /// ExternalSystem.CachedCall("systemName", "methodName", params) /// public ExternalSystemHelper ExternalSystem => new( - _externalSystemClient, _instanceName, _logger, _auditCorrelationId, _auditWriter, _siteId, _sourceScript, + _externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript, // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // on every ExternalSystem.CachedCall enqueue. _cachedForwarder); @@ -272,7 +275,7 @@ public class ScriptRuntimeContext _databaseGateway, _instanceName, _logger, - _auditCorrelationId, + _executionId, // Audit Log #23 (M4 Bundle A): wire the IAuditWriter so // Database.Connection(name) returns an auditing decorator that // emits one DbOutbound/DbWrite row per script-initiated @@ -299,7 +302,7 @@ public class ScriptRuntimeContext /// public NotifyHelper Notify => new( _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger, - _auditWriter); + _executionId, _auditWriter); /// /// Audit Log #23 (M3): site-local tracking-status API for cached operations. @@ -380,7 +383,7 @@ public class ScriptRuntimeContext private readonly IExternalSystemClient? _client; private readonly string _instanceName; private readonly ILogger _logger; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly IAuditWriter? _auditWriter; private readonly string _siteId; private readonly string? _sourceScript; @@ -390,7 +393,7 @@ public class ScriptRuntimeContext // (via InternalsVisibleTo). Production sites resolve the helper through // ScriptRuntimeContext.ExternalSystem. // - // Parameter ordering: auditCorrelationId sits immediately after the + // Parameter ordering: executionId sits immediately after the // ILogger across all four audit-threaded ctors (ExternalSystemHelper, // DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required // Guid cannot follow the optional provenance params without a @@ -400,7 +403,7 @@ public class ScriptRuntimeContext IExternalSystemClient? client, string instanceName, ILogger logger, - Guid auditCorrelationId, + Guid executionId, IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, @@ -409,7 +412,7 @@ public class ScriptRuntimeContext _client = client; _instanceName = instanceName; _logger = logger; - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; _auditWriter = auditWriter; _siteId = siteId; _sourceScript = sourceScript; @@ -567,7 +570,11 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.CachedSubmit, + // CorrelationId stays the per-operation lifecycle id + // (TrackedOperationId); ExecutionId carries the + // per-execution id shared across this script run. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -677,7 +684,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCallCached, + // CorrelationId = per-operation lifecycle id; + // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -738,7 +748,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.CachedResolve, + // CorrelationId = per-operation lifecycle id; + // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -910,9 +923,12 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, - // Audit Log #23: the execution-wide correlation id, so all the - // sync ApiCall/DbWrite rows from one script run share an id. - CorrelationId = _auditCorrelationId, + // Audit Log #23: a sync one-shot call has no operation + // lifecycle, so CorrelationId is null. ExecutionId carries the + // per-execution id so all the sync ApiCall/DbWrite rows from + // one script run can be correlated together. + CorrelationId = null, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -979,7 +995,7 @@ public class ScriptRuntimeContext private readonly IDatabaseGateway? _gateway; private readonly string _instanceName; private readonly ILogger _logger; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly string _siteId; private readonly string? _sourceScript; private readonly ICachedCallTelemetryForwarder? _cachedForwarder; @@ -996,7 +1012,7 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; - // Parameter ordering: auditCorrelationId sits immediately after the + // Parameter ordering: executionId sits immediately after the // ILogger — see the note on ExternalSystemHelper's ctor for why the // post-logger slot is the one consistent position across all four // audit-threaded ctors. @@ -1004,7 +1020,7 @@ public class ScriptRuntimeContext IDatabaseGateway? gateway, string instanceName, ILogger logger, - Guid auditCorrelationId, + Guid executionId, IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, @@ -1013,7 +1029,7 @@ public class ScriptRuntimeContext _gateway = gateway; _instanceName = instanceName; _logger = logger; - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; _auditWriter = auditWriter; _siteId = siteId; _sourceScript = sourceScript; @@ -1049,7 +1065,7 @@ public class ScriptRuntimeContext instanceName: _instanceName, sourceScript: _sourceScript, logger: _logger, - auditCorrelationId: _auditCorrelationId); + executionId: _executionId); } /// @@ -1116,7 +1132,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.DbOutbound, Kind = AuditKind.CachedSubmit, + // CorrelationId = per-operation lifecycle id + // (TrackedOperationId); ExecutionId = per-execution id. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -1178,6 +1197,12 @@ public class ScriptRuntimeContext private readonly TimeSpan _askTimeout; private readonly ILogger _logger; + /// + /// Audit Log #23: the per-execution id for this script run, stamped + /// into AuditEvent.ExecutionId on the NotifySend row. + /// + private readonly Guid _executionId; + /// /// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Notification/NotifySend row produced when the script @@ -1188,6 +1213,8 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; + // Parameter ordering: executionId sits immediately after the ILogger, + // consistent with the other audit-threaded ctors. internal NotifyHelper( StoreAndForwardService? storeAndForward, ICanTell? siteCommunicationActor, @@ -1196,6 +1223,7 @@ public class ScriptRuntimeContext string? sourceScript, TimeSpan askTimeout, ILogger logger, + Guid executionId, IAuditWriter? auditWriter = null) { _storeAndForward = storeAndForward; @@ -1205,6 +1233,7 @@ public class ScriptRuntimeContext _sourceScript = sourceScript; _askTimeout = askTimeout; _logger = logger; + _executionId = executionId; _auditWriter = auditWriter; } @@ -1215,6 +1244,9 @@ public class ScriptRuntimeContext { return new NotifyTarget( listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger, + // Audit Log #23: the per-execution id stamped into the + // NotifySend row's ExecutionId column. + _executionId, // Audit Log #23 (M4 Bundle C): forward the writer so Send() // can emit one NotifySend(Submitted) row per accepted submission. _auditWriter); @@ -1292,6 +1324,12 @@ public class ScriptRuntimeContext private readonly string? _sourceScript; private readonly ILogger _logger; + /// + /// Audit Log #23: the per-execution id for this script run, stamped + /// into AuditEvent.ExecutionId on the NotifySend row. + /// + private readonly Guid _executionId; + /// /// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Notification/NotifySend row written immediately after @@ -1307,6 +1345,7 @@ public class ScriptRuntimeContext string instanceName, string? sourceScript, ILogger logger, + Guid executionId, IAuditWriter? auditWriter = null) { _listName = listName; @@ -1315,6 +1354,7 @@ public class ScriptRuntimeContext _instanceName = instanceName; _sourceScript = sourceScript; _logger = logger; + _executionId = executionId; _auditWriter = auditWriter; } @@ -1431,7 +1471,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.Notification, Kind = AuditKind.NotifySend, + // CorrelationId is the NotificationId-derived per-operation + // lifecycle id; ExecutionId carries the per-execution id. CorrelationId = correlationId, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index 042a031..082ffcc 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -39,6 +39,12 @@ public class DatabaseCachedWriteEmissionTests private const string InstanceName = "Plant.Pump42"; private const string SourceScript = "ScriptActor:WriteAudit"; + /// + /// Audit Log #23: a fixed per-execution id so the cached-row tests can + /// assert against a known value. + /// + private static readonly Guid TestExecutionId = Guid.NewGuid(); + private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, ICachedCallTelemetryForwarder? forwarder) @@ -47,9 +53,10 @@ public class DatabaseCachedWriteEmissionTests gateway, InstanceName, NullLogger.Instance, - // Audit Log #23: execution-wide correlation id. Cached rows keep - // CorrelationId = TrackedOperationId, so any value works here. - Guid.NewGuid(), + // Audit Log #23: the per-execution id stamped into ExecutionId on + // every script-side row. Cached rows keep CorrelationId = + // TrackedOperationId (the per-operation lifecycle id). + TestExecutionId, siteId: SiteId, sourceScript: SourceScript, cachedForwarder: forwarder); @@ -79,7 +86,10 @@ public class DatabaseCachedWriteEmissionTests Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind); Assert.Equal(AuditStatus.Submitted, packet.Audit.Status); Assert.Equal("myDb", packet.Audit.Target); + // CorrelationId is the per-operation lifecycle id (TrackedOperationId); + // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.CorrelationId); + Assert.Equal(TestExecutionId, packet.Audit.ExecutionId); Assert.Equal(trackedId, packet.Operational.TrackedOperationId); Assert.Equal("DbOutbound", packet.Operational.Channel); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs index 40f2986..021cae5 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs @@ -49,27 +49,27 @@ public class DatabaseSyncEmissionTests private const string ConnectionName = "machineData"; /// - /// Audit Log #23: a fixed execution-wide correlation id used by the - /// default + /// Audit Log #23: a fixed per-execution id used by the default + /// /// overload so assertions can compare against a known value. /// - private static readonly Guid TestCorrelationId = Guid.NewGuid(); + private static readonly Guid TestExecutionId = Guid.NewGuid(); private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter) - => CreateHelper(gateway, auditWriter, TestCorrelationId); + => CreateHelper(gateway, auditWriter, TestExecutionId); private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter, - Guid correlationId) + Guid executionId) { return new ScriptRuntimeContext.DatabaseHelper( gateway, InstanceName, NullLogger.Instance, - correlationId, + executionId, auditWriter: auditWriter, siteId: SiteId, sourceScript: SourceScript, @@ -282,14 +282,16 @@ public class DatabaseSyncEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - // Audit Log #23: the sync DbWrite row now carries the execution-wide - // correlation id the helper was constructed with. - Assert.Equal(TestCorrelationId, evt.CorrelationId); + // Audit Log #23: the sync DbWrite row carries the per-execution id the + // helper was constructed with in ExecutionId. CorrelationId is null — + // a sync one-shot call has no operation lifecycle. + Assert.Equal(TestExecutionId, evt.ExecutionId); + Assert.Null(evt.CorrelationId); Assert.NotEqual(Guid.Empty, evt.EventId); } [Fact] - public async Task SyncDbWrite_StampsExecutionCorrelationId() + public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId() { using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared"); var inner = NewInMemoryDb(out var _); @@ -298,16 +300,18 @@ public class DatabaseSyncEmissionTests .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .ReturnsAsync(inner); var writer = new CapturingAuditWriter(); - var correlationId = Guid.NewGuid(); + var executionId = Guid.NewGuid(); - var helper = CreateHelper(gateway.Object, writer, correlationId); + var helper = CreateHelper(gateway.Object, writer, executionId); await using var conn = await helper.Connection(ConnectionName); await using var cmd = conn.CreateCommand(); cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')"; await cmd.ExecuteNonQueryAsync(); var evt = Assert.Single(writer.Events); - Assert.Equal(correlationId, evt.CorrelationId); + Assert.Equal(executionId, evt.ExecutionId); + // Sync one-shot call: CorrelationId is null (no operation lifecycle). + Assert.Null(evt.CorrelationId); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs index 13a54dc..5e85efb 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs @@ -16,13 +16,15 @@ namespace ScadaLink.SiteRuntime.Tests.Scripts; /// /// /// The ?? Guid.NewGuid() fallback in the -/// ctor: when no audit correlation id is supplied (tag-change / timer-triggered +/// ctor: when no execution id is supplied (tag-change / timer-triggered /// executions) a fresh, non-empty id is minted and stamped on the emitted rows. /// /// /// The execution-wide contract: an ExternalSystem.Call and a sync /// Database write performed through ONE context share a single -/// . +/// . The per-operation +/// stays null for these sync one-shot +/// calls — a sync call has no operation lifecycle. /// /// /// @@ -53,14 +55,14 @@ public class ExecutionCorrelationContextTests /// system client, database gateway and audit writer the cross-helper test /// needs. The actor refs are — the /// integration helpers (ExternalSystem / Database) never touch them — and - /// defaults to null so the ctor's + /// defaults to null so the ctor's /// ?? Guid.NewGuid() fallback is exercised unless a test supplies one. /// private static ScriptRuntimeContext CreateContext( IExternalSystemClient? externalSystemClient, IDatabaseGateway? databaseGateway, IAuditWriter? auditWriter, - Guid? auditCorrelationId = null) + Guid? executionId = null) { var compilationService = new ScriptCompilationService( NullLogger.Instance); @@ -85,7 +87,7 @@ public class ExecutionCorrelationContextTests auditWriter: auditWriter, operationTrackingStore: null, cachedForwarder: null, - auditCorrelationId: auditCorrelationId); + executionId: executionId); } /// @@ -113,9 +115,9 @@ public class ExecutionCorrelationContextTests } [Fact] - public async Task NoCorrelationIdSupplied_SyncCall_StampsFreshNonEmptyCorrelationId() + public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId() { - // No auditCorrelationId argument — the ScriptRuntimeContext ctor's + // No executionId argument — the ScriptRuntimeContext ctor's // `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id // branch every other audit test bypasses by passing an explicit id). var client = new Mock(); @@ -128,17 +130,19 @@ public class ExecutionCorrelationContextTests await context.ExternalSystem.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); - Assert.NotNull(evt.CorrelationId); - Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value); + Assert.NotNull(evt.ExecutionId); + Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); + // A sync one-shot call has no operation lifecycle — CorrelationId is null. + Assert.Null(evt.CorrelationId); } [Fact] - public async Task SameContext_ApiCallAndDbWrite_ShareTheSameCorrelationId() + public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId() { // The execution-wide contract: an ExternalSystem.Call AND a sync // Database write performed through ONE ScriptRuntimeContext must both - // carry the same execution correlation id, so an audit reader can tie - // every trust-boundary action from one script run together. + // carry the same ExecutionId, so an audit reader can tie every + // trust-boundary action from one script run together. using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared"); var innerDb = NewInMemoryDb(out var _); @@ -170,10 +174,13 @@ public class ExecutionCorrelationContextTests var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound); var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound); - Assert.NotNull(apiRow.CorrelationId); - Assert.NotEqual(Guid.Empty, apiRow.CorrelationId!.Value); + Assert.NotNull(apiRow.ExecutionId); + Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value); // The ApiCall row and the DbWrite row, emitted by two different helpers - // resolved off one context, carry the identical execution correlation id. - Assert.Equal(apiRow.CorrelationId, dbRow.CorrelationId); + // resolved off one context, carry the identical ExecutionId. + Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId); + // Both are sync one-shot calls — neither carries a CorrelationId. + Assert.Null(apiRow.CorrelationId); + Assert.Null(dbRow.CorrelationId); } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index f2edfa8..c65d93b 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -41,6 +41,12 @@ public class ExternalSystemCachedCallEmissionTests private const string InstanceName = "Plant.Pump42"; private const string SourceScript = "ScriptActor:CheckPressure"; + /// + /// Audit Log #23: a fixed per-execution id so the cached-row tests can + /// assert against a known value. + /// + private static readonly Guid TestExecutionId = Guid.NewGuid(); + private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, ICachedCallTelemetryForwarder? forwarder) @@ -49,9 +55,10 @@ public class ExternalSystemCachedCallEmissionTests client, InstanceName, NullLogger.Instance, - // Audit Log #23: execution-wide correlation id. Cached rows keep - // CorrelationId = TrackedOperationId, so any value works here. - Guid.NewGuid(), + // Audit Log #23: the per-execution id stamped into ExecutionId on + // every script-side row. Cached rows keep CorrelationId = + // TrackedOperationId (the per-operation lifecycle id). + TestExecutionId, auditWriter: null, siteId: SiteId, sourceScript: SourceScript, @@ -83,7 +90,10 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind); Assert.Equal(AuditStatus.Submitted, packet.Audit.Status); Assert.Equal("ERP.GetOrder", packet.Audit.Target); + // CorrelationId is the per-operation lifecycle id (TrackedOperationId); + // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.CorrelationId); + Assert.Equal(TestExecutionId, packet.Audit.ExecutionId); Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState); // Operational mirror — same id, Submitted, RetryCount 0, not terminal. @@ -298,6 +308,7 @@ public class ExternalSystemCachedCallEmissionTests var submit = forwarder.Telemetry[0]; Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind); Assert.Equal(AuditStatus.Submitted, submit.Audit.Status); + Assert.Equal(TestExecutionId, submit.Audit.ExecutionId); Assert.Equal(trackedId, submit.Operational.TrackedOperationId); Assert.Null(submit.Operational.TerminalAtUtc); @@ -305,7 +316,10 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel); Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind); Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status); + // Cached rows: CorrelationId = TrackedOperationId; ExecutionId is the + // per-execution id from the runtime context. Assert.Equal(trackedId.Value, attempted.Audit.CorrelationId); + Assert.Equal(TestExecutionId, attempted.Audit.ExecutionId); Assert.Equal("ERP.GetOrder", attempted.Audit.Target); Assert.Equal(trackedId, attempted.Operational.TrackedOperationId); Assert.Equal("Attempted", attempted.Operational.Status); @@ -316,6 +330,7 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind); Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status); Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId); + Assert.Equal(TestExecutionId, resolve.Audit.ExecutionId); Assert.Equal(trackedId, resolve.Operational.TrackedOperationId); Assert.Equal("Delivered", resolve.Operational.Status); // Terminal row carries TerminalAtUtc. diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs index 209946e..6632783 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -46,27 +46,27 @@ public class ExternalSystemCallAuditEmissionTests private const string SourceScript = "ScriptActor:CheckPressure"; /// - /// Audit Log #23: a fixed execution-wide correlation id used by the - /// default + /// Audit Log #23: a fixed per-execution id used by the default + /// /// overload so assertions can compare against a known value. /// - private static readonly Guid TestCorrelationId = Guid.NewGuid(); + private static readonly Guid TestExecutionId = Guid.NewGuid(); private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, IAuditWriter? auditWriter) - => CreateHelper(client, auditWriter, TestCorrelationId); + => CreateHelper(client, auditWriter, TestExecutionId); private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, IAuditWriter? auditWriter, - Guid correlationId) + Guid executionId) { return new ScriptRuntimeContext.ExternalSystemHelper( client, InstanceName, NullLogger.Instance, - correlationId, + executionId, auditWriter, SiteId, SourceScript); @@ -225,47 +225,54 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - // Audit Log #23: the sync ApiCall row now carries the execution-wide - // correlation id the helper was constructed with. - Assert.Equal(TestCorrelationId, evt.CorrelationId); + // Audit Log #23: the sync ApiCall row carries the per-execution id the + // helper was constructed with in ExecutionId. CorrelationId is null — + // a sync one-shot call has no operation lifecycle. + Assert.Equal(TestExecutionId, evt.ExecutionId); + Assert.Null(evt.CorrelationId); } [Fact] - public async Task Call_SyncApiCall_StampsExecutionCorrelationId() + public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId() { var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); - var correlationId = Guid.NewGuid(); + var executionId = Guid.NewGuid(); - var helper = CreateHelper(client.Object, writer, correlationId); + var helper = CreateHelper(client.Object, writer, executionId); await helper.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); - Assert.Equal(correlationId, evt.CorrelationId); + Assert.Equal(executionId, evt.ExecutionId); + // Sync one-shot call: CorrelationId is null (no operation lifecycle). + Assert.Null(evt.CorrelationId); } [Fact] - public async Task Call_TwoCallsOnSameHelper_ShareTheSameCorrelationId() + public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId() { var client = new Mock(); client .Setup(c => c.CallAsync(It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); - var correlationId = Guid.NewGuid(); + var executionId = Guid.NewGuid(); - var helper = CreateHelper(client.Object, writer, correlationId); + var helper = CreateHelper(client.Object, writer, executionId); await helper.Call("ERP", "GetOrder"); await helper.Call("ERP", "GetCustomer"); Assert.Equal(2, writer.Events.Count); - // Both sync ApiCall rows from one execution carry the same id. - Assert.Equal(correlationId, writer.Events[0].CorrelationId); - Assert.Equal(correlationId, writer.Events[1].CorrelationId); - Assert.Equal(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId); + // Both sync ApiCall rows from one execution carry the same ExecutionId. + Assert.Equal(executionId, writer.Events[0].ExecutionId); + Assert.Equal(executionId, writer.Events[1].ExecutionId); + Assert.Equal(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId); + // Neither sync call carries a CorrelationId. + Assert.Null(writer.Events[0].CorrelationId); + Assert.Null(writer.Events[1].CorrelationId); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index 509e8a6..adfec68 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -69,7 +69,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable "Plant.Pump3", sourceScript, TimeSpan.FromSeconds(3), - NullLogger.Instance); + NullLogger.Instance, + Guid.NewGuid()); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs index 7218ad9..402821b 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs @@ -53,6 +53,12 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable private const string Subject = "Pump alarm"; private const string Body = "Pump 3 tripped"; + /// + /// Audit Log #23: a fixed per-execution id so the NotifySend test can + /// assert against a known value. + /// + private static readonly Guid TestExecutionId = Guid.NewGuid(); + private readonly SqliteConnection _keepAlive; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; @@ -102,6 +108,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, + TestExecutionId, auditWriter); } @@ -214,12 +221,14 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable // NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char // hex form, which Guid.TryParse accepts. The audit row's CorrelationId - // must round-trip back to the same Guid value. + // must round-trip back to the same Guid value (the per-operation + // lifecycle id). ExecutionId carries the per-execution id instead. Assert.True(Guid.TryParse(notificationId, out var expected), $"NotificationId '{notificationId}' should be a parseable Guid"); var evt = writer.Events[0]; Assert.NotNull(evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId); + Assert.Equal(TestExecutionId, evt.ExecutionId); } [Fact]