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

@@ -37,11 +37,11 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId; private readonly Guid _executionId;
private readonly ILogger _logger; private readonly ILogger _logger;
private DbConnection? _wrappingConnection; 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, // consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection). // DatabaseHelper, AuditingDbConnection).
public AuditingDbCommand( public AuditingDbCommand(
@@ -52,7 +52,7 @@ internal sealed class AuditingDbCommand : DbCommand
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger, ILogger logger,
Guid auditCorrelationId) Guid executionId)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -61,7 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId; _executionId = executionId;
} }
// -- Forwarded surface ------------------------------------------------ // -- Forwarded surface ------------------------------------------------
@@ -432,10 +432,12 @@ internal sealed class AuditingDbCommand : DbCommand
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound, Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite, Kind = AuditKind.DbWrite,
// Audit Log #23: the execution-wide correlation id, so this sync // Audit Log #23: a sync one-shot DB write has no operation
// DbWrite row shares an id with the other sync trust-boundary rows // lifecycle, so CorrelationId is null. ExecutionId carries the
// from the same script run. // per-execution id so this row shares an id with the other sync
CorrelationId = _auditCorrelationId, // trust-boundary rows from the same script run.
CorrelationId = null,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,

View File

@@ -36,10 +36,10 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId; private readonly Guid _executionId;
private readonly ILogger _logger; 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, // consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand). // DatabaseHelper, AuditingDbCommand).
public AuditingDbConnection( public AuditingDbConnection(
@@ -50,7 +50,7 @@ internal sealed class AuditingDbConnection : DbConnection
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger, ILogger logger,
Guid auditCorrelationId) Guid executionId)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -59,7 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId; _executionId = executionId;
} }
// ConnectionString is settable on DbConnection — forward both halves. // ConnectionString is settable on DbConnection — forward both halves.
@@ -99,7 +99,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName, _instanceName,
_sourceScript, _sourceScript,
_logger, _logger,
_auditCorrelationId); _executionId);
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View File

