From 047988e4c824406ba307223feeba2b130155b96c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 14:55:17 -0400 Subject: [PATCH] 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. --- .../Telemetry/CachedCallLifecycleBridge.cs | 205 ++++++++++++++++++ .../CachedCallLifecycleBridgeTests.cs | 187 ++++++++++++++++ .../DatabaseCachedWriteEmissionTests.cs | 170 +++++++++++++++ 3 files changed, 562 insertions(+) create mode 100644 src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs create mode 100644 tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs create mode 100644 tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs new file mode 100644 index 0000000..d61c3fb --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs @@ -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; + +/// +/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt +/// notifications from the store-and-forward retry loop into one (or two) +/// packets and pushes them through +/// . +/// +/// +/// +/// The S&F loop's reports a +/// single coarse outcome per attempt; the audit pipeline however models the +/// lifecycle as TWO rows on terminal outcomes — an Attempted +/// ( / ) +/// row capturing the per-attempt mechanics, plus a +/// row marking the terminal state for downstream consumers. The bridge fans +/// out per outcome: +/// +/// +/// TransientFailure -> one Attempted(Failed) row. +/// Delivered -> Attempted(Delivered) + CachedResolve(Delivered). +/// PermanentFailure -> Attempted(Failed) + CachedResolve(Parked). +/// ParkedMaxRetries -> Attempted(Failed) + CachedResolve(Parked). +/// +/// +/// Best-effort emission (alog.md §7): the bridge itself never throws; +/// the underlying forwarder swallows + logs its own failures. +/// +/// +public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver +{ + private readonly ICachedCallTelemetryForwarder _forwarder; + private readonly ILogger _logger; + + public CachedCallLifecycleBridge( + ICachedCallTelemetryForwarder forwarder, + ILogger logger) + { + _forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + 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, + }; +} diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs new file mode 100644 index 0000000..97cbb84 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs @@ -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; + +/// +/// Bundle E Tasks E4/E5 bridge tests. The bridge ingests +/// notifications from the S&F +/// retry loop and routes them through +/// as one or two packets: +/// +/// Per-attempt: one ApiCallCached/DbWriteCached Attempted row. +/// Terminal (Delivered/PermanentFailure/ParkedMaxRetries): adds a CachedResolve row carrying the terminal Status. +/// +/// +public class CachedCallLifecycleBridgeTests +{ + private readonly ICachedCallTelemetryForwarder _forwarder = Substitute.For(); + private readonly TrackedOperationId _id = TrackedOperationId.New(); + + private CachedCallLifecycleBridge CreateSut() => new( + _forwarder, NullLogger.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(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .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(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .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(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .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(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .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(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .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(), Arg.Any()) + .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(t => captured = t), Arg.Any()) + .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); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs new file mode 100644 index 0000000..991bbaf --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -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; + +/// +/// Audit Log #23 — M3 Bundle E (Task E6): every script-initiated +/// Database.CachedWrite emits exactly one CachedSubmit +/// combined-telemetry packet at enqueue time on the DbOutbound +/// channel, returns a fresh , 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. +/// +public class DatabaseCachedWriteEmissionTests +{ + private sealed class CapturingForwarder : ICachedCallTelemetryForwarder + { + public List 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(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny())) + .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(); + gateway + .Setup(g => g.CachedWriteAsync( + It.IsAny(), It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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(); + gateway + .Setup(g => g.CachedWriteAsync( + It.IsAny(), It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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?>(), + InstanceName, + It.IsAny(), + trackedId), + Times.Once); + } + + [Fact] + public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId() + { + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + It.IsAny(), It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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?>(), + InstanceName, + It.IsAny(), + trackedId), + Times.Once); + } +}