perf(audit-log): cached-call forwarder enqueues the audit row instead of awaiting its commit (194 ms -> 25 ms)
Measuring the tracking-store queue on the rig moved nothing: the DB-done -> Notify.Send gap stayed at 108 ms. The cost was the audit half of CachedCallTelemetryForwarder: SqliteAuditWriter.WriteAsync enqueues synchronously but returns the BATCH completion, and the writer loop coalesces for FlushIntervalMs (50 ms) before it commits, so each awaited audit row cost ~50 ms + fsync and a cached call awaited three (submit, attempted, resolve) on the script thread. Every other script-path emitter already fired-and-observed; only this forwarder awaited. TryEmitAuditAsync now hands the row to the writer and observes the completion on a fault-logging continuation (the FallbackAuditWriter ring-buffer fallback still runs inside that task). Best-effort contract unchanged; per-store FIFO unchanged; nothing joins the audit and tracking stores by commit order. Rig, FlowOutboundDemo, 5-run medians: inbound 194 -> 25 ms, site script run 176 -> 17 ms, CachedWrite submit->done 61 -> 11 ms, DB done -> Notify.Send 108 -> 0.1 ms. The tracking queue stays (three fewer fsyncs per call, batched). Docs corrected to attribute the cost to the flush wait, with the trap recorded in CLAUDE.md and Component-SiteRuntime.md. Tests: ForwardAsync returns while the audit commit is still pending (never-completing writer task) and still records tracking; a late commit fault is swallowed. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
+41
@@ -193,6 +193,47 @@ public class CachedCallTelemetryForwarderTests
|
||||
default, default!, default, default, default, default);
|
||||
}
|
||||
|
||||
// ── 2026-09-06: the audit row is enqueued, never awaited to commit ─────
|
||||
// Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending()
|
||||
{
|
||||
// SqliteAuditWriter.WriteAsync returns the batch completion, which lands only
|
||||
// after FlushIntervalMs + fsync. A never-completing task models "not flushed
|
||||
// yet": ForwardAsync must still return and still record the tracking half.
|
||||
var neverFlushed = new TaskCompletionSource();
|
||||
_writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(neverFlushed.Task);
|
||||
|
||||
var sut = CreateSut();
|
||||
var forward = sut.ForwardAsync(ResolvePacket(), CancellationToken.None);
|
||||
|
||||
var finished = await Task.WhenAny(forward, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(forward, finished);
|
||||
await _writer.Received(1).WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
|
||||
await _tracking.Received(1).RecordTerminalAsync(
|
||||
_id, "Delivered", Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardAsync_AuditCommitFaultsLater_IsSwallowed()
|
||||
{
|
||||
var lateFault = new TaskCompletionSource();
|
||||
_writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(lateFault.Task);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None); // returns before the fault
|
||||
lateFault.SetException(new InvalidOperationException("disk full"));
|
||||
await Task.Yield();
|
||||
|
||||
// Nothing propagates anywhere the script could see; the tracking half ran.
|
||||
await _tracking.Received(1).RecordEnqueueAsync(
|
||||
Arg.Any<TrackedOperationId>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user