perf(audit): bounded async audit writer with batched inserts, one-time bootstrap, retention sweep
This commit is contained in:
@@ -5,6 +5,41 @@ using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
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>
|
||||
/// MxGateway-owned, append-only SQLite store for canonical
|
||||
/// <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
|
||||
/// <see cref="CanonicalForwardingApiKeyAuditStore"/>, which forwards onto this store via
|
||||
/// <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
|
||||
/// created idempotently (<c>CREATE TABLE IF NOT EXISTS</c>) on each write so it
|
||||
/// self-bootstraps regardless of migration ordering.
|
||||
/// <c>api_key_audit</c> tables are not touched here.
|
||||
/// <para>
|
||||
/// 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>
|
||||
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory)
|
||||
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory) : IAuditEventSink
|
||||
{
|
||||
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>
|
||||
/// <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>
|
||||
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
|
||||
private const string InsertSql =
|
||||
"""
|
||||
INSERT INTO audit_event
|
||||
(event_id, occurred_at_utc, actor, action, outcome,
|
||||
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);
|
||||
|
||||
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 connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -56,25 +193,14 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"""
|
||||
INSERT INTO audit_event
|
||||
(event_id, occurred_at_utc, actor, action, outcome,
|
||||
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);
|
||||
DELETE FROM audit_event
|
||||
WHERE datetime(occurred_at_utc) < datetime($cutoff);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$event_id", auditEvent.EventId.ToString());
|
||||
command.Parameters.AddWithValue("$occurred_at_utc", auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
command.Parameters.AddWithValue("$actor", auditEvent.Actor);
|
||||
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);
|
||||
command.Parameters.AddWithValue(
|
||||
"$cutoff",
|
||||
cutoffUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
|
||||
@@ -128,11 +254,20 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
|
||||
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();
|
||||
command.CommandText = CreateTableSql;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Volatile.Write(ref _tableEnsured, 1);
|
||||
}
|
||||
|
||||
private static DateTimeOffset ParseUtc(string value) =>
|
||||
|
||||
Reference in New Issue
Block a user