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
@@ -1,6 +1,6 @@
# Operation tracking store: writes off the script thread
# Cached-call telemetry off the script thread (tracking store queue + audit enqueue-and-observe)
**Date:** 2026-09-06 · **Status:** implemented the same day (branch `feat/tracking-store-async-writes`)
**Date:** 2026-09-06 · **Status:** implemented and measured the same day (branch `feat/tracking-store-async-writes`)
## Problem
@@ -17,9 +17,10 @@ which the site script run was 176 ms. Reading the audit rows against the code pa
`OperationTrackingStore` executed every `Record*Async` as its own autocommit SQLite transaction on the
consolidated LocalDb file, **awaited inline on the script thread** through
`CachedCallTelemetryForwarder.ForwardAsync`. Each commit is a WAL append + fsync
(`synchronous=NORMAL`, Docker volume): ~30–50 ms apiece here. The audit half of the same forwarder was
already cheap — `SqliteAuditWriter.WriteAsync` is a `Channel.TryWrite` — so the tracking half was the
whole cost. The API cached-call path (`ExternalSystem.CachedCall`) pays the same three commits.
(`synchronous=NORMAL`, Docker volume). The initial reading — which turned out to be wrong, see the
correction below — was that the audit half of the same forwarder was already cheap because
`SqliteAuditWriter.WriteAsync` enqueues with `Channel.TryWrite`, so the tracking commits were the whole
cost. The API cached-call path (`ExternalSystem.CachedCall`) takes the same three-packet path.
## Decision
@@ -73,9 +74,36 @@ untouched, and their existing `await`s now cost a channel write.
- *A bigger LocalDb `synchronous` relaxation*: `OFF` would remove the fsync but risk corruption on
power loss for every replicated table, not just this one.
## What the measurement actually showed (correction)
The tracking-store queue alone moved **nothing**: five runs on that image still sat at 108 ms for the
DB-done → `Notify.Send` gap and ~176 ms for the script run. The commits were cheap; the wait was the
**audit half** of the same forwarder. `SqliteAuditWriter.WriteAsync` enqueues synchronously but
returns the *batch's completion task*, and the writer loop deliberately coalesces for
`FlushIntervalMs` (50 ms default) before it commits — so every awaited audit row costs ~50 ms + fsync,
and a cached call awaits three of them (submit, attempted, resolve) on the script thread: ~160 ms.
Every other script-path audit emission (`AuditingDbCommand`, `ScriptRunLauncher`, the trace rows)
already fired-and-observed; only `CachedCallTelemetryForwarder.TryEmitAuditAsync` awaited.
Second change, same branch: the forwarder now enqueues the audit row and observes the completion on
a fault-logging continuation (the `FallbackAuditWriter` ring-buffer fallback still runs inside that
task). Nothing joins the audit and tracking stores by commit order; each keeps per-store FIFO.
| Segment (5-run median, rig) | before | tracking queue only | + audit enqueue-and-observe |
|---|---|---|---|
| inbound request total | 194 ms | 186 ms | **25 ms** |
| site script run | 176 ms | 173 ms | **17 ms** |
| `Database.CachedWrite` submit → done | 61 ms | 58 ms | **11 ms** |
| DB done → `Notify.Send` | 108 ms | 108 ms | **0.1 ms** |
The tracking-store queue is kept: it removes three fsyncs per cached call from the script path and
batches them, and `FlushIntervalMs` no longer bounds a script's latency — a cached call now costs
its real I/O (the REST or SQL round trip) plus microseconds of bookkeeping.
## Verification
- Store tests: a write returns while another connection holds the database write lock and lands
once it is released; reads observe every earlier write in order; `DisposeAsync` drains the queue.
- Rig: rerun `FlowOutboundDemo` through the Flow Debugger and compare the `DbCall`-to-`NotifySend`
gap and the run total against the 108 ms / 176 ms baseline above.
- Forwarder tests: `ForwardAsync` returns while the audit commit is still pending; a late commit
fault is swallowed and logged.
- Rig: `FlowOutboundDemo` five times per image through `flow-debug run-method`; table above.