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.
This commit is contained in:
Joseph Doherty
2026-05-21 15:05:00 -04:00
parent 6b16a48886
commit 0149ce6180
10 changed files with 193 additions and 95 deletions

View File

@@ -16,13 +16,15 @@ namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <list type="bullet">
/// <item><description>
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/>
/// 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.
/// </description></item>
/// <item><description>
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
/// <c>Database</c> write performed through ONE context share a single
/// <see cref="AuditEvent.CorrelationId"/>.
/// <see cref="AuditEvent.ExecutionId"/>. The per-operation
/// <see cref="AuditEvent.CorrelationId"/> stays null for these sync one-shot
/// calls — a sync call has no operation lifecycle.
/// </description></item>
/// </list>
/// </summary>
@@ -53,14 +55,14 @@ public class ExecutionCorrelationContextTests
/// system client, database gateway and audit writer the cross-helper test
/// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
/// integration helpers (ExternalSystem / Database) never touch them — and
/// <paramref name="auditCorrelationId"/> defaults to null so the ctor's
/// <paramref name="executionId"/> defaults to null so the ctor's
/// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
/// </summary>
private static ScriptRuntimeContext CreateContext(
IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter,
Guid? auditCorrelationId = null)
Guid? executionId = null)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
@@ -85,7 +87,7 @@ public class ExecutionCorrelationContextTests
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
auditCorrelationId: auditCorrelationId);
executionId: executionId);
}
/// <summary>
@@ -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<IExternalSystemClient>();
@@ -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);
}
}