@@ -106,19 +106,22 @@ public class ScriptRuntimeContext
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary> /// <summary>
/// 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 /// trust-boundary audit row emitted by this script execution
/// (<c>ApiCall</c>, <c>DbWrite</c>) is stamped with this id so all the /// (sync <c>ApiCall</c>/<c>DbWrite</c>, cached-call lifecycle rows,
/// rows from one script run can be correlated together. /// <c>NotifySend</c>) is stamped into <c>AuditEvent.ExecutionId</c> with
/// this value so all the rows from one script run can be correlated
/// together — independently of the per-operation
/// <c>AuditEvent.CorrelationId</c>.
/// </summary> /// </summary>
private readonly Guid _auditCorrelationId; private readonly Guid _executionId;
/// <param name="auditCorrelationId"> /// <param name="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 /// (tag-change / timer-triggered executions) a fresh id is generated; an
/// inbound caller may supply one to tie the execution to an upstream /// inbound caller may supply one to tie the execution to an upstream
/// request. Stamped on the sync <c>ApiCall</c>/<c>DbWrite</c> audit rows /// request. Stamped into <c>AuditEvent.ExecutionId</c> on every
/// this execution emits. /// trust-boundary audit row this execution emits.
/// </param> /// </param>
public ScriptRuntimeContext( public ScriptRuntimeContext(
IActorRef instanceActor, IActorRef instanceActor,
@@ -138,7 +141,7 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null, IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null, ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? auditCorrelationId = null) Guid? executionId = null)
{ {
_instanceActor = instanceActor; _instanceActor = instanceActor;
_self = self; _self = self;
@@ -157,7 +160,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter; _auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore; _operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_auditCorrelationId = auditCorrelationId ?? Guid.NewGuid(); _executionId = executionId ?? Guid.NewGuid();
} }
/// <summary> /// <summary>
@@ -258,7 +261,7 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary> /// </summary>
public ExternalSystemHelper ExternalSystem => new( 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 // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue. // on every ExternalSystem.CachedCall enqueue.
_cachedForwarder); _cachedForwarder);
@@ -272,7 +275,7 @@ public class ScriptRuntimeContext
_databaseGateway, _databaseGateway,
_instanceName, _instanceName,
_logger, _logger,
_auditCorrelationId, _executionId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so // Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that // Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated // emits one DbOutbound/DbWrite row per script-initiated
@@ -299,7 +302,7 @@ public class ScriptRuntimeContext
/// </remarks> /// </remarks>
public NotifyHelper Notify => new( public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger, _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_auditWriter); _executionId, _auditWriter);
/// <summary> /// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations. /// 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 IExternalSystemClient? _client;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _auditCorrelationId; private readonly Guid _executionId;
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
@@ -390,7 +393,7 @@ public class ScriptRuntimeContext
// (via InternalsVisibleTo). Production sites resolve the helper through // (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem. // ScriptRuntimeContext.ExternalSystem.
// //
// Parameter ordering: auditCorrelationId sits immediately after the // Parameter ordering: executionId sits immediately after the
// ILogger across all four audit-threaded ctors (ExternalSystemHelper, // ILogger across all four audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required // DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a // Guid cannot follow the optional provenance params without a
@@ -400,7 +403,7 @@ public class ScriptRuntimeContext
IExternalSystemClient? client, IExternalSystemClient? client,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid auditCorrelationId, Guid executionId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
@@ -409,7 +412,7 @@ public class ScriptRuntimeContext
_client = client; _client = client;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_auditCorrelationId = auditCorrelationId; _executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
@@ -567,7 +570,11 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit, 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, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -677,7 +684,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached, Kind = AuditKind.ApiCallCached,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -738,7 +748,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve, Kind = AuditKind.CachedResolve,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -910,9 +923,12 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall, Kind = AuditKind.ApiCall,
// Audit Log #23: the execution-wide correlation id, so all the // Audit Log #23: a sync one-shot call has no operation
// sync ApiCall/DbWrite rows from one script run share an id. // lifecycle, so CorrelationId is null. ExecutionId carries the
CorrelationId = _auditCorrelationId, // 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -979,7 +995,7 @@ public class ScriptRuntimeContext
private readonly IDatabaseGateway? _gateway; private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _auditCorrelationId; private readonly Guid _executionId;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -996,7 +1012,7 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly IAuditWriter? _auditWriter; 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 // ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four // post-logger slot is the one consistent position across all four
// audit-threaded ctors. // audit-threaded ctors.
@@ -1004,7 +1020,7 @@ public class ScriptRuntimeContext
IDatabaseGateway? gateway, IDatabaseGateway? gateway,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid auditCorrelationId, Guid executionId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
@@ -1013,7 +1029,7 @@ public class ScriptRuntimeContext
_gateway = gateway; _gateway = gateway;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_auditCorrelationId = auditCorrelationId; _executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
@@ -1049,7 +1065,7 @@ public class ScriptRuntimeContext
instanceName: _instanceName, instanceName: _instanceName,
sourceScript: _sourceScript, sourceScript: _sourceScript,
logger: _logger, logger: _logger,
auditCorrelationId: _auditCorrelationId); executionId: _executionId);
} }
/// <summary> /// <summary>
@@ -1116,7 +1132,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound, Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit, Kind = AuditKind.CachedSubmit,
// CorrelationId = per-operation lifecycle id
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -1178,6 +1197,12 @@ public class ScriptRuntimeContext
private readonly TimeSpan _askTimeout; private readonly TimeSpan _askTimeout;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script /// <c>Notification</c>/<c>NotifySend</c> row produced when the script
@@ -1188,6 +1213,8 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors.
internal NotifyHelper( internal NotifyHelper(
StoreAndForwardService? storeAndForward, StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor, ICanTell? siteCommunicationActor,
@@ -1196,6 +1223,7 @@ public class ScriptRuntimeContext
string? sourceScript, string? sourceScript,
TimeSpan askTimeout, TimeSpan askTimeout,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null) IAuditWriter? auditWriter = null)
{ {
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
@@ -1205,6 +1233,7 @@ public class ScriptRuntimeContext
_sourceScript = sourceScript; _sourceScript = sourceScript;
_askTimeout = askTimeout; _askTimeout = askTimeout;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
} }
@@ -1215,6 +1244,9 @@ public class ScriptRuntimeContext
{ {
return new NotifyTarget( return new NotifyTarget(
listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger, 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() // Audit Log #23 (M4 Bundle C): forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission. // can emit one NotifySend(Submitted) row per accepted submission.
_auditWriter); _auditWriter);
@@ -1292,6 +1324,12 @@ public class ScriptRuntimeContext
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after /// <c>Notification</c>/<c>NotifySend</c> row written immediately after
@@ -1307,6 +1345,7 @@ public class ScriptRuntimeContext
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null) IAuditWriter? auditWriter = null)
{ {
_listName = listName; _listName = listName;
@@ -1315,6 +1354,7 @@ public class ScriptRuntimeContext
_instanceName = instanceName; _instanceName = instanceName;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
} }
@@ -1431,7 +1471,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.Notification, Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend, Kind = AuditKind.NotifySend,
// CorrelationId is the NotificationId-derived per-operation
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId, CorrelationId = correlationId,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,

