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:
Joseph Doherty
2026-09-06 12:35:41 -04:00
parent d91135f960
commit 0ccbe140ae
6 changed files with 129 additions and 23 deletions
@@ -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()
{