feat(siteruntime): Database.CachedWrite emits combined telemetry + S&F audit bridge (#23 M3)
Wire the M3 cached-call audit pipeline end-to-end for the database
channel and close the loop between the S&F lifecycle observer and the
site-side dual emitter.
* DatabaseCachedWriteEmissionTests covers Database.CachedWrite (set up
in Bundle E3): mints a TrackedOperationId, emits one CachedSubmit
packet on DbOutbound, threads the id into IDatabaseGateway, and is
best-effort on a thrown forwarder. Mirrors ExternalSystem.CachedCall
coverage from E3.
* CachedCallLifecycleBridge (new) implements ICachedCallLifecycleObserver
and lives alongside CachedCallTelemetryForwarder. The bridge ingests
per-attempt notifications from the S&F retry loop and fans them out
to the forwarder:
- TransientFailure -> 1 Attempted row
- Delivered -> Attempted + CachedResolve(Delivered)
- PermanentFailure -> Attempted + CachedResolve(Parked)
- ParkedMaxRetries -> Attempted + CachedResolve(Parked)
Channel string -> AuditKind mapping (ApiOutbound->ApiCallCached,
DbOutbound->DbWriteCached). Best-effort top-level catch swallows any
unexpected throw so the S&F retry bookkeeping is never disturbed.
* Bridge tests (7) cover all four outcomes, channel mapping, provenance
propagation, and the no-throw-on-forwarder-failure contract.
Bundle F (Host registration) will instantiate the bridge and inject it
into StoreAndForwardService.cachedCallObserver, closing the wiring path
end-to-end.
Bundle E task E6.
This commit is contained in:
@@ -0,0 +1,205 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Messages.Integration;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Site.Telemetry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt
|
||||||
|
/// notifications from the store-and-forward retry loop into one (or two)
|
||||||
|
/// <see cref="CachedCallTelemetry"/> packets and pushes them through
|
||||||
|
/// <see cref="ICachedCallTelemetryForwarder"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The S&F loop's <see cref="ICachedCallLifecycleObserver"/> reports a
|
||||||
|
/// single coarse outcome per attempt; the audit pipeline however models the
|
||||||
|
/// lifecycle as TWO rows on terminal outcomes — an <c>Attempted</c>
|
||||||
|
/// (<see cref="AuditKind.ApiCallCached"/> / <see cref="AuditKind.DbWriteCached"/>)
|
||||||
|
/// row capturing the per-attempt mechanics, plus a <see cref="AuditKind.CachedResolve"/>
|
||||||
|
/// row marking the terminal state for downstream consumers. The bridge fans
|
||||||
|
/// out per outcome:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>TransientFailure</c> -> one Attempted(Failed) row.</description></item>
|
||||||
|
/// <item><description><c>Delivered</c> -> Attempted(Delivered) + CachedResolve(Delivered).</description></item>
|
||||||
|
/// <item><description><c>PermanentFailure</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
|
||||||
|
/// <item><description><c>ParkedMaxRetries</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Best-effort emission (alog.md §7):</b> the bridge itself never throws;
|
||||||
|
/// the underlying forwarder swallows + logs its own failures.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
||||||
|
{
|
||||||
|
private readonly ICachedCallTelemetryForwarder _forwarder;
|
||||||
|
private readonly ILogger<CachedCallLifecycleBridge> _logger;
|
||||||
|
|
||||||
|
public CachedCallLifecycleBridge(
|
||||||
|
ICachedCallTelemetryForwarder forwarder,
|
||||||
|
ILogger<CachedCallLifecycleBridge> logger)
|
||||||
|
{
|
||||||
|
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task OnAttemptCompletedAsync(
|
||||||
|
CachedCallAttemptContext context, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EmitAttemptedAsync(context, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (IsTerminal(context.Outcome))
|
||||||
|
{
|
||||||
|
await EmitResolveAsync(context, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Defensive — both EmitX paths call the forwarder which is itself
|
||||||
|
// best-effort. A throw here is unexpected, but the alog.md §7
|
||||||
|
// contract requires we never propagate.
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"CachedCallLifecycleBridge: unexpected throw for {TrackedOperationId} (Outcome {Outcome})",
|
||||||
|
context.TrackedOperationId, context.Outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EmitAttemptedAsync(CachedCallAttemptContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Per-attempt row: kind discriminates channel, status carries the
|
||||||
|
// per-attempt result.
|
||||||
|
var kind = ChannelToAttemptKind(context.Channel);
|
||||||
|
var status = context.Outcome == CachedCallAttemptOutcome.Delivered
|
||||||
|
? AuditStatus.Attempted
|
||||||
|
: AuditStatus.Attempted;
|
||||||
|
// (Note: per the M3 brief and alog.md §4 the per-attempt row always
|
||||||
|
// carries Status=Attempted; success vs. failure is captured by the
|
||||||
|
// companion HttpStatus / ErrorMessage fields, NOT by flipping the
|
||||||
|
// status. CachedResolve carries the terminal Status.)
|
||||||
|
|
||||||
|
var packet = BuildPacket(
|
||||||
|
context,
|
||||||
|
kind: kind,
|
||||||
|
status: status,
|
||||||
|
// Operational status mirror — for the per-attempt row the
|
||||||
|
// operational state is the running status; the bridge always
|
||||||
|
// writes "Attempted" so reconciliation can't roll back.
|
||||||
|
operationalStatus: "Attempted",
|
||||||
|
terminalAtUtc: null,
|
||||||
|
lastError: context.LastError,
|
||||||
|
httpStatus: context.HttpStatus);
|
||||||
|
|
||||||
|
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EmitResolveAsync(CachedCallAttemptContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (auditStatus, operationalStatus) = TerminalOutcomeToStatuses(context.Outcome);
|
||||||
|
|
||||||
|
var packet = BuildPacket(
|
||||||
|
context,
|
||||||
|
kind: AuditKind.CachedResolve,
|
||||||
|
status: auditStatus,
|
||||||
|
operationalStatus: operationalStatus,
|
||||||
|
terminalAtUtc: context.OccurredAtUtc,
|
||||||
|
lastError: context.LastError,
|
||||||
|
httpStatus: context.HttpStatus);
|
||||||
|
|
||||||
|
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CachedCallTelemetry BuildPacket(
|
||||||
|
CachedCallAttemptContext context,
|
||||||
|
AuditKind kind,
|
||||||
|
AuditStatus status,
|
||||||
|
string operationalStatus,
|
||||||
|
DateTime? terminalAtUtc,
|
||||||
|
string? lastError,
|
||||||
|
int? httpStatus)
|
||||||
|
{
|
||||||
|
var channel = ChannelStringToEnum(context.Channel);
|
||||||
|
|
||||||
|
return new CachedCallTelemetry(
|
||||||
|
Audit: new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
||||||
|
Channel = channel,
|
||||||
|
Kind = kind,
|
||||||
|
CorrelationId = context.TrackedOperationId.Value,
|
||||||
|
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||||
|
SourceInstanceId = context.SourceInstanceId,
|
||||||
|
SourceScript = null, // Not threaded through S&F; left null on retry-loop rows.
|
||||||
|
Target = context.Target,
|
||||||
|
Status = status,
|
||||||
|
HttpStatus = httpStatus,
|
||||||
|
DurationMs = context.DurationMs,
|
||||||
|
ErrorMessage = lastError,
|
||||||
|
ForwardState = AuditForwardState.Pending,
|
||||||
|
},
|
||||||
|
Operational: new SiteCallOperational(
|
||||||
|
TrackedOperationId: context.TrackedOperationId,
|
||||||
|
Channel: context.Channel,
|
||||||
|
Target: context.Target,
|
||||||
|
SourceSite: context.SourceSite,
|
||||||
|
Status: operationalStatus,
|
||||||
|
RetryCount: context.RetryCount,
|
||||||
|
LastError: lastError,
|
||||||
|
HttpStatus: httpStatus,
|
||||||
|
CreatedAtUtc: DateTime.SpecifyKind(context.CreatedAtUtc, DateTimeKind.Utc),
|
||||||
|
UpdatedAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
||||||
|
TerminalAtUtc: terminalAtUtc is null
|
||||||
|
? null
|
||||||
|
: DateTime.SpecifyKind(terminalAtUtc.Value, DateTimeKind.Utc)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditKind ChannelToAttemptKind(string channel) => channel switch
|
||||||
|
{
|
||||||
|
"ApiOutbound" => AuditKind.ApiCallCached,
|
||||||
|
"DbOutbound" => AuditKind.DbWriteCached,
|
||||||
|
// Defensive default — the S&F observer is filtered to cached-call
|
||||||
|
// categories so this branch shouldn't fire in practice.
|
||||||
|
_ => AuditKind.ApiCallCached,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static AuditChannel ChannelStringToEnum(string channel) => channel switch
|
||||||
|
{
|
||||||
|
"ApiOutbound" => AuditChannel.ApiOutbound,
|
||||||
|
"DbOutbound" => AuditChannel.DbOutbound,
|
||||||
|
_ => AuditChannel.ApiOutbound,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (AuditStatus auditStatus, string operationalStatus) TerminalOutcomeToStatuses(
|
||||||
|
CachedCallAttemptOutcome outcome) => outcome switch
|
||||||
|
{
|
||||||
|
CachedCallAttemptOutcome.Delivered =>
|
||||||
|
(AuditStatus.Delivered, "Delivered"),
|
||||||
|
CachedCallAttemptOutcome.PermanentFailure =>
|
||||||
|
(AuditStatus.Parked, "Parked"),
|
||||||
|
CachedCallAttemptOutcome.ParkedMaxRetries =>
|
||||||
|
(AuditStatus.Parked, "Parked"),
|
||||||
|
// TransientFailure isn't terminal — see IsTerminal — but the switch
|
||||||
|
// is exhaustive so we route it through Failed for safety.
|
||||||
|
CachedCallAttemptOutcome.TransientFailure =>
|
||||||
|
(AuditStatus.Failed, "Failed"),
|
||||||
|
_ => (AuditStatus.Failed, "Failed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsTerminal(CachedCallAttemptOutcome outcome) => outcome switch
|
||||||
|
{
|
||||||
|
CachedCallAttemptOutcome.Delivered => true,
|
||||||
|
CachedCallAttemptOutcome.PermanentFailure => true,
|
||||||
|
CachedCallAttemptOutcome.ParkedMaxRetries => true,
|
||||||
|
CachedCallAttemptOutcome.TransientFailure => false,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Messages.Integration;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Site.Telemetry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle E Tasks E4/E5 bridge tests. The bridge ingests
|
||||||
|
/// <see cref="CachedCallAttemptContext"/> notifications from the S&F
|
||||||
|
/// retry loop and routes them through <see cref="ICachedCallTelemetryForwarder"/>
|
||||||
|
/// as one or two <see cref="CachedCallTelemetry"/> packets:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Per-attempt: one <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted row.</description></item>
|
||||||
|
/// <item><description>Terminal (Delivered/PermanentFailure/ParkedMaxRetries): adds a CachedResolve row carrying the terminal Status.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class CachedCallLifecycleBridgeTests
|
||||||
|
{
|
||||||
|
private readonly ICachedCallTelemetryForwarder _forwarder = Substitute.For<ICachedCallTelemetryForwarder>();
|
||||||
|
private readonly TrackedOperationId _id = TrackedOperationId.New();
|
||||||
|
|
||||||
|
private CachedCallLifecycleBridge CreateSut() => new(
|
||||||
|
_forwarder, NullLogger<CachedCallLifecycleBridge>.Instance);
|
||||||
|
|
||||||
|
private CachedCallAttemptContext Ctx(
|
||||||
|
CachedCallAttemptOutcome outcome,
|
||||||
|
string channel = "ApiOutbound",
|
||||||
|
int retryCount = 1,
|
||||||
|
string? lastError = null,
|
||||||
|
int? httpStatus = null) =>
|
||||||
|
new(
|
||||||
|
TrackedOperationId: _id,
|
||||||
|
Channel: channel,
|
||||||
|
Target: "ERP.GetOrder",
|
||||||
|
SourceSite: "site-77",
|
||||||
|
Outcome: outcome,
|
||||||
|
RetryCount: retryCount,
|
||||||
|
LastError: lastError,
|
||||||
|
HttpStatus: httpStatus,
|
||||||
|
CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
|
||||||
|
OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
|
DurationMs: 42,
|
||||||
|
SourceInstanceId: "Plant.Pump42");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
|
||||||
|
{
|
||||||
|
var captured = new List<CachedCallTelemetry>();
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(
|
||||||
|
CachedCallAttemptOutcome.TransientFailure,
|
||||||
|
retryCount: 2,
|
||||||
|
lastError: "HTTP 503",
|
||||||
|
httpStatus: 503));
|
||||||
|
|
||||||
|
var packet = Assert.Single(captured);
|
||||||
|
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||||
|
Assert.Equal(503, packet.Audit.HttpStatus);
|
||||||
|
Assert.Equal("HTTP 503", packet.Audit.ErrorMessage);
|
||||||
|
Assert.Equal(_id.Value, packet.Audit.CorrelationId);
|
||||||
|
Assert.Equal("Attempted", packet.Operational.Status);
|
||||||
|
Assert.Equal(2, packet.Operational.RetryCount);
|
||||||
|
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
|
||||||
|
{
|
||||||
|
var captured = new List<CachedCallTelemetry>();
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
|
||||||
|
|
||||||
|
Assert.Equal(2, captured.Count);
|
||||||
|
|
||||||
|
var attempted = captured[0];
|
||||||
|
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
|
||||||
|
Assert.Equal("Attempted", attempted.Operational.Status);
|
||||||
|
Assert.Null(attempted.Operational.TerminalAtUtc);
|
||||||
|
|
||||||
|
var resolve = captured[1];
|
||||||
|
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
|
||||||
|
Assert.Equal("Delivered", resolve.Operational.Status);
|
||||||
|
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
||||||
|
Assert.Equal(_id.Value, resolve.Audit.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
|
||||||
|
{
|
||||||
|
var captured = new List<CachedCallTelemetry>();
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(
|
||||||
|
CachedCallAttemptOutcome.PermanentFailure,
|
||||||
|
lastError: "Permanent failure (handler returned false)"));
|
||||||
|
|
||||||
|
Assert.Equal(2, captured.Count);
|
||||||
|
Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.Kind);
|
||||||
|
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
|
||||||
|
Assert.Equal("Parked", captured[1].Operational.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
|
||||||
|
{
|
||||||
|
var captured = new List<CachedCallTelemetry>();
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries));
|
||||||
|
|
||||||
|
Assert.Equal(2, captured.Count);
|
||||||
|
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
|
||||||
|
{
|
||||||
|
var captured = new List<CachedCallTelemetry>();
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(
|
||||||
|
CachedCallAttemptOutcome.Delivered, channel: "DbOutbound"));
|
||||||
|
|
||||||
|
Assert.Equal(2, captured.Count);
|
||||||
|
Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.Kind);
|
||||||
|
Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.Channel);
|
||||||
|
Assert.Equal("DbOutbound", captured[0].Operational.Channel);
|
||||||
|
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
||||||
|
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.Channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BridgeDoesNotThrow_WhenForwarderThrows()
|
||||||
|
{
|
||||||
|
_forwarder
|
||||||
|
.ForwardAsync(Arg.Any<CachedCallTelemetry>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromException(new InvalidOperationException("forwarder down")));
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
// Must not throw — best-effort emission.
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BridgePopulatesProvenance_FromAttemptContext()
|
||||||
|
{
|
||||||
|
CachedCallTelemetry? captured = null;
|
||||||
|
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.OnAttemptCompletedAsync(Ctx(
|
||||||
|
CachedCallAttemptOutcome.TransientFailure,
|
||||||
|
retryCount: 3,
|
||||||
|
lastError: "transient",
|
||||||
|
httpStatus: 500));
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal("site-77", captured!.Audit.SourceSiteId);
|
||||||
|
Assert.Equal("Plant.Pump42", captured.Audit.SourceInstanceId);
|
||||||
|
Assert.Equal("ERP.GetOrder", captured.Audit.Target);
|
||||||
|
Assert.Equal(42, captured.Audit.DurationMs);
|
||||||
|
Assert.Equal(_id.Value, captured.Audit.CorrelationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
||||||
|
IDatabaseGateway gateway,
|
||||||
|
ICachedCallTelemetryForwarder? forwarder)
|
||||||
|
{
|
||||||
|
return new ScriptRuntimeContext.DatabaseHelper(
|
||||||
|
gateway,
|
||||||
|
InstanceName,
|
||||||
|
NullLogger.Instance,
|
||||||
|
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?>()))
|
||||||
|
.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);
|
||||||
|
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
|
||||||
|
|
||||||
|
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?>()))
|
||||||
|
.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?>()))
|
||||||
|
.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),
|
||||||
|
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?>()))
|
||||||
|
.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),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user