View File

@@ -39,6 +39,12 @@ public class DatabaseCachedWriteEmissionTests
private const string InstanceName = "Plant.Pump42"; private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:WriteAudit"; private const string SourceScript = "ScriptActor:WriteAudit";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper( private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway, IDatabaseGateway gateway,
ICachedCallTelemetryForwarder? forwarder) ICachedCallTelemetryForwarder? forwarder)
@@ -47,9 +53,10 @@ public class DatabaseCachedWriteEmissionTests
gateway, gateway,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep // Audit Log #23: the per-execution id stamped into ExecutionId on
// CorrelationId = TrackedOperationId, so any value works here. // every script-side row. Cached rows keep CorrelationId =
Guid.NewGuid(), // TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
siteId: SiteId, siteId: SiteId,
sourceScript: SourceScript, sourceScript: SourceScript,
cachedForwarder: forwarder); cachedForwarder: forwarder);
@@ -79,7 +86,10 @@ public class DatabaseCachedWriteEmissionTests
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind); Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status); Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("myDb", packet.Audit.Target); 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(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId); Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("DbOutbound", packet.Operational.Channel); Assert.Equal("DbOutbound", packet.Operational.Channel);

View File

@@ -49,27 +49,27 @@ public class DatabaseSyncEmissionTests
private const string ConnectionName = "machineData"; private const string ConnectionName = "machineData";
/// <summary> /// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the /// Audit Log #23: a fixed per-execution id used by the default
/// default <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/> /// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
/// overload so assertions can compare against a known value. /// overload so assertions can compare against a known value.
/// </summary> /// </summary>
private static readonly Guid TestCorrelationId = Guid.NewGuid(); private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper( private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway, IDatabaseGateway gateway,
IAuditWriter? auditWriter) IAuditWriter? auditWriter)
=> CreateHelper(gateway, auditWriter, TestCorrelationId); => CreateHelper(gateway, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.DatabaseHelper CreateHelper( private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway, IDatabaseGateway gateway,
IAuditWriter? auditWriter, IAuditWriter? auditWriter,
Guid correlationId) Guid executionId)
{ {
return new ScriptRuntimeContext.DatabaseHelper( return new ScriptRuntimeContext.DatabaseHelper(
gateway, gateway,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
correlationId, executionId,
auditWriter: auditWriter, auditWriter: auditWriter,
siteId: SiteId, siteId: SiteId,
sourceScript: SourceScript, sourceScript: SourceScript,
@@ -282,14 +282,16 @@ public class DatabaseSyncEmissionTests
Assert.Equal(SourceScript, evt.SourceScript); Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity. // Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor); Assert.Equal(SourceScript, evt.Actor);
// Audit Log #23: the sync DbWrite row now carries the execution-wide // Audit Log #23: the sync DbWrite row carries the per-execution id the
// correlation id the helper was constructed with. // helper was constructed with in ExecutionId. CorrelationId is null —
Assert.Equal(TestCorrelationId, evt.CorrelationId); // a sync one-shot call has no operation lifecycle.
Assert.Equal(TestExecutionId, evt.ExecutionId);
Assert.Null(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.EventId); Assert.NotEqual(Guid.Empty, evt.EventId);
} }
[Fact] [Fact]
public async Task SyncDbWrite_StampsExecutionCorrelationId() public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId()
{ {
using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared"); using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _); var inner = NewInMemoryDb(out var _);
@@ -298,16 +300,18 @@ public class DatabaseSyncEmissionTests
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>())) .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner); .ReturnsAsync(inner);
var writer = new CapturingAuditWriter(); 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 conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')"; cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')";
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events); 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] [Fact]

