perf(audit): bounded async audit writer with batched inserts, one-time bootstrap, retention sweep

This commit is contained in:
Joseph Doherty
2026-08-15 12:17:22 -04:00
parent 6c5218913b
commit e2ac5d117a
9 changed files with 845 additions and 38 deletions
+25
View File
@@ -228,6 +228,31 @@ Storage recommendation:
administrators.
- Require TLS when the gateway is reachable off-machine.
## Audit Pipeline
Decision: audit is asynchronous, bounded, and swept.
The canonical `IAuditWriter` contract has always been best-effort — a failed audit write is
logged and swallowed so it cannot abort the action that produced it. The registered writer is
`ChannelAuditWriter`, which makes the cost of that promise explicit: a producer enqueues onto a
4096-event bounded channel and returns, and `AuditDrainService` commits up to 64 buffered events
per transaction. This exists because constraint denials are emitted per denied tag inside bulk
RPC loops: a partially denied 1,000-tag request previously awaited 1,000 sequential SQLite
inserts — each re-running `CREATE TABLE IF NOT EXISTS` — against the same database file every
authenticated call reads. The schema bootstrap now runs once, from the drain's `StartAsync`.
When the channel is full the newest event is dropped and counted rather than blocking the
producer: a stalled audit database must cost audit completeness, not gateway availability. Drops
are logged once and reported in aggregate on each sweep. Shutdown drains what is buffered under a
2-second cap. Where no hosted service runs — the `apikey` admin CLI — the writer falls back to the
synchronous path, so audit is never buffered into a channel nobody drains.
`MxGateway:Security:AuditRetentionDays` (default 90, minimum 1) bounds the table: the drain sweeps
at startup and hourly, deleting older rows. Retention cannot be configured off. The sweep compares
through SQLite's `datetime()` rather than on the stored ISO-8601 text, because timestamps written
from a non-UTC offset do not sort lexicographically against a UTC cutoff; a row whose timestamp
cannot be parsed yields NULL and is kept rather than deleted.
## Authorization
Decision: start with scope checks by command category.