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
@@ -99,16 +99,40 @@ public static class AuthStoreServiceCollectionExtensions
services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
services.AddSingleton<IAuditEventSink>(sp => sp.GetRequiredService<SqliteCanonicalAuditStore>());
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
// DI-only auth/CLI/dashboard unit tests build a bare ServiceCollection without AddLogging().
// Fall back to NullLogger there so the audit writer (and the IApiKeyAuditStore override that
// depends on it) still resolve. The write path is best-effort regardless.
services.AddSingleton<IAuditWriter>(sp =>
services.AddSingleton(sp =>
new CanonicalAuditWriter(
sp.GetRequiredService<SqliteCanonicalAuditStore>(),
sp.GetRequiredService<IAuditEventSink>(),
sp.GetService<ILogger<CanonicalAuditWriter>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<CanonicalAuditWriter>.Instance));
// The registered IAuditWriter is the bounded, asynchronous one: audit producers — above
// all IConstraintEnforcer.RecordDenialAsync, which fires once per denied tag inside bulk
// RPC loops — enqueue and return instead of awaiting a SQLite insert each. No producer
// signature changes; the seam is entirely here. AuditDrainService batches the buffered
// events onto the sink, owns the one-time schema bootstrap and sweeps expired rows. Where
// no hosted service runs (the `apikey` CLI, the DI-only tests) the channel writer falls
// back to CanonicalAuditWriter's synchronous path, so audit is never silently buffered
// into a channel nobody drains.
services.AddSingleton(sp =>
new ChannelAuditWriter(
sp.GetRequiredService<CanonicalAuditWriter>(),
sp.GetService<ILogger<ChannelAuditWriter>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<ChannelAuditWriter>.Instance));
services.AddSingleton<IAuditWriter>(sp => sp.GetRequiredService<ChannelAuditWriter>());
services.AddSingleton(sp => new AuditDrainService(
sp.GetRequiredService<ChannelAuditWriter>(),
sp.GetRequiredService<IAuditEventSink>(),
security,
sp.GetService<TimeProvider>() ?? TimeProvider.System,
sp.GetService<ILogger<AuditDrainService>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AuditDrainService>.Instance));
services.AddHostedService(sp => sp.GetRequiredService<AuditDrainService>());
// OVERRIDE the library's IApiKeyAuditStore (AddZbApiKeyAuth registered the library's
// SqliteApiKeyAuditStore via TryAddSingleton) with an adapter that canonicalizes every
// library-emitted ApiKeyAuditEntry onto AuditEvent and forwards it through IAuditWriter.