View File

@@ -16,13 +16,15 @@ namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <list type="bullet"> /// <list type="bullet">
/// <item><description> /// <item><description>
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/> /// 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. /// executions) a fresh, non-empty id is minted and stamped on the emitted rows.
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync /// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
/// <c>Database</c> write performed through ONE context share a single /// <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> /// </description></item>
/// </list> /// </list>
/// </summary> /// </summary>
@@ -53,14 +55,14 @@ public class ExecutionCorrelationContextTests
/// system client, database gateway and audit writer the cross-helper test /// system client, database gateway and audit writer the cross-helper test
/// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the /// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
/// integration helpers (ExternalSystem / Database) never touch them — and /// 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. /// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
/// </summary> /// </summary>
private static ScriptRuntimeContext CreateContext( private static ScriptRuntimeContext CreateContext(
IExternalSystemClient? externalSystemClient, IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway, IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter, IAuditWriter? auditWriter,
Guid? auditCorrelationId = null) Guid? executionId = null)
{ {
var compilationService = new ScriptCompilationService( var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance); NullLogger<ScriptCompilationService>.Instance);
@@ -85,7 +87,7 @@ public class ExecutionCorrelationContextTests
auditWriter: auditWriter, auditWriter: auditWriter,
operationTrackingStore: null, operationTrackingStore: null,
cachedForwarder: null, cachedForwarder: null,
auditCorrelationId: auditCorrelationId); executionId: executionId);
} }
/// <summary> /// <summary>
@@ -113,9 +115,9 @@ public class ExecutionCorrelationContextTests
} }
[Fact] [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 // `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id
// branch every other audit test bypasses by passing an explicit id). // branch every other audit test bypasses by passing an explicit id).
var client = new Mock<IExternalSystemClient>(); var client = new Mock<IExternalSystemClient>();
@@ -128,17 +130,19 @@ public class ExecutionCorrelationContextTests
await context.ExternalSystem.Call("ERP", "GetOrder"); await context.ExternalSystem.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events); var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.CorrelationId); Assert.NotNull(evt.ExecutionId);
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value); Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
// A sync one-shot call has no operation lifecycle — CorrelationId is null.
Assert.Null(evt.CorrelationId);
} }
[Fact] [Fact]
public async Task SameContext_ApiCallAndDbWrite_ShareTheSameCorrelationId() public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId()
{ {
// The execution-wide contract: an ExternalSystem.Call AND a sync // The execution-wide contract: an ExternalSystem.Call AND a sync
// Database write performed through ONE ScriptRuntimeContext must both // Database write performed through ONE ScriptRuntimeContext must both
// carry the same execution correlation id, so an audit reader can tie // carry the same ExecutionId, so an audit reader can tie every
// every trust-boundary action from one script run together. // trust-boundary action from one script run together.
using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared"); using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out var _); var innerDb = NewInMemoryDb(out var _);
@@ -170,10 +174,13 @@ public class ExecutionCorrelationContextTests
var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound); var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound); var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);
Assert.NotNull(apiRow.CorrelationId); Assert.NotNull(apiRow.ExecutionId);
Assert.NotEqual(Guid.Empty, apiRow.CorrelationId!.Value); Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value);
// The ApiCall row and the DbWrite row, emitted by two different helpers // The ApiCall row and the DbWrite row, emitted by two different helpers
// resolved off one context, carry the identical execution correlation id. // resolved off one context, carry the identical ExecutionId.
Assert.Equal(apiRow.CorrelationId, dbRow.CorrelationId); Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId);
// Both are sync one-shot calls — neither carries a CorrelationId.
Assert.Null(apiRow.CorrelationId);
Assert.Null(dbRow.CorrelationId);
} }
} }

