The store-and-forward retry loop emits the per-attempt and terminal cached audit rows (ApiCallCached/DbWriteCached Attempted, CachedResolve) via CachedCallLifecycleBridge from a CachedCallAttemptContext, not from the script context. ExecutionId (and SourceScript) were not threaded through the S&F buffer, so those rows had ExecutionId = null and SourceScript = null. Thread both, additively, from the cached-call enqueue path: - StoreAndForwardMessage gains ExecutionId (Guid?) / SourceScript (string?). - StoreAndForwardStorage adds nullable execution_id / source_script columns via an idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by an older build read back null (back-compat). - StoreAndForwardService.EnqueueAsync gains optional executionId / sourceScript params, stamped onto the buffered message and surfaced on the CachedCallAttemptContext built in the retry loop. - CachedCallAttemptContext gains ExecutionId / SourceScript. - CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ExecutionId and AuditEvent.SourceScript from the context (replacing the hard-coded SourceScript = null and its now-stale comment). - IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync gain optional executionId / sourceScript params; ScriptRuntimeContext's CachedCall / CachedWrite helpers pass _executionId / _sourceScript. Script-side cached rows (CachedSubmit, immediate Attempted+Resolve) are unchanged. All threading is additive — old buffered S&F rows still deserialize and process with the new fields null.
190 lines
7.6 KiB
C#
190 lines
7.6 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.Integration;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.SiteRuntime.Scripts;
|
|
|
|
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// Audit Log #23 — M3 Bundle E (Task E6): every script-initiated
|
|
/// <c>Database.CachedWrite</c> emits exactly one <c>CachedSubmit</c>
|
|
/// combined-telemetry packet at enqueue time on the <c>DbOutbound</c>
|
|
/// channel, returns a fresh <see cref="TrackedOperationId"/>, and threads
|
|
/// the id into the database gateway so the store-and-forward retry loop can
|
|
/// emit per-attempt + terminal telemetry under the same id.
|
|
/// </summary>
|
|
public class DatabaseCachedWriteEmissionTests
|
|
{
|
|
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
|
|
{
|
|
public List<CachedCallTelemetry> Telemetry { get; } = new();
|
|
public Exception? ThrowOnForward { get; set; }
|
|
|
|
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
|
{
|
|
if (ThrowOnForward != null)
|
|
{
|
|
return Task.FromException(ThrowOnForward);
|
|
}
|
|
Telemetry.Add(telemetry);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private const string SiteId = "site-77";
|
|
private const string InstanceName = "Plant.Pump42";
|
|
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(
|
|
IDatabaseGateway gateway,
|
|
ICachedCallTelemetryForwarder? forwarder)
|
|
{
|
|
return new ScriptRuntimeContext.DatabaseHelper(
|
|
gateway,
|
|
InstanceName,
|
|
NullLogger.Instance,
|
|
// 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedWrite_EmitsSubmitTelemetry_OnEnqueue_KindCachedSubmit_ChannelDbOutbound()
|
|
{
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.CachedWriteAsync(
|
|
"myDb", "INSERT INTO t VALUES (1)",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
|
.Returns(Task.CompletedTask);
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(gateway.Object, forwarder);
|
|
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
|
|
|
|
Assert.NotEqual(default, trackedId);
|
|
var packet = Assert.Single(forwarder.Telemetry);
|
|
|
|
Assert.Equal(AuditChannel.DbOutbound, packet.Audit.Channel);
|
|
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);
|
|
Assert.Equal("myDb", packet.Operational.Target);
|
|
Assert.Equal(SiteId, packet.Operational.SourceSite);
|
|
Assert.Equal("Submitted", packet.Operational.Status);
|
|
Assert.Equal(0, packet.Operational.RetryCount);
|
|
Assert.Null(packet.Operational.TerminalAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedWrite_ProvenancePopulated()
|
|
{
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.CachedWriteAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(),
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
It.IsAny<string?>(),
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
|
.Returns(Task.CompletedTask);
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(gateway.Object, forwarder);
|
|
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
|
|
|
|
var packet = Assert.Single(forwarder.Telemetry);
|
|
Assert.Equal(SiteId, packet.Audit.SourceSiteId);
|
|
Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
|
|
Assert.Equal(SourceScript, packet.Audit.SourceScript);
|
|
Assert.Equal(SiteId, packet.Operational.SourceSite);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway()
|
|
{
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.CachedWriteAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(),
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
It.IsAny<string?>(),
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
|
.Returns(Task.CompletedTask);
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(gateway.Object, forwarder);
|
|
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
|
|
|
|
Assert.NotEqual(default, trackedId);
|
|
gateway.Verify(g => g.CachedWriteAsync(
|
|
"myDb", "INSERT INTO t VALUES (1)",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
trackedId,
|
|
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId()
|
|
{
|
|
var gateway = new Mock<IDatabaseGateway>();
|
|
gateway
|
|
.Setup(g => g.CachedWriteAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(),
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
It.IsAny<string?>(),
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
|
.Returns(Task.CompletedTask);
|
|
var forwarder = new CapturingForwarder
|
|
{
|
|
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
|
|
};
|
|
|
|
var helper = CreateHelper(gateway.Object, forwarder);
|
|
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
|
|
|
|
Assert.NotEqual(default, trackedId);
|
|
gateway.Verify(g => g.CachedWriteAsync(
|
|
"myDb", "INSERT INTO t VALUES (1)",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
trackedId,
|
|
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
|
Times.Once);
|
|
}
|
|
}
|