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. administrators.
- Require TLS when the gateway is reachable off-machine. - 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 ## Authorization
Decision: start with scope checks by command category. Decision: start with scope checks by command category.
+1
View File
@@ -393,6 +393,7 @@ model requires otherwise.
| `MxGateway:Security:ApiKeyFailureWindowSeconds` | `60` | Sliding-window length, in seconds, over which API-key verification failures are counted, for both the per-partition and the per-key-id aggregate layer. Must be greater than zero. | | `MxGateway:Security:ApiKeyFailureWindowSeconds` | `60` | Sliding-window length, in seconds, over which API-key verification failures are counted, for both the per-partition and the per-key-id aggregate layer. Must be greater than zero. |
| `MxGateway:Security:ApiKeyFailureAggregateLimit` | `30` | Failed verifications for one key id counted across **all** transport peers within `ApiKeyFailureWindowSeconds` before that key id enters probe mode. This second layer bounds a distributed or source-rotating sprayer that never trips any single `(peer, key id)` partition. `0` disables the aggregate layer, leaving only per-partition counting. Must be zero or greater. | | `MxGateway:Security:ApiKeyFailureAggregateLimit` | `30` | Failed verifications for one key id counted across **all** transport peers within `ApiKeyFailureWindowSeconds` before that key id enters probe mode. This second layer bounds a distributed or source-rotating sprayer that never trips any single `(peer, key id)` partition. `0` disables the aggregate layer, leaving only per-partition counting. Must be zero or greater. |
| `MxGateway:Security:ApiKeyFailureProbeIntervalSeconds` | `5` | Minimum interval, in seconds, between probe admissions for an over-limit partition or key-id aggregate. An over-limit state is a valve rather than a wall: one request per interval reaches the real verifier — exactly one, even when a burst arrives together at the interval boundary — so the holder of the correct secret always gets through and clears the state, while everything else is still refused before the store read. `0` blocks absolutely instead — **not recommended**, because an unauthenticated peer can then deny the key to its holder for the whole window. Must be zero or greater. | | `MxGateway:Security:ApiKeyFailureProbeIntervalSeconds` | `5` | Minimum interval, in seconds, between probe admissions for an over-limit partition or key-id aggregate. An over-limit state is a valve rather than a wall: one request per interval reaches the real verifier — exactly one, even when a burst arrives together at the interval boundary — so the holder of the correct secret always gets through and clears the state, while everything else is still refused before the store read. `0` blocks absolutely instead — **not recommended**, because an unauthenticated peer can then deny the key to its holder for the whole window. Must be zero or greater. |
| `MxGateway:Security:AuditRetentionDays` | `90` | Days of canonical audit history kept in the `audit_event` table. The audit drain sweeps once at startup and hourly thereafter, deleting rows older than this window; without it the table grows without bound inside the same SQLite file the authentication hot path reads. Rows whose timestamp SQLite cannot parse are never swept. Must be greater than zero — retention can be widened but not switched off. |
| `MxGateway:Security:ApiKeyFailureTrackedPeers` | `4096` | Maximum distinct partitions tracked by the failure counter (a bounded LRU) so a spray of unique tokens cannot grow memory without limit. It cannot be used to flush an active block either: only a validly shaped `mxgw_<keyId>_<secret>` token mints a key-id partition (everything else lands on the sender's transport-peer partition), each address may mint at most 32 key-id partitions before the overflow collapses onto that address's fallback partition, and eviction prefers fully expired windows, never removing an over-limit partition until the map exceeds twice this cap. Must be greater than zero. | | `MxGateway:Security:ApiKeyFailureTrackedPeers` | `4096` | Maximum distinct partitions tracked by the failure counter (a bounded LRU) so a spray of unique tokens cannot grow memory without limit. It cannot be used to flush an active block either: only a validly shaped `mxgw_<keyId>_<secret>` token mints a key-id partition (everything else lands on the sender's transport-peer partition), each address may mint at most 32 key-id partitions before the overflow collapses onto that address's fallback partition, and eviction prefers fully expired windows, never removing an over-limit partition until the map exceeds twice this cap. Must be greater than zero. |
## Galaxy Options ## Galaxy Options
@@ -100,6 +100,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
"MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.", "MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.",
builder); builder);
// Retention must be at least one day: 0 would sweep the audit table on every pass, which
// is a way to silently disable auditing rather than an expression of intent.
AddIfNotPositive(
options.AuditRetentionDays,
"MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).",
builder);
// The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate // The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate
// limit turns off cross-peer counting, and a zero probe interval restores absolute blocking. // limit turns off cross-peer counting, and a zero probe interval restores absolute blocking.
// Negatives express no intent. // Negatives express no intent.
@@ -88,4 +88,13 @@ public sealed class SecurityOptions
/// ceiling of twice this value. Default is 4096. /// ceiling of twice this value. Default is 4096.
/// </summary> /// </summary>
public int ApiKeyFailureTrackedPeers { get; init; } = 4096; public int ApiKeyFailureTrackedPeers { get; init; } = 4096;
/// <summary>
/// Gets how many days of canonical audit history the gateway keeps. The audit drain sweeps
/// <c>audit_event</c> once at startup and hourly thereafter, deleting rows older than this
/// window; without it the table grows without bound in the same SQLite file the
/// authentication hot path reads. Must be greater than zero — audit retention cannot be
/// disabled by configuration, only widened. Default is 90 days.
/// </summary>
public int AuditRetentionDays { get; init; } = 90;
} }
@@ -3,24 +3,26 @@ using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <summary> /// <summary>
/// Best-effort <see cref="IAuditWriter"/> over the MxGateway-owned /// Best-effort, <em>synchronous</em> <see cref="IAuditWriter"/> over the MxGateway-owned
/// <see cref="SqliteCanonicalAuditStore"/>. It honours the canonical /// <see cref="IAuditEventSink"/>. It honours the canonical
/// <see cref="IAuditWriter"/> contract: a failed audit write is swallowed and logged /// <see cref="IAuditWriter"/> contract: a failed audit write is swallowed and logged
/// rather than propagated, so it can never abort the user-facing action that produced it. /// rather than propagated, so it can never abort the user-facing action that produced it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This is the single sink through which ALL MxGateway audit flows — the library admin /// This is the durable bottom of the audit pipeline. Callers reach it two ways: through
/// verbs (via <see cref="CanonicalForwardingApiKeyAuditStore"/>) and the gateway's own /// <see cref="ChannelAuditWriter"/> — the registered <see cref="IAuditWriter"/>, which
/// dashboard / constraint-denial producers, which write canonical events directly. The /// enqueues and lets <see cref="AuditDrainService"/> batch events onto the sink — and
/// best-effort wrapping here also closes the gap that the library's /// directly, when there is no drain to batch behind (the <c>apikey</c> CLI, and any host
/// shutdown window), where writing through immediately is the only way the event survives.
/// The best-effort wrapping here also closes the gap that the library's
/// <c>SqliteApiKeyAuditStore.AppendAsync</c> propagated exceptions. /// <c>SqliteApiKeyAuditStore.AppendAsync</c> propagated exceptions.
/// </remarks> /// </remarks>
public sealed class CanonicalAuditWriter( public sealed class CanonicalAuditWriter(
SqliteCanonicalAuditStore store, IAuditEventSink sink,
ILogger<CanonicalAuditWriter> logger) : IAuditWriter ILogger<CanonicalAuditWriter> logger) : IAuditWriter
{ {
/// <summary> /// <summary>
/// Persists a canonical audit event to the underlying <see cref="SqliteCanonicalAuditStore"/>. /// Persists a canonical audit event to the underlying <see cref="IAuditEventSink"/>.
/// Any failure is caught, logged, and swallowed rather than propagated to the caller. /// Any failure is caught, logged, and swallowed rather than propagated to the caller.
/// </summary> /// </summary>
/// <param name="auditEvent">The canonical audit event to persist.</param> /// <param name="auditEvent">The canonical audit event to persist.</param>
@@ -32,7 +34,7 @@ public sealed class CanonicalAuditWriter(
try try
{ {
await store.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false); await sink.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false);
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -0,0 +1,333 @@
using System.Threading.Channels;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <summary>
/// Bounded, non-blocking <see cref="IAuditWriter"/>: <see cref="WriteAsync"/> enqueues onto a
/// fixed-capacity channel and returns, leaving <see cref="AuditDrainService"/> to batch the
/// events onto the durable <see cref="IAuditEventSink"/>.
/// </summary>
/// <remarks>
/// The canonical <see cref="IAuditWriter"/> contract is already best-effort — a failed audit
/// write is swallowed rather than propagated. The channel makes the <em>bound</em> on that
/// promise explicit: audit can cost the calling RPC at most one enqueue, never a SQLite
/// round-trip, and at most <see cref="ChannelCapacity"/> events of memory. This matters on the
/// constraint-denial path, where a partially denied bulk RPC previously awaited one insert per
/// denied tag, serially, against the same database file the authentication hot path reads.
/// <para>
/// When the channel is full the newest write is dropped (<see cref="BoundedChannelFullMode.DropWrite"/>)
/// and counted in <see cref="DroppedCount"/>. Dropping is the deliberate choice over blocking:
/// a stalled audit database must degrade audit completeness, not stall the gateway. Drops are
/// logged, and <see cref="AuditDrainService"/> reports the running total on its sweep.
/// </para>
/// <para>
/// Until a drain attaches (<see cref="AttachDrain"/>), and again after it detaches, writes go
/// straight through to <see cref="CanonicalAuditWriter"/>. Enqueueing into a channel nobody will
/// ever read would silently discard audit in the processes that have no hosted services — the
/// <c>apikey</c> admin CLI and the DI-only tests — so those keep the original synchronous path.
/// </para>
/// </remarks>
public sealed class ChannelAuditWriter : IAuditWriter
{
/// <summary>
/// Maximum number of audit events buffered before writes start being dropped. Sized to
/// absorb a fully denied bulk RPC (the gateway's bulk request cap) plus headroom, so a
/// realistic burst is buffered rather than lost.
/// </summary>
public const int ChannelCapacity = 4096;
private readonly CanonicalAuditWriter _directWriter;
private readonly ILogger<ChannelAuditWriter> _logger;
private readonly Channel<AuditEvent> _channel;
private long _droppedCount;
private int _drainAttached;
private int _dropLogged;
/// <summary>Creates the writer and its bounded buffer.</summary>
/// <param name="directWriter">The synchronous writer used when no drain is attached.</param>
/// <param name="logger">Logger for drop diagnostics.</param>
public ChannelAuditWriter(CanonicalAuditWriter directWriter, ILogger<ChannelAuditWriter> logger)
{
_directWriter = directWriter;
_logger = logger;
// DropWrite discards the incoming item and still reports success to the producer, so the
// itemDropped callback is the only place a drop can be observed and counted.
_channel = Channel.CreateBounded<AuditEvent>(
new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
},
itemDropped: RecordDrop);
}
/// <summary>Gets the number of audit events dropped because the channel was full.</summary>
public long DroppedCount => Interlocked.Read(ref _droppedCount);
/// <summary>Gets the reader the drain service consumes buffered events from.</summary>
public ChannelReader<AuditEvent> Reader => _channel.Reader;
/// <summary>
/// Marks a drain as running, so subsequent writes enqueue instead of writing through.
/// Called by <see cref="AuditDrainService"/> once its one-time bootstrap has completed.
/// </summary>
public void AttachDrain() => Volatile.Write(ref _drainAttached, 1);
/// <summary>
/// Marks the drain as no longer running, so writes revert to the synchronous path. Called
/// at shutdown so late audit is still persisted rather than buffered into a dead channel.
/// </summary>
public void DetachDrain() => Volatile.Write(ref _drainAttached, 0);
/// <summary>
/// Enqueues a canonical audit event for the drain to persist. Never blocks, never throws,
/// and never touches the store on the caller's thread while a drain is attached.
/// </summary>
/// <param name="auditEvent">The canonical audit event to persist.</param>
/// <param name="cancellationToken">Token honoured only by the direct write-through path.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(auditEvent);
if (Volatile.Read(ref _drainAttached) == 0)
{
return _directWriter.WriteAsync(auditEvent, cancellationToken);
}
// Under DropWrite a full channel still reports success (the discard surfaces through the
// itemDropped callback); a false here means the channel is completed, which is also a drop.
if (!_channel.Writer.TryWrite(auditEvent))
{
RecordDrop(auditEvent);
}
return Task.CompletedTask;
}
/// <summary>Signals that no further events will be enqueued, so the drain loop can finish.</summary>
public void CompleteWriting() => _channel.Writer.TryComplete();
private void RecordDrop(AuditEvent auditEvent)
{
Interlocked.Increment(ref _droppedCount);
// Log the first drop only; the running total is reported on the drain's periodic sweep,
// so a sustained overload cannot turn audit pressure into a log flood.
if (Interlocked.Exchange(ref _dropLogged, 1) == 0)
{
_logger.LogWarning(
"Audit channel is full ({Capacity} events); dropping audit event {EventId} (action {Action}). "
+ "Audit is best-effort and bounded; further drops are reported in aggregate.",
ChannelCapacity,
auditEvent.EventId,
auditEvent.Action);
}
}
}
/// <summary>
/// Drains <see cref="ChannelAuditWriter"/> onto the durable <see cref="IAuditEventSink"/>,
/// owns the one-time schema bootstrap, and sweeps audit rows past their retention window.
/// </summary>
/// <remarks>
/// Batching is the point: up to <see cref="MaxBatchSize"/> buffered events are committed in a
/// single transaction, so a burst of constraint denials costs a handful of commits instead of
/// one per denied tag. The bootstrap runs here — before the writer starts enqueueing — so no
/// audit write ever pays a <c>CREATE TABLE IF NOT EXISTS</c> round-trip.
/// </remarks>
/// <param name="writer">The channel writer whose buffered events are drained.</param>
/// <param name="sink">The durable sink events are committed to.</param>
/// <param name="security">Security options carrying the audit retention window.</param>
/// <param name="timeProvider">Clock used for the retention cutoff and sweep interval.</param>
/// <param name="logger">Logger for bootstrap, drain and sweep diagnostics.</param>
public sealed class AuditDrainService(
ChannelAuditWriter writer,
IAuditEventSink sink,
SecurityOptions security,
TimeProvider timeProvider,
ILogger<AuditDrainService> logger) : BackgroundService
{
/// <summary>Maximum number of audit events committed in one transaction per drain pass.</summary>
public const int MaxBatchSize = 64;
/// <summary>How often the retention sweep runs while the gateway is up.</summary>
public static readonly TimeSpan RetentionSweepInterval = TimeSpan.FromHours(1);
/// <summary>Upper bound on how long shutdown waits for the remaining buffered events.</summary>
private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2);
/// <summary>
/// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the
/// writer switches from synchronous write-through to enqueueing.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public override async Task StartAsync(CancellationToken cancellationToken)
{
try
{
await sink.EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await SweepRetentionAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
// Audit is best-effort: a bootstrap failure must not take the gateway down. The
// sink's own latch will retry the schema check on the first write.
logger.LogWarning(exception, "Audit store bootstrap failed; audit writes will retry the schema check.");
}
writer.AttachDrain();
await base.StartAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Detaches the drain (so late writes go straight to the sink) and gives the buffered
/// events a bounded window to reach the store.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public override async Task StopAsync(CancellationToken cancellationToken)
{
writer.DetachDrain();
writer.CompleteWriting();
await base.StopAsync(cancellationToken).ConfigureAwait(false);
using CancellationTokenSource drainCap = new(ShutdownDrainCap);
try
{
await DrainPendingAsync(drainCap.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
logger.LogWarning(
"Shutdown drain exceeded {CapSeconds}s; remaining buffered audit events were not persisted.",
ShutdownDrainCap.TotalSeconds);
}
}
/// <summary>
/// Commits every event currently buffered, in batches of at most <see cref="MaxBatchSize"/>.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The number of events persisted.</returns>
public async Task<int> DrainPendingAsync(CancellationToken cancellationToken)
{
int persisted = 0;
List<AuditEvent> batch = new(MaxBatchSize);
while (!cancellationToken.IsCancellationRequested)
{
batch.Clear();
while (batch.Count < MaxBatchSize && writer.Reader.TryRead(out AuditEvent? auditEvent))
{
batch.Add(auditEvent);
}
if (batch.Count == 0)
{
break;
}
try
{
await sink.InsertBatchAsync(batch, cancellationToken).ConfigureAwait(false);
persisted += batch.Count;
}
catch (Exception exception)
{
// Best-effort: the batch is lost, but the drain keeps going so one poisoned
// batch cannot stall every later audit event behind it.
logger.LogWarning(
exception,
"Failed to persist a batch of {Count} audit events; audit writes are best-effort and were suppressed.",
batch.Count);
}
}
return persisted;
}
/// <summary>
/// Deletes audit rows older than <c>MxGateway:Security:AuditRetentionDays</c>, and reports
/// any audit events dropped by channel pressure since the last sweep.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task SweepRetentionAsync(CancellationToken cancellationToken)
{
DateTimeOffset cutoff = timeProvider.GetUtcNow() - TimeSpan.FromDays(security.AuditRetentionDays);
try
{
int deleted = await sink.DeleteOlderThanAsync(cutoff, cancellationToken).ConfigureAwait(false);
if (deleted > 0)
{
logger.LogInformation(
"Audit retention sweep removed {Deleted} events older than {Cutoff:o} ({RetentionDays} days).",
deleted,
cutoff,
security.AuditRetentionDays);
}
}
catch (Exception exception)
{
logger.LogWarning(exception, "Audit retention sweep failed; it will be retried on the next interval.");
}
long dropped = writer.DroppedCount;
if (dropped > 0)
{
logger.LogWarning(
"{Dropped} audit events have been dropped since startup because the audit channel was full.",
dropped);
}
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.WhenAll(
DrainLoopAsync(stoppingToken),
RetentionLoopAsync(stoppingToken)).ConfigureAwait(false);
}
private async Task DrainLoopAsync(CancellationToken stoppingToken)
{
try
{
while (await writer.Reader.WaitToReadAsync(stoppingToken).ConfigureAwait(false))
{
await DrainPendingAsync(stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutdown; StopAsync performs the final bounded drain.
}
}
private async Task RetentionLoopAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(RetentionSweepInterval, timeProvider);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await SweepRetentionAsync(stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutdown.
}
}
}
@@ -5,6 +5,41 @@ using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <summary>
/// Durable sink the audit pipeline persists canonical <see cref="AuditEvent"/>s through.
/// It exists so the write path (<see cref="CanonicalAuditWriter"/>) and the batching drain
/// (<see cref="AuditDrainService"/>) depend on the storage contract rather than on the
/// concrete <see cref="SqliteCanonicalAuditStore"/>.
/// </summary>
public interface IAuditEventSink
{
/// <summary>
/// Bootstraps the backing storage. Called once at startup so no write path pays a schema
/// round-trip; implementations must be idempotent and safe to call concurrently.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task EnsureInitializedAsync(CancellationToken cancellationToken);
/// <summary>Persists a single canonical audit event.</summary>
/// <param name="auditEvent">The canonical event to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken);
/// <summary>Persists a batch of canonical audit events as one unit of work.</summary>
/// <param name="auditEvents">The canonical events to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken);
/// <summary>Deletes every audit row that occurred strictly before <paramref name="cutoffUtc"/>.</summary>
/// <param name="cutoffUtc">The retention cutoff; rows older than this are removed.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The number of rows deleted.</returns>
Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken);
}
/// <summary> /// <summary>
/// MxGateway-owned, append-only SQLite store for canonical /// MxGateway-owned, append-only SQLite store for canonical
/// <see cref="AuditEvent"/>s. It writes to a NEW <c>audit_event</c> table in the /// <see cref="AuditEvent"/>s. It writes to a NEW <c>audit_event</c> table in the
@@ -18,11 +53,20 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <c>IApiKeyAuditStore</c> registration is overridden by /// <c>IApiKeyAuditStore</c> registration is overridden by
/// <see cref="CanonicalForwardingApiKeyAuditStore"/>, which forwards onto this store via /// <see cref="CanonicalForwardingApiKeyAuditStore"/>, which forwards onto this store via
/// <see cref="CanonicalAuditWriter"/>. The library's <c>schema_version</c> / /// <see cref="CanonicalAuditWriter"/>. The library's <c>schema_version</c> /
/// <c>api_key_audit</c> tables are not touched here; the <c>audit_event</c> table is /// <c>api_key_audit</c> tables are not touched here.
/// created idempotently (<c>CREATE TABLE IF NOT EXISTS</c>) on each write so it /// <para>
/// self-bootstraps regardless of migration ordering. /// The <c>audit_event</c> table is created idempotently, but the <c>CREATE TABLE IF NOT
/// EXISTS</c> is <em>latched</em>: <see cref="AuditDrainService"/> runs
/// <see cref="EnsureInitializedAsync"/> once at startup, and every later insert/list/delete
/// then skips the DDL round-trip. Keeping the (now free) check on each path rather than
/// dropping it means the store still self-bootstraps for callers that use it without the
/// hosted drain — the <c>apikey</c> CLI and the DI-only tests — regardless of migration
/// ordering. The latch is deliberately racy: a lost race merely re-runs an idempotent
/// <c>CREATE TABLE IF NOT EXISTS</c>, and a failure leaves the latch open so the next call
/// retries.
/// </para>
/// </remarks> /// </remarks>
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory) public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory) : IAuditEventSink
{ {
private const string CreateTableSql = private const string CreateTableSql =
""" """
@@ -40,14 +84,107 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
); );
"""; """;
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary> private const string InsertSql =
/// <param name="auditEvent">The canonical event to persist.</param> """
/// <param name="cancellationToken">Token to observe for cancellation.</param> INSERT INTO audit_event
/// <returns>A task that represents the asynchronous operation.</returns> (event_id, occurred_at_utc, actor, action, outcome,
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken) category, target, source_node, correlation_id, details_json)
VALUES
($event_id, $occurred_at_utc, $actor, $action, $outcome,
$category, $target, $source_node, $correlation_id, $details_json);
""";
/// <summary>0 until the <c>audit_event</c> table has been created at least once by this instance.</summary>
private int _tableEnsured;
/// <inheritdoc />
public async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(auditEvent); ArgumentNullException.ThrowIfNull(auditEvent);
return InsertBatchAsync([auditEvent], cancellationToken);
}
/// <inheritdoc />
/// <remarks>
/// One connection, one transaction and one prepared command for the whole batch: the drain
/// pays a single commit for up to <see cref="AuditDrainService.MaxBatchSize"/> events rather
/// than one round-trip per event.
/// </remarks>
public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(auditEvents);
if (auditEvents.Count == 0)
{
return;
}
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
await using SqliteTransaction transaction =
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (SqliteCommand command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertSql;
SqliteParameter eventId = command.Parameters.Add("$event_id", SqliteType.Text);
SqliteParameter occurredAtUtc = command.Parameters.Add("$occurred_at_utc", SqliteType.Text);
SqliteParameter actor = command.Parameters.Add("$actor", SqliteType.Text);
SqliteParameter action = command.Parameters.Add("$action", SqliteType.Text);
SqliteParameter outcome = command.Parameters.Add("$outcome", SqliteType.Text);
SqliteParameter category = command.Parameters.Add("$category", SqliteType.Text);
SqliteParameter target = command.Parameters.Add("$target", SqliteType.Text);
SqliteParameter sourceNode = command.Parameters.Add("$source_node", SqliteType.Text);
SqliteParameter correlationId = command.Parameters.Add("$correlation_id", SqliteType.Text);
SqliteParameter detailsJson = command.Parameters.Add("$details_json", SqliteType.Text);
foreach (AuditEvent auditEvent in auditEvents)
{
ArgumentNullException.ThrowIfNull(auditEvent);
eventId.Value = auditEvent.EventId.ToString();
occurredAtUtc.Value = auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture);
actor.Value = auditEvent.Actor;
action.Value = auditEvent.Action;
outcome.Value = auditEvent.Outcome.ToString();
category.Value = (object?)auditEvent.Category ?? DBNull.Value;
target.Value = (object?)auditEvent.Target ?? DBNull.Value;
sourceNode.Value = (object?)auditEvent.SourceNode ?? DBNull.Value;
correlationId.Value = (object?)auditEvent.CorrelationId?.ToString() ?? DBNull.Value;
detailsJson.Value = (object?)auditEvent.DetailsJson ?? DBNull.Value;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
/// <remarks>
/// The comparison goes through SQLite's <c>datetime()</c> rather than comparing the stored
/// ISO-8601 text directly: rows written from a non-UTC <see cref="DateTimeOffset"/> (the
/// library's <c>CreatedUtc</c> is caller-supplied) do not sort lexicographically against a
/// UTC cutoff. A row whose timestamp <c>datetime()</c> cannot parse yields NULL and is
/// therefore never deleted — audit that cannot be dated is kept, not swept.
/// </remarks>
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
@@ -56,25 +193,14 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
await using SqliteCommand command = connection.CreateCommand(); await using SqliteCommand command = connection.CreateCommand();
command.CommandText = command.CommandText =
""" """
INSERT INTO audit_event DELETE FROM audit_event
(event_id, occurred_at_utc, actor, action, outcome, WHERE datetime(occurred_at_utc) < datetime($cutoff);
category, target, source_node, correlation_id, details_json)
VALUES
($event_id, $occurred_at_utc, $actor, $action, $outcome,
$category, $target, $source_node, $correlation_id, $details_json);
"""; """;
command.Parameters.AddWithValue("$event_id", auditEvent.EventId.ToString()); command.Parameters.AddWithValue(
command.Parameters.AddWithValue("$occurred_at_utc", auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture)); "$cutoff",
command.Parameters.AddWithValue("$actor", auditEvent.Actor); cutoffUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
command.Parameters.AddWithValue("$action", auditEvent.Action);
command.Parameters.AddWithValue("$outcome", auditEvent.Outcome.ToString());
command.Parameters.AddWithValue("$category", (object?)auditEvent.Category ?? DBNull.Value);
command.Parameters.AddWithValue("$target", (object?)auditEvent.Target ?? DBNull.Value);
command.Parameters.AddWithValue("$source_node", (object?)auditEvent.SourceNode ?? DBNull.Value);
command.Parameters.AddWithValue("$correlation_id", (object?)auditEvent.CorrelationId?.ToString() ?? DBNull.Value);
command.Parameters.AddWithValue("$details_json", (object?)auditEvent.DetailsJson ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
} }
/// <summary>Returns the most recent canonical audit events, newest first.</summary> /// <summary>Returns the most recent canonical audit events, newest first.</summary>
@@ -128,11 +254,20 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
return events; return events;
} }
private static async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken) // Latched bootstrap: after the first success this is a single volatile read, so the DDL
// round-trip is paid once per process rather than once per audit write.
private async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken)
{ {
if (Volatile.Read(ref _tableEnsured) == 1)
{
return;
}
await using SqliteCommand command = connection.CreateCommand(); await using SqliteCommand command = connection.CreateCommand();
command.CommandText = CreateTableSql; command.CommandText = CreateTableSql;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _tableEnsured, 1);
} }
private static DateTimeOffset ParseUtc(string value) => private static DateTimeOffset ParseUtc(string value) =>
@@ -99,16 +99,40 @@ public static class AuthStoreServiceCollectionExtensions
services.AddSingleton(sp => services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>())); 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 // 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(). // 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 // 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. // depends on it) still resolve. The write path is best-effort regardless.
services.AddSingleton<IAuditWriter>(sp => services.AddSingleton(sp =>
new CanonicalAuditWriter( new CanonicalAuditWriter(
sp.GetRequiredService<SqliteCanonicalAuditStore>(), sp.GetRequiredService<IAuditEventSink>(),
sp.GetService<ILogger<CanonicalAuditWriter>>() sp.GetService<ILogger<CanonicalAuditWriter>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<CanonicalAuditWriter>.Instance)); ?? 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 // OVERRIDE the library's IApiKeyAuditStore (AddZbApiKeyAuth registered the library's
// SqliteApiKeyAuditStore via TryAddSingleton) with an adapter that canonicalizes every // SqliteApiKeyAuditStore via TryAddSingleton) with an adapter that canonicalizes every
// library-emitted ApiKeyAuditEntry onto AuditEvent and forwards it through IAuditWriter. // library-emitted ApiKeyAuditEntry onto AuditEvent and forwards it through IAuditWriter.
@@ -0,0 +1,271 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit;
/// <summary>
/// Tests the bounded, asynchronous audit path: <see cref="ChannelAuditWriter"/> (enqueue-only
/// once a drain is attached, drops rather than blocks when the channel is full) and
/// <see cref="AuditDrainService"/> (batched drain, one-time table bootstrap, retention sweep).
/// The channel makes the already-documented best-effort audit contract explicit and bounded:
/// a partially denied bulk RPC no longer pays a SQLite round-trip per denied tag.
/// </summary>
public sealed class ChannelAuditWriterTests : IDisposable
{
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
/// <summary>
/// With a drain attached, <see cref="ChannelAuditWriter.WriteAsync"/> only enqueues — the
/// sink is not touched until the drain runs, and the event arrives once it does.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_WithDrainAttached_EnqueuesOnlyAndReachesSinkAfterDrain()
{
CountingAuditSink sink = new();
(ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink);
writer.AttachDrain();
await writer.WriteAsync(MakeEvent("constraint-denied"), CancellationToken.None);
// Enqueue-only: nothing has reached the sink yet.
Assert.Equal(0, sink.InsertBatchCalls);
Assert.Empty(sink.Events);
int drained = await drain.DrainPendingAsync(CancellationToken.None);
Assert.Equal(1, drained);
Assert.Equal(1, sink.InsertBatchCalls);
Assert.Equal("constraint-denied", Assert.Single(sink.Events).Action);
}
/// <summary>
/// A drain pass batches up to <see cref="AuditDrainService.MaxBatchSize"/> events into one
/// sink call, so a partially denied bulk RPC costs a handful of transactions, not one per tag.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DrainPendingAsync_BatchesEventsIntoBoundedInsertCalls()
{
CountingAuditSink sink = new();
(ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink);
writer.AttachDrain();
const int eventCount = 150;
for (int index = 0; index < eventCount; index++)
{
await writer.WriteAsync(MakeEvent($"denied-{index}"), CancellationToken.None);
}
int drained = await drain.DrainPendingAsync(CancellationToken.None);
Assert.Equal(eventCount, drained);
Assert.Equal(eventCount, sink.Events.Count);
// 150 events at a batch size of 64 → 3 transactions (64 + 64 + 22), not 150.
Assert.Equal(3, sink.InsertBatchCalls);
Assert.All(sink.BatchSizes, size => Assert.True(size <= AuditDrainService.MaxBatchSize));
}
/// <summary>
/// When the bounded channel is full the write is dropped rather than blocking the caller,
/// and the drop is counted. A stalled or slow audit database must never stall an RPC.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_WhenChannelFull_DropsWriteAndCountsItWithoutBlocking()
{
CountingAuditSink sink = new();
(ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink);
writer.AttachDrain();
const int overflow = 32;
for (int index = 0; index < ChannelAuditWriter.ChannelCapacity + overflow; index++)
{
// Every call must complete synchronously: the channel never blocks a producer.
Task write = writer.WriteAsync(MakeEvent($"denied-{index}"), CancellationToken.None);
Assert.True(write.IsCompletedSuccessfully);
}
Assert.Equal(overflow, writer.DroppedCount);
// The channel still holds exactly its capacity; nothing reached the sink (no drain ran).
Assert.Equal(0, sink.InsertBatchCalls);
await Task.CompletedTask;
}
/// <summary>
/// With no drain attached (the <c>apikey</c> CLI, DI-only tests, post-shutdown) the writer
/// writes through to the sink instead of enqueueing into a channel nobody will ever read.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_WithNoDrainAttached_WritesThroughToSink()
{
CountingAuditSink sink = new();
(ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink);
await writer.WriteAsync(MakeEvent("dashboard-create-key"), CancellationToken.None);
Assert.Equal("dashboard-create-key", Assert.Single(sink.Events).Action);
}
/// <summary>
/// The retention sweep deletes rows older than <c>AuditRetentionDays</c>, measured from the
/// injected clock, and runs once at startup after the one-time table bootstrap.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_BootstrapsTableThenSweepsRetentionAtConfiguredCutoff()
{
CountingAuditSink sink = new();
FakeTimeProvider clock = new(new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero));
(_, AuditDrainService drain) = CreateWriterAndDrain(
sink,
new SecurityOptions { AuditRetentionDays = 30 },
clock);
await drain.StartAsync(CancellationToken.None);
await drain.StopAsync(CancellationToken.None);
Assert.Equal(1, sink.EnsureInitializedCalls);
Assert.Equal(new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero), Assert.Single(sink.DeleteCutoffs));
}
/// <summary>
/// The real SQLite sink deletes only rows older than the cutoff, and its bootstrap latch
/// means the <c>CREATE TABLE</c> round-trip is not repeated on every subsequent write.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SqliteStore_DeleteOlderThan_RemovesOnlyRowsBeyondTheRetentionWindow()
{
SqliteCanonicalAuditStore store = CreateStore();
DateTimeOffset now = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
await store.InsertBatchAsync(
[
MakeEvent("stale", now.AddDays(-120)),
// A non-UTC offset must still compare correctly against the cutoff.
MakeEvent("stale-offset", now.AddDays(-100).ToOffset(TimeSpan.FromHours(-5))),
MakeEvent("fresh", now.AddDays(-1)),
],
CancellationToken.None);
int deleted = await store.DeleteOlderThanAsync(now.AddDays(-90), CancellationToken.None);
Assert.Equal(2, deleted);
Assert.Equal("fresh", Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)).Action);
}
private static (ChannelAuditWriter Writer, AuditDrainService Drain) CreateWriterAndDrain(
IAuditEventSink sink,
SecurityOptions? security = null,
TimeProvider? timeProvider = null)
{
ChannelAuditWriter writer = new(
new CanonicalAuditWriter(sink, NullLogger<CanonicalAuditWriter>.Instance),
NullLogger<ChannelAuditWriter>.Instance);
AuditDrainService drain = new(
writer,
sink,
security ?? new SecurityOptions(),
timeProvider ?? TimeProvider.System,
NullLogger<AuditDrainService>.Instance);
return (writer, drain);
}
private SqliteCanonicalAuditStore CreateStore()
{
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-channel-audit");
_tempDirectories.Add(directory);
return new SqliteCanonicalAuditStore(new AuthSqliteConnectionFactory(directory.DatabasePath()));
}
private static AuditEvent MakeEvent(string action, DateTimeOffset? occurredAtUtc = null) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc ?? DateTimeOffset.UtcNow,
Actor = "operator01",
Action = action,
Outcome = AuditOutcome.Denied,
Category = "ApiKey",
};
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
public void Dispose()
{
foreach (TempDatabaseDirectory directory in _tempDirectories)
{
directory.Dispose();
}
_tempDirectories.Clear();
}
/// <summary>
/// In-memory <see cref="IAuditEventSink"/> that records every call, so a test can prove the
/// write path did NOT touch the sink and that the drain batched what it did write.
/// </summary>
private sealed class CountingAuditSink : IAuditEventSink
{
private readonly Lock _gate = new();
/// <summary>Gets the events handed to the sink, in the order they were written.</summary>
public List<AuditEvent> Events { get; } = [];
/// <summary>Gets the size of each batch the sink was asked to insert.</summary>
public List<int> BatchSizes { get; } = [];
/// <summary>Gets the cutoffs the sink was asked to delete below.</summary>
public List<DateTimeOffset> DeleteCutoffs { get; } = [];
/// <summary>Gets the number of <see cref="InsertBatchAsync"/> calls.</summary>
public int InsertBatchCalls => BatchSizes.Count;
/// <summary>Gets the number of <see cref="EnsureInitializedAsync"/> calls.</summary>
public int EnsureInitializedCalls { get; private set; }
/// <inheritdoc />
public Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
lock (_gate)
{
EnsureInitializedCalls++;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken) =>
InsertBatchAsync([auditEvent], cancellationToken);
/// <inheritdoc />
public Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
{
lock (_gate)
{
BatchSizes.Add(auditEvents.Count);
Events.AddRange(auditEvents);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
{
lock (_gate)
{
DeleteCutoffs.Add(cutoffUtc);
}
return Task.FromResult(0);
}
}
}