View File

@@ -41,6 +41,12 @@ public class ExternalSystemCachedCallEmissionTests
private const string InstanceName = "Plant.Pump42"; private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure"; private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client, IExternalSystemClient client,
ICachedCallTelemetryForwarder? forwarder) ICachedCallTelemetryForwarder? forwarder)
@@ -49,9 +55,10 @@ public class ExternalSystemCachedCallEmissionTests
client, client,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep // Audit Log #23: the per-execution id stamped into ExecutionId on
// CorrelationId = TrackedOperationId, so any value works here. // every script-side row. Cached rows keep CorrelationId =
Guid.NewGuid(), // TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
auditWriter: null, auditWriter: null,
siteId: SiteId, siteId: SiteId,
sourceScript: SourceScript, sourceScript: SourceScript,
@@ -83,7 +90,10 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind); Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status); Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("ERP.GetOrder", packet.Audit.Target); 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(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState); Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState);
// Operational mirror — same id, Submitted, RetryCount 0, not terminal. // Operational mirror — same id, Submitted, RetryCount 0, not terminal.
@@ -298,6 +308,7 @@ public class ExternalSystemCachedCallEmissionTests
var submit = forwarder.Telemetry[0]; var submit = forwarder.Telemetry[0];
Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind); Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, submit.Audit.Status); Assert.Equal(AuditStatus.Submitted, submit.Audit.Status);
Assert.Equal(TestExecutionId, submit.Audit.ExecutionId);
Assert.Equal(trackedId, submit.Operational.TrackedOperationId); Assert.Equal(trackedId, submit.Operational.TrackedOperationId);
Assert.Null(submit.Operational.TerminalAtUtc); Assert.Null(submit.Operational.TerminalAtUtc);
@@ -305,7 +316,10 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel); Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel);
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind); Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status); 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(trackedId.Value, attempted.Audit.CorrelationId);
Assert.Equal(TestExecutionId, attempted.Audit.ExecutionId);
Assert.Equal("ERP.GetOrder", attempted.Audit.Target); Assert.Equal("ERP.GetOrder", attempted.Audit.Target);
Assert.Equal(trackedId, attempted.Operational.TrackedOperationId); Assert.Equal(trackedId, attempted.Operational.TrackedOperationId);
Assert.Equal("Attempted", attempted.Operational.Status); Assert.Equal("Attempted", attempted.Operational.Status);
@@ -316,6 +330,7 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind); Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status); Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId); Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId);
Assert.Equal(TestExecutionId, resolve.Audit.ExecutionId);
Assert.Equal(trackedId, resolve.Operational.TrackedOperationId); Assert.Equal(trackedId, resolve.Operational.TrackedOperationId);
Assert.Equal("Delivered", resolve.Operational.Status); Assert.Equal("Delivered", resolve.Operational.Status);
// Terminal row carries TerminalAtUtc. // Terminal row carries TerminalAtUtc.

View File

@@ -46,27 +46,27 @@ public class ExternalSystemCallAuditEmissionTests
private const string SourceScript = "ScriptActor:CheckPressure"; private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary> /// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the /// Audit Log #23: a fixed per-execution id used by the default
/// default <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/> /// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// overload so assertions can compare against a known value. /// overload so assertions can compare against a known value.
/// </summary> /// </summary>
private static readonly Guid TestCorrelationId = Guid.NewGuid(); private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client, IExternalSystemClient client,
IAuditWriter? auditWriter) IAuditWriter? auditWriter)
=> CreateHelper(client, auditWriter, TestCorrelationId); => CreateHelper(client, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client, IExternalSystemClient client,
IAuditWriter? auditWriter, IAuditWriter? auditWriter,
Guid correlationId) Guid executionId)
{ {
return new ScriptRuntimeContext.ExternalSystemHelper( return new ScriptRuntimeContext.ExternalSystemHelper(
client, client,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
correlationId, executionId,
auditWriter, auditWriter,
SiteId, SiteId,
SourceScript); SourceScript);
@@ -225,47 +225,54 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(SourceScript, evt.SourceScript); Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity. // Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor); Assert.Equal(SourceScript, evt.Actor);
// Audit Log #23: the sync ApiCall row now carries the execution-wide // Audit Log #23: the sync ApiCall row carries the per-execution id the
// correlation id the helper was constructed with. // helper was constructed with in ExecutionId. CorrelationId is null —
Assert.Equal(TestCorrelationId, evt.CorrelationId); // a sync one-shot call has no operation lifecycle.
Assert.Equal(TestExecutionId, evt.ExecutionId);
Assert.Null(evt.CorrelationId);
} }
[Fact] [Fact]
public async Task Call_SyncApiCall_StampsExecutionCorrelationId() public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId()
{ {
var client = new Mock<IExternalSystemClient>(); var client = new Mock<IExternalSystemClient>();
client client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>())) .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null)); .ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter(); 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", "GetOrder");
var evt = Assert.Single(writer.Events); 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] [Fact]
public async Task Call_TwoCallsOnSameHelper_ShareTheSameCorrelationId() public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId()
{ {
var client = new Mock<IExternalSystemClient>(); var client = new Mock<IExternalSystemClient>();
client client
.Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>())) .Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null)); .ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter(); 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", "GetOrder");
await helper.Call("ERP", "GetCustomer"); await helper.Call("ERP", "GetCustomer");
Assert.Equal(2, writer.Events.Count); Assert.Equal(2, writer.Events.Count);
// Both sync ApiCall rows from one execution carry the same id. // Both sync ApiCall rows from one execution carry the same ExecutionId.
Assert.Equal(correlationId, writer.Events[0].CorrelationId); Assert.Equal(executionId, writer.Events[0].ExecutionId);
Assert.Equal(correlationId, writer.Events[1].CorrelationId); Assert.Equal(executionId, writer.Events[1].ExecutionId);
Assert.Equal(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId); 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] [Fact]

View File

@@ -69,7 +69,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
"Plant.Pump3", "Plant.Pump3",
sourceScript, sourceScript,
TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3),
NullLogger.Instance); NullLogger.Instance,
Guid.NewGuid());
} }
[Fact] [Fact]

View File

@@ -53,6 +53,12 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
private const string Subject = "Pump alarm"; private const string Subject = "Pump alarm";
private const string Body = "Pump 3 tripped"; private const string Body = "Pump 3 tripped";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the NotifySend test can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private readonly SqliteConnection _keepAlive; private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _saf; private readonly StoreAndForwardService _saf;
@@ -102,6 +108,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
sourceScript, sourceScript,
TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3),
NullLogger.Instance, NullLogger.Instance,
TestExecutionId,
auditWriter); auditWriter);
} }
@@ -214,12 +221,14 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
// NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char // NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char
// hex form, which Guid.TryParse accepts. The audit row's CorrelationId // 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), Assert.True(Guid.TryParse(notificationId, out var expected),
$"NotificationId '{notificationId}' should be a parseable Guid"); $"NotificationId '{notificationId}' should be a parseable Guid");
var evt = writer.Events[0]; var evt = writer.Events[0];
Assert.NotNull(evt.CorrelationId); Assert.NotNull(evt.CorrelationId);
Assert.Equal(expected, evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId);
Assert.Equal(TestExecutionId, evt.ExecutionId);
} }
[Fact] [Fact]