Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs
T

1783 lines
90 KiB
C#

using System.Threading.Channels;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Site-side SQLite hot-path writer for Audit Log events. Mirrors the
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogger"/> design — a single
/// owned <see cref="SqliteConnection"/> serialised behind a write lock, fed by a
/// bounded <see cref="Channel{T}"/> drained on a dedicated background writer
/// task — so script-thread callers never block on disk I/O.
/// </summary>
/// <remarks>
/// <para>
/// <b>Two-table schema.</b> The site store is now two tables:
/// the append-only canonical <c>audit_event</c> (the 10 canonical
/// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
/// the mutable operational <c>audit_forward_state</c> sidecar that carries the
/// forwarding lifecycle (<see cref="AuditForwardState"/>), a duplicated
/// <c>OccurredAtUtc</c> for the drain index range-scan, a precomputed
/// <c>IsCachedKind</c> flag that drives the cached/non-cached drain split without
/// re-parsing <c>DetailsJson</c> on the read hot-path, plus attempt bookkeeping.
/// </para>
/// <para>
/// <b>Ephemeral reset.</b> The site SQLite store is ephemeral (≈7-day retention,
/// recreated per deployment), so this schema change is an in-place RESET: the new
/// tables are created and the old single 24-column <c>AuditLog</c> table is
/// DROP-ped if present. No SQLite data migration is performed (and none is
/// needed) — any rows in the old <c>AuditLog</c> table are within the retention
/// window and are discarded by the drop.
/// </para>
/// <para>
/// Site rows always carry <see cref="AuditForwardState.Pending"/> on first
/// insert; the central row-shape's <c>IngestedAtUtc</c> is a DetailsJson field
/// stamped by central on ingest, not a site column.
/// </para>
/// </remarks>
public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, ISiteAuditPushLifecycle, IAsyncDisposable, IDisposable
{
// Microsoft.Data.Sqlite reports a generic SQLITE_CONSTRAINT (error code 19)
// on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY)
// is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably
// surfaced across all SQLite builds. We treat any constraint error on insert
// as a duplicate-eventid race and swallow it (first-write-wins) — the PRIMARY
// KEY on audit_event.EventId is the constraint that fires first, so this scope
// is precise (the sidecar insert for the same EventId is in the same
// transaction and never reached once audit_event's insert throws).
private const int SqliteErrorConstraint = 19;
// Max EventIds bound into a single IN (...) UPDATE by MarkForwardedAsync /
// MarkReconciledAsync. SQLite caps bound parameters per statement (999 on older
// builds, SQLITE_MAX_VARIABLE_NUMBER); a large drain batch would otherwise blow the
// ceiling as batch sizes grow. Chunk the id list into ≤500-id slices, all inside one
// transaction so the batch stays atomic (arch-review 04, P8).
private const int MarkForwardStateChunkSize = 500;
private readonly SqliteConnection _connection;
// Dedicated read-only connection used by GetBacklogStatsAsync,
// ReadPendingAsync, ReadPendingSinceAsync, and ReadForwardedAsync so a slow
// backlog scan (COUNT(*) over hundreds of thousands of Pending rows under a
// central outage) never parks the hot-path writer behind _writeLock.
// SQLite-with-WAL allows a second connection on the same file to read
// concurrently with the writer; the writer's WAL pragma is set in
// InitializeSchema before this connection is opened. The reader connection
// has its own _readLock because SqliteConnection itself is not thread-safe
// even in read-only mode — multiple read callers can otherwise interleave
// commands on the shared connection.
private readonly SqliteConnection _readConnection;
private readonly object _readLock = new();
private readonly SqliteAuditWriterOptions _options;
private readonly ILogger<SqliteAuditWriter> _logger;
private readonly INodeIdentityProvider _nodeIdentity;
private readonly object _writeLock = new();
private readonly Channel<PendingAuditEvent> _writeQueue;
private readonly Task _writerLoop;
private bool _disposed;
/// <summary>Initializes a new instance of the SqliteAuditWriter class.</summary>
/// <param name="options">Configuration options for the audit writer.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="nodeIdentity">Node identity provider.</param>
/// <param name="connectionStringOverride">Optional connection string override.</param>
public SqliteAuditWriter(
IOptions<SqliteAuditWriterOptions> options,
ILogger<SqliteAuditWriter> logger,
INodeIdentityProvider nodeIdentity,
string? connectionStringOverride = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(nodeIdentity);
_options = options.Value;
_logger = logger;
_nodeIdentity = nodeIdentity;
var connectionString = connectionStringOverride
?? $"Data Source={_options.DatabasePath};Cache=Shared";
_connection = new SqliteConnection(connectionString);
_connection.Open();
InitializeSchema();
LoadMaxServedRowId();
// Open a second connection for read-only callers
// (GetBacklogStatsAsync, ReadPendingAsync, ReadPendingSinceAsync,
// ReadForwardedAsync). InitializeSchema set journal_mode=WAL on the
// writer connection, which is a database-level setting that persists
// for the file — subsequent connections to the same file see WAL and
// can read concurrently with the writer without taking _writeLock.
// Reuse the same connection string so the read connection sees the
// same Data Source / Cache settings as the writer.
_readConnection = new SqliteConnection(connectionString);
_readConnection.Open();
// PRAGMA foreign_keys is a per-connection setting. Set it on the read
// connection as well so that any future read-path change (e.g. a
// DELETE that may be added later) also benefits from FK enforcement.
// Pure SELECT queries are unaffected — this is defensive belt-and-
// suspenders for the read connection.
using (var pragmaCmd = _readConnection.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
pragmaCmd.ExecuteNonQuery();
}
_writeQueue = Channel.CreateBounded<PendingAuditEvent>(
new BoundedChannelOptions(_options.ChannelCapacity)
{
// Wait mode is only what the (never-awaited) async write path would
// do; WriteAsync uses TryWrite exclusively and fails fast with
// SqliteAuditWriterSaturatedException when the queue is full, so no
// caller ever parks on room (review 2026-10-21 06/MEM-1). The
// FallbackAuditWriter's drop-oldest ring absorbs the overflow.
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false,
});
_writerLoop = Task.Run(ProcessWriteQueueAsync);
}
private void InitializeSchema()
{
// auto_vacuum must be set before any table is created for it to take
// effect on a fresh database. INCREMENTAL lets the `PRAGMA
// incremental_vacuum` run at the end of PurgeExpiredAsync shrink the file
// after the ~7-day retention purge — see alog.md §10.
using (var pragmaCmd = _connection.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA auto_vacuum = INCREMENTAL";
pragmaCmd.ExecuteNonQuery();
}
// Enable WAL so a second connection on the same file can
// serve read-only callers (GetBacklogStatsAsync, ReadPendingAsync,
// ReadPendingSinceAsync, ReadForwardedAsync) concurrently with the
// batched writer, decoupling those reads from _writeLock. WAL is a
// database-level setting persisted in the file header; setting it on
// the writer connection means every connection opened to the file
// afterwards inherits WAL behaviour. PRAGMA journal_mode returns the
// mode actually adopted ("memory" for ":memory:" / shared-cache memory
// mode, "wal" for file-backed) — we don't error if WAL was rejected
// because the read connection's correctness does not depend on WAL
// itself, only its concurrency advantage does.
using (var pragmaCmd = _connection.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA journal_mode = WAL";
pragmaCmd.ExecuteNonQuery();
}
// synchronous=NORMAL: SQLite's FULL default
// fsyncs on every transaction commit, which is the dominant cost of the
// per-event write path this same change coalesces via FlushIntervalMs — FULL
// would still fsync once per FLUSH even after that fix. NORMAL fsyncs at
// fewer, WAL-checkpoint-aligned points instead of every commit; the window
// this opens is a handful of the most recent commits lost on OS crash / power
// loss (WAL mode itself still guarantees no *corruption*, only a possible
// short rollback on unclean restart). That trade is acceptable here because
// audit writes are explicitly best-effort by design (CLAUDE.md: "Audit-write
// failure NEVER aborts the user-facing action — audit is best-effort, the
// action's own success/failure path is authoritative") — this is the same
// durability class as a dropped audit row, not a new risk class.
using (var pragmaCmd = _connection.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA synchronous = NORMAL";
pragmaCmd.ExecuteNonQuery();
}
// Enable FK enforcement on the WRITE connection. PRAGMA foreign_keys is
// a per-connection, per-session setting in SQLite — it is NOT persisted
// in the database file, so every new connection that may INSERT into
// audit_forward_state must set it for the FK
// audit_forward_state.EventId → audit_event.EventId
// to be a real runtime guard rather than decorative DDL. The write
// connection owns all INSERTs (and the MarkForwardedAsync /
// MarkReconciledAsync UPDATEs), so setting it here — after WAL is
// established, before the CREATE TABLEs — ensures the FK is live for
// every insert that follows. The existing insert order (audit_event
// first, then audit_forward_state, inside the same transaction) already
// satisfies the FK, so no pre-existing rows can violate the constraint.
using (var pragmaCmd = _connection.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
pragmaCmd.ExecuteNonQuery();
}
// In-place reset. The site store is EPHEMERAL (≈7-day
// retention, recreated per deployment), so we do NOT migrate the old
// single 24-column AuditLog table to the new two-table shape: any rows
// it holds are within the retention window and discarded. DROP it if an
// earlier deployment left it behind, then CREATE the two new tables. This
// is safe precisely BECAUSE the site store is ephemeral — never do this
// on a durable store (the central SQL Server side keeps its shim until
// it is migrated, not reset).
using (var dropCmd = _connection.CreateCommand())
{
dropCmd.CommandText = "DROP TABLE IF EXISTS AuditLog;";
dropCmd.ExecuteNonQuery();
}
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
-- Canonical, append-only / write-once: the 10 fields of the canonical
-- ZB.MOM.WW.Audit.AuditEvent stored directly (DetailsJson carries the
-- ScadaBridge domain fields). No forwarding state lives here — that is
-- the audit_forward_state sidecar's concern.
CREATE TABLE IF NOT EXISTS audit_event (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Actor TEXT NOT NULL,
Action TEXT NOT NULL,
Outcome TEXT NOT NULL,
Category TEXT NULL,
Target TEXT NULL,
SourceNode TEXT NULL,
CorrelationId TEXT NULL,
DetailsJson TEXT NULL,
PRIMARY KEY (EventId)
);
-- Operational, mutable: the forwarding lifecycle for each canonical
-- row. OccurredAtUtc is duplicated here so the drain range-scan stays
-- on this one table's index; IsCachedKind is precomputed at insert so
-- the cached/non-cached drain split never re-parses DetailsJson on the
-- read hot-path.
CREATE TABLE IF NOT EXISTS audit_forward_state (
EventId TEXT NOT NULL,
ForwardState TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
IsCachedKind INTEGER NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
LastAttemptUtc TEXT NULL,
PRIMARY KEY (EventId),
FOREIGN KEY (EventId) REFERENCES audit_event(EventId)
);
-- Drain index: every read filters on (ForwardState, IsCachedKind) and
-- range-scans/orders by OccurredAtUtc, so this composite covers the
-- four reads + the backlog COUNT/MIN.
CREATE INDEX IF NOT EXISTS IX_fwd
ON audit_forward_state (ForwardState, IsCachedKind, OccurredAtUtc);
-- Node-local key/value state that must survive a process restart. Holds the
-- reconciliation pull's served-rows high-water mark ('max_served_rowid'), so a
-- restart no longer resets the bound that lets a covering cursor retire rows the
-- pull already delivered (review 2026-10-21 06/PERF-1 adversarial follow-up).
-- Additive and idempotent: an older binary simply ignores the table.
CREATE TABLE IF NOT EXISTS audit_site_state (
Key TEXT NOT NULL PRIMARY KEY,
Value INTEGER NOT NULL
);
-- Flow Debugger (design 2026-09-05 §5.4): PullTraceEvents reads audit_event by
-- time window + json_extract on ExecutionId/ParentExecutionId; this bounds the scan
-- to the window. Additive and idempotent.
CREATE INDEX IF NOT EXISTS IX_audit_event_occurred
ON audit_event (OccurredAtUtc);
-- Fresh execution IDs permit clock-independent trace reads. Partial indexes
-- exclude malformed legacy details before evaluating either JSON expression.
CREATE INDEX IF NOT EXISTS IX_audit_event_execution
ON audit_event (json_extract(DetailsJson, '$.executionId'), OccurredAtUtc, EventId)
WHERE json_valid(DetailsJson);
CREATE INDEX IF NOT EXISTS IX_audit_event_parent_execution
ON audit_event (json_extract(DetailsJson, '$.parentExecutionId'), OccurredAtUtc, EventId)
WHERE json_valid(DetailsJson);
""";
cmd.ExecuteNonQuery();
}
/// <summary>
/// Enqueues an audit event for asynchronous batched persistence to SQLite.
/// Never parks the caller on queue room: a full queue faults the returned task with
/// <see cref="SqliteAuditWriterSaturatedException"/> immediately.
/// </summary>
/// <param name="evt">The audit event to persist.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes when the event has been persisted.</returns>
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evt);
// The canonical record carries no ForwardState (a site-storage-only
// concern). Site rows always start Pending; the sidecar row is written
// alongside the canonical row in the same transaction.
var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
if (_writeQueue.Writer.TryWrite(pending))
{
return pending.Completion.Task;
}
// TryWrite fails for exactly two reasons: the writer is completed (disposed)
// or the queue is at capacity. The second used to fall into an awaited
// WriteAsync on the channel — one parked waiter, holding its AuditEvent, per
// fire-and-continue script-thread caller for as long as the background writer
// stayed stuck (e.g. behind a multi-minute retention purge). That count was
// unbounded and invisible. Fail fast instead: FallbackAuditWriter routes the
// event to its bounded drop-oldest ring and bumps SiteAuditWriteFailures, which
// is the documented best-effort degradation (review 2026-10-21 06/MEM-1).
if (Volatile.Read(ref _writerCompleted))
{
return Task.FromException(new ObjectDisposedException(nameof(SqliteAuditWriter),
"Event could not be recorded: the audit writer has been disposed."));
}
Interlocked.Increment(ref _saturatedRejections);
return Task.FromException(new SqliteAuditWriterSaturatedException(_options.ChannelCapacity));
}
/// <summary>
/// Number of <see cref="WriteAsync"/> calls refused because the write queue was full
/// since construction. Observability seam; the health-report count is the
/// <c>SiteAuditWriteFailures</c> bump <see cref="FallbackAuditWriter"/> makes per refusal.
/// </summary>
public long SaturatedRejectionCount => Interlocked.Read(ref _saturatedRejections);
private long _saturatedRejections;
// Set (before the channel is completed) once DisposeAsync starts, so a refused
// TryWrite can tell "disposed" from "full" without racing the channel's state.
private bool _writerCompleted;
/// <summary>
/// Rows moved to <see cref="SiteAuditForwardStates.Rejected"/> since construction (also exported
/// as the <c>scadabridge.site.audit.rejected_rows</c> counter).
/// </summary>
public long RejectedPushRowCount => Interlocked.Read(ref _rejectedPushRows);
private long _rejectedPushRows;
private async Task ProcessWriteQueueAsync()
{
var batch = new List<PendingAuditEvent>(_options.BatchSize);
// ReadAllAsync completes when the channel is marked complete (Dispose).
await foreach (var first in _writeQueue.Reader.ReadAllAsync().ConfigureAwait(false))
{
batch.Clear();
batch.Add(first);
var deadline = DateTime.UtcNow.AddMilliseconds(_options.FlushIntervalMs);
// Pull additional ready events up to BatchSize. TryRead is non-
// blocking and lets us amortise the transaction overhead across a
// burst of concurrent enqueues. Once the immediately-available
// burst is drained, keep the transaction open for up to
// FlushIntervalMs waiting for MORE events to trickle in — this is
// the soft-flush coalescing
// this loop provides: without it, a low-rate trickle of writes (one script call every
// few ms, well under BatchSize) fsyncs once PER EVENT, because the
// writer loop reaches an empty channel and flushes immediately.
// FlushIntervalMs bounds the worst-case added latency any single
// event can see from this coalescing.
while (batch.Count < _options.BatchSize)
{
if (_writeQueue.Reader.TryRead(out var next))
{
batch.Add(next);
continue;
}
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
break;
}
var waitToRead = _writeQueue.Reader.WaitToReadAsync().AsTask();
var completed = await Task.WhenAny(
waitToRead, Task.Delay(remaining)).ConfigureAwait(false);
if (completed != waitToRead)
{
// Deadline elapsed with nothing new — flush what we have.
break;
}
// WaitToReadAsync resolving false means the channel completed
// (Dispose) with nothing left to read; resolving true means at
// least one item is available, so the top of the loop's
// TryRead will pick it up.
if (!await waitToRead.ConfigureAwait(false))
{
break;
}
}
FlushBatch(batch);
}
}
/// <summary>
/// Total number of <see cref="FlushBatch"/> transactions committed since
/// construction. Test-only observability seam for asserting that FlushIntervalMs coalesces a trickle of
/// near-simultaneous writes into a single transaction rather than one
/// commit per event; production code never reads this.
/// </summary>
internal int FlushCountForTests => _flushCountForTests;
/// <summary>
/// Test-only: the write lock FlushBatch / the flips / the purge serialise on, so a test can
/// park the background writer deterministically (e.g. to fill the write queue).
/// </summary>
internal object WriteLockForTests => _writeLock;
private int _flushCountForTests;
private void FlushBatch(IReadOnlyList<PendingAuditEvent> batch)
{
lock (_writeLock)
{
if (_disposed)
{
foreach (var pending in batch)
{
pending.Completion.TrySetException(
new ObjectDisposedException(nameof(SqliteAuditWriter),
"Event could not be recorded: the audit writer was disposed before the write completed."));
}
return;
}
using var transaction = _connection.BeginTransaction();
try
{
// INSERT 1: the canonical row, stored DIRECTLY (the 10 canonical
// fields straight off the AuditEvent — no Decompose; audit_event
// holds canonical shape, not the legacy 24-column shape).
using var eventCmd = _connection.CreateCommand();
eventCmd.Transaction = transaction;
eventCmd.CommandText = """
INSERT INTO audit_event (
EventId, OccurredAtUtc, Actor, Action, Outcome,
Category, Target, SourceNode, CorrelationId, DetailsJson
) VALUES (
$EventId, $OccurredAtUtc, $Actor, $Action, $Outcome,
$Category, $Target, $SourceNode, $CorrelationId, $DetailsJson
);
""";
var eEventId = eventCmd.Parameters.Add("$EventId", SqliteType.Text);
var eOccurredAt = eventCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
var eActor = eventCmd.Parameters.Add("$Actor", SqliteType.Text);
var eAction = eventCmd.Parameters.Add("$Action", SqliteType.Text);
var eOutcome = eventCmd.Parameters.Add("$Outcome", SqliteType.Text);
var eCategory = eventCmd.Parameters.Add("$Category", SqliteType.Text);
var eTarget = eventCmd.Parameters.Add("$Target", SqliteType.Text);
var eSourceNode = eventCmd.Parameters.Add("$SourceNode", SqliteType.Text);
var eCorrelationId = eventCmd.Parameters.Add("$CorrelationId", SqliteType.Text);
var eDetailsJson = eventCmd.Parameters.Add("$DetailsJson", SqliteType.Text);
// INSERT 2: the operational sidecar row. ForwardState=Pending,
// OccurredAtUtc duplicated for the drain index, IsCachedKind
// precomputed (so the read split never parses DetailsJson),
// AttemptCount=0, LastAttemptUtc=NULL.
using var fwdCmd = _connection.CreateCommand();
fwdCmd.Transaction = transaction;
fwdCmd.CommandText = """
INSERT INTO audit_forward_state (
EventId, ForwardState, OccurredAtUtc, IsCachedKind, AttemptCount, LastAttemptUtc
) VALUES (
$EventId, $ForwardState, $OccurredAtUtc, $IsCachedKind, 0, NULL
);
""";
var fEventId = fwdCmd.Parameters.Add("$EventId", SqliteType.Text);
var fForwardState = fwdCmd.Parameters.Add("$ForwardState", SqliteType.Text);
var fOccurredAt = fwdCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
var fIsCachedKind = fwdCmd.Parameters.Add("$IsCachedKind", SqliteType.Integer);
foreach (var pending in batch)
{
var evt = pending.Event;
// Canonical OccurredAtUtc is UTC by construction; store the
// round-trip "o" form so string comparison stays monotonic
// (the drain range-scan and ORDER BY rely on it).
var occurredText = evt.OccurredAtUtc.UtcDateTime.ToString(
"o", System.Globalization.CultureInfo.InvariantCulture);
eEventId.Value = evt.EventId.ToString();
eOccurredAt.Value = occurredText;
// Canonical Actor is a required non-null string.
eActor.Value = evt.Actor ?? string.Empty;
eAction.Value = evt.Action;
eOutcome.Value = evt.Outcome.ToString();
eCategory.Value = (object?)evt.Category ?? DBNull.Value;
eTarget.Value = (object?)evt.Target ?? DBNull.Value;
// SourceNode-stamping: caller-provided value wins (preserves
// rows reconciled in from other nodes via the same writer);
// otherwise stamp from the local INodeIdentityProvider. The
// event record itself is NOT mutated — stamping is at write
// time only. If the provider also returns null (unconfigured
// node), the column stays NULL — operators see "needs config"
// via the schema, not a magic fallback string.
var sourceNode = evt.SourceNode ?? _nodeIdentity.NodeName;
eSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
eCorrelationId.Value = (object?)evt.CorrelationId?.ToString() ?? DBNull.Value;
eDetailsJson.Value = (object?)evt.DetailsJson ?? DBNull.Value;
fEventId.Value = evt.EventId.ToString();
fForwardState.Value = pending.ForwardState.ToString();
fOccurredAt.Value = occurredText;
fIsCachedKind.Value = IsCachedKind(evt.DetailsJson) ? 1 : 0;
try
{
eventCmd.ExecuteNonQuery();
fwdCmd.ExecuteNonQuery();
// Do NOT complete pending.Completion here. A row's INSERTs succeeding is not durable until the
// transaction actually commits below — completing the caller's Task
// this early means a later commit failure (SQLITE_FULL, IOERR, or a
// later row in this same batch throwing) reports false success: the
// row is rolled back out of the database but the caller — and
// FallbackAuditWriter, whose ring-buffer fallback exists precisely
// for this outage — already saw it as delivered. Every row in this
// batch is completed with TrySetResult() together, AFTER
// transaction.Commit() succeeds, mirroring SiteEventLogger.FlushBatch.
}
catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint)
{
// Duplicate EventId — first-write-wins (alog.md §11). The
// audit_event PRIMARY KEY throws before the sidecar insert
// runs, so neither table gains a second row. Treat as
// success: the lifecycle event is durably recorded under
// the first writer's payload. Still deferred to after Commit()
// below — the surrounding transaction (this row's canonical/sidecar
// insert never ran, but earlier rows in the batch did) must still
// commit for that to be true.
_logger.LogDebug(ex,
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
evt.EventId);
}
}
transaction.Commit();
_flushCountForTests++;
// Only NOW — after the whole batch is durably committed — tell every
// caller (and FallbackAuditWriter) that their row landed.
foreach (var pending in batch)
{
pending.Completion.TrySetResult();
}
}
catch (Exception ex)
{
transaction.Rollback();
_logger.LogError(ex, "SqliteAuditWriter batch insert failed; faulting {Count} pending events", batch.Count);
// Fault EVERY row in the batch, including any whose own INSERTs succeeded
// above — none of that is true anymore, since the whole transaction (this
// row's insert included) just rolled back. No TrySetResult has run yet for
// any row in this batch (see above), so every TrySetException here is a
// real transition, not a no-op racing an already-completed Task.
foreach (var pending in batch)
{
pending.Completion.TrySetException(ex);
}
}
}
}
// Cached-lifecycle audit kinds that ride the combined-telemetry
// drain (joined with the operational tracking row + pushed via
// IngestCachedTelemetryAsync into the central dual-write transaction).
// This is the SAME set the earlier ReadPendingCachedTelemetryAsync query
// filtered on (Kind IN (...)); it is now precomputed into the sidecar's
// IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
// integer predicate, not a JSON parse. ReadPendingAsync drains everything
// with IsCachedKind=0; ReadPendingCachedTelemetryAsync drains IsCachedKind=1.
private static readonly HashSet<AuditKind> CachedTelemetryKinds = new()
{
AuditKind.CachedSubmit,
AuditKind.ApiCallCached,
AuditKind.DbWriteCached,
AuditKind.CachedResolve,
};
/// <summary>
/// Precomputes the sidecar's <c>IsCachedKind</c> flag from a canonical
/// row's <c>DetailsJson</c>. Parses the <see cref="AuditDetails.Kind"/>
/// discriminator via <see cref="AuditDetailsCodec"/> and returns <c>true</c>
/// iff it is one of the cached-lifecycle kinds
/// (<see cref="AuditKind.CachedSubmit"/>, <see cref="AuditKind.ApiCallCached"/>,
/// <see cref="AuditKind.DbWriteCached"/>, <see cref="AuditKind.CachedResolve"/>).
/// Runs once per event at INSERT time so the cached/non-cached drain split is
/// a cheap integer predicate on read, never a JSON parse on the hot path.
/// </summary>
private static bool IsCachedKind(string? detailsJson)
{
var details = AuditDetailsCodec.Deserialize(detailsJson);
var kind = AuditRowProjection.ParseEnum(details.Kind, AuditKind.InboundRequest);
return CachedTelemetryKinds.Contains(kind);
}
/// <summary>
/// Returns up to <paramref name="limit"/> non-cached pending audit events, oldest first.
/// Cached-lifecycle kinds are excluded; use <see cref="ReadPendingCachedTelemetryAsync"/> for those.
/// </summary>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of pending audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
{
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// Read via the dedicated _readConnection so this scan
// (which can be expensive when the backlog grows under a central
// outage) does not block the batched writer on _writeLock. WAL mode
// gives us a stable snapshot of the tables while writes proceed on the
// writer connection. _readLock serialises this connection across
// multiple concurrent read callers since SqliteConnection itself is
// not thread-safe.
// JOIN the sidecar and filter on IsCachedKind=0 — the cached-
// lifecycle kinds (IsCachedKind=1) flow through
// ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
// split is a precomputed integer predicate on the indexed sidecar, not
// a DetailsJson parse. Ordering is by the sidecar's OccurredAtUtc with
// EventId as the deterministic tiebreaker.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState = $pending
AND fs.IsCachedKind = 0
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$limit", limit);
return Task.FromResult(ReadRows(cmd, limit));
}
}
/// <summary>
/// Returns up to <paramref name="limit"/> pending cached-lifecycle audit events, oldest first.
/// Only rows with cached-call kinds (CachedSubmit, ApiCallCached, DbWriteCached, CachedResolve) are included.
/// </summary>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of pending cached-telemetry audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(
int limit, CancellationToken ct = default)
{
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// Dedicated read surface for the cached-call lifecycle
// drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
// Same _readConnection + _readLock pattern so the hot-path writer is not
// contended.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState = $pending
AND fs.IsCachedKind = 1
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$limit", limit);
return Task.FromResult(ReadRows(cmd, limit));
}
}
/// <summary>
/// Returns up to <paramref name="limit"/> rows in
/// <see cref="AuditForwardState.Forwarded"/>, oldest
/// <see cref="AuditEvent.OccurredAtUtc"/> first, with
/// <see cref="AuditEvent.EventId"/> as the deterministic tiebreaker. The
/// <see cref="AuditForwardState.Forwarded"/>-specific counterpart of
/// <see cref="ReadPendingAsync"/>; used by tests to assert a row reached the
/// <see cref="AuditForwardState.Forwarded"/> state specifically (unlike
/// <see cref="ReadPendingSinceAsync"/>, which also returns
/// <see cref="AuditForwardState.Pending"/> rows).
/// </summary>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of forwarded audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
{
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// Mirror ReadPendingAsync — read via _readConnection /
// _readLock so this query never contends with the batched writer on
// _writeLock. JOIN the sidecar and filter on ForwardState='Forwarded'
// (no IsCachedKind split — both cached and non-cached Forwarded rows are
// returned, as before).
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState = $forwarded
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
cmd.Parameters.AddWithValue("$limit", limit);
return Task.FromResult(ReadRows(cmd, limit));
}
}
/// <inheritdoc />
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventIds);
if (eventIds.Count == 0)
{
return Task.CompletedTask;
}
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Flip the sidecar — UPDATE audit_forward_state, not the canonical
// audit_event (which is append-only / write-once). Bump AttemptCount +
// stamp LastAttemptUtc so operators can see how many drain passes a row
// took to forward. Each id is bound as its own parameter, so no string
// concatenation of user data ever enters the SQL.
//
// Defensive state guard: only transition rows that are still Pending or
// Forwarded (i.e. not yet Reconciled). Without this guard a mis-called
// batch that includes a Reconciled EventId would silently demote it back
// to Forwarded — a state regression that would cause duplicate central
// ingestion. Symmetric with MarkReconciledAsync's
// WHERE ForwardState IN ($pending, $forwarded)
// guard. Current callers only pass Pending IDs, so normal-path behaviour
// is unchanged; the guard is purely defensive.
//
// Stamp one $now for the whole call so every chunk records the same
// LastAttemptUtc (matching the prior single-statement behaviour).
var now = DateTime.UtcNow.ToString(
"o", System.Globalization.CultureInfo.InvariantCulture);
var forwarded = AuditForwardState.Forwarded.ToString();
var pending = AuditForwardState.Pending.ToString();
// Chunk into ≤MarkForwardStateChunkSize-id UPDATEs, all inside ONE
// transaction so the whole batch commits atomically (arch-review 04, P8).
using var transaction = _connection.BeginTransaction();
for (int start = 0; start < eventIds.Count; start += MarkForwardStateChunkSize)
{
var chunk = Math.Min(MarkForwardStateChunkSize, eventIds.Count - start);
using var cmd = _connection.CreateCommand();
cmd.Transaction = transaction;
var sb = new System.Text.StringBuilder();
sb.Append("UPDATE audit_forward_state SET ForwardState = $forwarded, ")
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
for (int i = 0; i < chunk; i++)
{
if (i > 0) sb.Append(',');
var p = $"$id{i}";
sb.Append(p);
cmd.Parameters.AddWithValue(p, eventIds[start + i].ToString());
}
sb.Append(");");
cmd.CommandText = sb.ToString();
cmd.Parameters.AddWithValue("$forwarded", forwarded);
cmd.Parameters.AddWithValue("$pending", pending);
cmd.Parameters.AddWithValue("$now", now);
cmd.ExecuteNonQuery();
}
transaction.Commit();
return Task.CompletedTask;
}
}
/// <summary>
/// Returns up to <paramref name="batchSize"/> audit events central has not yet acknowledged
/// (<see cref="AuditForwardState.Pending"/>, <see cref="SiteAuditForwardStates.PushAbandoned"/>,
/// <see cref="SiteAuditForwardStates.Rejected"/>) with <see cref="AuditEvent.OccurredAtUtc"/>
/// &gt;= <paramref name="sinceUtc"/>, oldest first. <see cref="AuditForwardState.Forwarded"/> rows
/// are NOT served (review 2026-10-21 06/PERF-1). Used by the reconciliation-pull handler.
/// </summary>
/// <param name="sinceUtc">Lower bound timestamp (UTC) for event occurrence.</param>
/// <param name="batchSize">Maximum number of rows to return.</param>
/// <param name="afterId">
/// Composite-keyset tiebreak: the EventId of the last row already consumed at
/// <paramref name="sinceUtc"/>. Non-null switches the predicate from the inclusive
/// <c>OccurredAtUtc &gt;= $since</c> to the strict composite
/// <c>(OccurredAtUtc, EventId) &gt; ($since, $afterId)</c>, matching the query's own
/// ORDER BY so a batch cannot stall on rows sharing one instant.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of audit events since the given timestamp.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default)
{
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), "batchSize must be > 0.");
}
// Read via _readConnection / _readLock — same lock-
// decoupling as ReadPendingAsync. JOIN the sidecar; the range scan
// is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd.
//
// ONLY rows central has NOT acknowledged are served: Pending, plus the
// push-exit states PushAbandoned / Rejected (review 2026-10-21 06/PERF-1).
// Forwarded rows used to be served too, which made the pull re-ship every
// row the push had already delivered — central re-inserted each one, and the
// pull's 256-rows-per-tick budget was spent on acked rows, so any site above
// ~0.85 audit rows/s fell permanently behind and latched the stalled signal.
// A Forwarded row is acked by central; the pull has nothing to repair there.
IReadOnlyList<AuditEvent> rows;
long batchMaxRowId;
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
var cursorPredicate = afterId is null
? "fs.OccurredAtUtc >= $since"
// Composite keyset, lexicographic on (OccurredAtUtc, EventId) — the same
// ordering the query applies, so it is a strict "everything after this row".
: "(fs.OccurredAtUtc > $since OR (fs.OccurredAtUtc = $since AND ae.EventId > $afterId))";
// fs.rowid rides along as an 11th column purely to maintain
// _maxServedRowId — the insertion-order high-water mark that bounds
// MarkReconciledUpToAsync (see that method). It is never mapped onto
// the returned AuditEvent; rowid stays a storage-layer concept.
cmd.CommandText = $"""
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson,
fs.rowid
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState IN ($pending, $pushAbandoned, $rejected)
AND {cursorPredicate}
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
AddOwedStateParameters(cmd);
// Normalise to UTC ISO-8601 round-trip format to match how OccurredAtUtc
// is stored on insert ("o" format) — string comparison is monotonic for
// that encoding so we can index-scan against it.
cmd.Parameters.AddWithValue("$since", EnsureUtc(sinceUtc).ToString(
"o", System.Globalization.CultureInfo.InvariantCulture));
if (afterId is not null)
{
// EventIds are stored as Guid.ToString() ("D"), so compare in that form.
cmd.Parameters.AddWithValue("$afterId", NormalizeEventId(afterId));
}
cmd.Parameters.AddWithValue("$limit", batchSize);
rows = ReadServedRows(cmd, batchSize, out batchMaxRowId);
}
// Advance (and persist) the mark OUTSIDE _readLock so the two locks are never nested in
// read→write order, and UNDER _writeLock — the lock the purge's rowid clamp runs under.
// Advancing it under _readLock from a value sampled before the SELECT let a purge clamp
// that landed in between be overwritten with the stale-high value (review 2026-10-21
// follow-up L1). Taking max(current, batch max) under _writeLock sees any clamp that
// already happened; the served rows themselves are owed and so can never be purged.
// Only when it moved — at most one tiny upsert per pull.
lock (_writeLock)
{
if (!_disposed && batchMaxRowId > Volatile.Read(ref _maxServedRowId))
{
Volatile.Write(ref _maxServedRowId, batchMaxRowId);
try
{
PersistMaxServedRowId(batchMaxRowId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"SqliteAuditWriter could not persist the served high-water mark {Mark}; a restart re-learns it from the next pull.",
batchMaxRowId);
}
}
}
return Task.FromResult(rows);
}
/// <summary>
/// Binds the three "owed to central" states — <c>$pending</c>, <c>$pushAbandoned</c>,
/// <c>$rejected</c> — that the reconciliation pull serves and retires.
/// </summary>
private static void AddOwedStateParameters(SqliteCommand cmd)
{
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$pushAbandoned", SiteAuditForwardStates.PushAbandoned);
cmd.Parameters.AddWithValue("$rejected", SiteAuditForwardStates.Rejected);
}
/// <inheritdoc />
public Task MarkPushAbandonedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventIds);
if (eventIds.Count == 0)
{
return Task.CompletedTask;
}
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Pending → PushAbandoned only. Unlike the old "mark Forwarded" this is honest:
// central never acked these rows, so they stay owed to the reconciliation pull
// (served, retired by a covering cursor, never age-purged) — review 2026-10-21
// 06/PERF-1. Attempt bookkeeping is bumped like any drain pass that touched them.
var now = DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
using var transaction = _connection.BeginTransaction();
for (int start = 0; start < eventIds.Count; start += MarkForwardStateChunkSize)
{
var chunk = Math.Min(MarkForwardStateChunkSize, eventIds.Count - start);
using var cmd = _connection.CreateCommand();
cmd.Transaction = transaction;
var sb = new System.Text.StringBuilder();
sb.Append("UPDATE audit_forward_state SET ForwardState = $abandoned, ")
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
.Append("WHERE ForwardState = $pending AND EventId IN (");
AppendIdParameters(cmd, sb, eventIds, start, chunk);
sb.Append(");");
cmd.CommandText = sb.ToString();
cmd.Parameters.AddWithValue("$abandoned", SiteAuditForwardStates.PushAbandoned);
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$now", now);
cmd.ExecuteNonQuery();
}
transaction.Commit();
return Task.CompletedTask;
}
}
/// <inheritdoc />
public Task<int> RecordRejectedPushAttemptsAsync(
IReadOnlyList<Guid> eventIds, int maxAttempts, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventIds);
if (eventIds.Count == 0)
{
return Task.FromResult(0);
}
var threshold = Math.Max(1, maxAttempts);
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Review 2026-10-21 06/IMPL-1: AttemptCount/LastAttemptUtc used to move only on
// the Pending→Forwarded flip, so a row central kept refusing had no history and
// no give-up — and since the push reads oldest-first, it sat at the head of every
// batch forever. Bump on every refused attempt, then move rows that reached the
// threshold out of the push queue into Rejected (still owed to the pull).
var now = DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
var flipped = 0;
using var transaction = _connection.BeginTransaction();
for (int start = 0; start < eventIds.Count; start += MarkForwardStateChunkSize)
{
var chunk = Math.Min(MarkForwardStateChunkSize, eventIds.Count - start);
using (var bump = _connection.CreateCommand())
{
bump.Transaction = transaction;
var sb = new System.Text.StringBuilder();
sb.Append("UPDATE audit_forward_state SET AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
.Append("WHERE ForwardState = $pending AND EventId IN (");
AppendIdParameters(bump, sb, eventIds, start, chunk);
sb.Append(");");
bump.CommandText = sb.ToString();
bump.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
bump.Parameters.AddWithValue("$now", now);
bump.ExecuteNonQuery();
}
using (var reject = _connection.CreateCommand())
{
reject.Transaction = transaction;
var sb = new System.Text.StringBuilder();
sb.Append("UPDATE audit_forward_state SET ForwardState = $rejected ")
.Append("WHERE ForwardState = $pending AND AttemptCount >= $max AND EventId IN (");
AppendIdParameters(reject, sb, eventIds, start, chunk);
sb.Append(");");
reject.CommandText = sb.ToString();
reject.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
reject.Parameters.AddWithValue("$rejected", SiteAuditForwardStates.Rejected);
reject.Parameters.AddWithValue("$max", threshold);
flipped += reject.ExecuteNonQuery();
}
}
transaction.Commit();
if (flipped > 0)
{
_logger.LogWarning(
"{Count} site audit row(s) were refused by central on {Max} consecutive pushes and moved out " +
"of the push queue as Rejected; the reconciliation pull still serves them (central records " +
"a ReconciliationAbandoned row if they can never be inserted).",
flipped, threshold);
// Its OWN counter, not SiteAuditWriteFailures: the local write succeeded, and
// the refusal is central's verdict on the row — blaming the site's writer for it
// would be wrong (review 2026-10-21 adversarial follow-up).
Interlocked.Add(ref _rejectedPushRows, flipped);
SiteAuditTelemetryMetrics.RecordRejectedRows(flipped);
}
return Task.FromResult(flipped);
}
}
private static void AppendIdParameters(
SqliteCommand cmd, System.Text.StringBuilder sb, IReadOnlyList<Guid> eventIds, int start, int chunk)
{
for (int i = 0; i < chunk; i++)
{
if (i > 0) sb.Append(',');
var p = $"$id{i}";
sb.Append(p);
cmd.Parameters.AddWithValue(p, eventIds[start + i].ToString());
}
}
/// <summary>
/// Test/diagnostic read of one row's sidecar state: <c>(ForwardState, AttemptCount)</c>, or
/// null when the row does not exist.
/// </summary>
/// <param name="eventId">The event to look up.</param>
/// <returns>The row's forward state and attempt count, or null.</returns>
internal (string State, int AttemptCount)? GetForwardStateForTests(Guid eventId)
{
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = "SELECT ForwardState, AttemptCount FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", eventId.ToString());
using var reader = cmd.ExecuteReader();
return reader.Read() ? (reader.GetString(0), reader.GetInt32(1)) : null;
}
}
/// <summary>Flow Debugger PullTraceEvents: hard cap on ids per read (design §5.4).</summary>
public const int MaxExecutionIdsPerRead = 64;
/// <summary>
/// Reads audit events belonging to any of the given execution ids, newer than <paramref name="sinceUtc"/>, in keyset-paged order.
/// </summary>
/// <param name="executionIds">Execution ids to match (capped at <see cref="MaxExecutionIdsPerRead"/>).</param>
/// <param name="sinceUtc">Lower bound on the event's occurrence time.</param>
/// <param name="limit">Maximum number of events to return; must be positive.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="afterEventId">Optional keyset cursor; only events after this id are returned.</param>
/// <returns>The matching audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
{
ArgumentNullException.ThrowIfNull(executionIds);
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
if (executionIds.Count == 0)
{
return Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
}
if (executionIds.Count > MaxExecutionIdsPerRead)
{
throw new ArgumentOutOfRangeException(nameof(executionIds),
$"at most {MaxExecutionIdsPerRead} execution ids per read.");
}
// Flow Debugger (design 2026-09-05 §5.4): a POINT read independent of the forwarding
// sidecar — a traced row is served whether Pending, Forwarded or Reconciled, and
// serving it moves no cursor. DetailsJson is the codec's camelCase JSON, so the paths
// are $.executionId / $.parentExecutionId; ids are stored in "D" form.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
var names = new List<string>(executionIds.Count);
var i = 0;
foreach (var id in executionIds)
{
var name = $"$e{i++}";
names.Add(name);
cmd.Parameters.AddWithValue(name, id.ToString("D"));
}
var inList = string.Join(", ", names);
cmd.CommandText = BuildTraceReadSql(inList);
cmd.Parameters.AddWithValue("$since", EnsureUtc(sinceUtc).ToString(
"o", System.Globalization.CultureInfo.InvariantCulture));
cmd.Parameters.AddWithValue("$after", (object?)afterEventId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
}
}
/// <summary>
/// Reads matching keys through both expression indexes before loading the bounded payload page.
/// UNION deduplicates rows matching both IDs. Keeping payloads outside the union bounds sorting
/// to IDs/timestamps and avoids the optimizer preferring a whole-store timestamp scan.
/// </summary>
/// <param name="inList">The pre-built SQL parameter list for the <c>IN (...)</c> clauses.</param>
/// <returns>The trace-read SQL text.</returns>
internal static string BuildTraceReadSql(string inList) => $"""
SELECT e.EventId, e.OccurredAtUtc, e.Actor, e.Action, e.Outcome,
e.Category, e.Target, e.SourceNode, e.CorrelationId, e.DetailsJson
FROM audit_event AS e
JOIN (
SELECT EventId, OccurredAtUtc
FROM audit_event INDEXED BY IX_audit_event_execution
WHERE json_valid(DetailsJson)
AND json_extract(DetailsJson, '$.executionId') IN ({inList})
AND (OccurredAtUtc > $since OR (OccurredAtUtc = $since AND ($after IS NULL OR EventId > $after)))
UNION
SELECT EventId, OccurredAtUtc
FROM audit_event INDEXED BY IX_audit_event_parent_execution
WHERE json_valid(DetailsJson)
AND json_extract(DetailsJson, '$.parentExecutionId') IN ({inList})
AND (OccurredAtUtc > $since OR (OccurredAtUtc = $since AND ($after IS NULL OR EventId > $after)))
ORDER BY OccurredAtUtc, EventId
LIMIT $limit
) AS matched ON matched.EventId = e.EventId
ORDER BY matched.OccurredAtUtc, matched.EventId;
""";
/// <summary>
/// Highest <c>audit_forward_state.rowid</c> this instance has ever SERVED from
/// <see cref="ReadPendingSinceAsync"/> — i.e. the insertion-order high-water mark of
/// rows central could possibly have received through the pull path. Bounds
/// <see cref="MarkReconciledUpToAsync"/> so a row inserted after that point can never be
/// retired by a cursor that happens to sit above its timestamp. Monotonic (a later, smaller
/// batch never lowers it) except for the purge clamp. Every WRITE happens under
/// <c>_writeLock</c> (the pull's advance and the purge's clamp alike — review 2026-10-21
/// follow-up L1), so a clamp can never be overwritten with a stale-high value; reads from
/// other paths go through <see cref="Volatile"/> for visibility. A stale read can only make
/// the bound smaller, i.e. more conservative, never unsafe.
/// </summary>
private long _maxServedRowId;
private const string MaxServedRowIdKey = "max_served_rowid";
/// <summary>
/// Restores <see cref="_maxServedRowId"/> from <c>audit_site_state</c>, clamped to the live
/// <c>MAX(rowid)</c> so a stale value can never vouch for a reused rowid. Before this the
/// bound was per-process: after a site-node restart it was 0, and because a healthy site's
/// pull now often serves nothing, owed rows central had already ingested (e.g. PushAbandoned)
/// could stay unretired — and counted on the backlog — indefinitely.
/// </summary>
private void LoadMaxServedRowId()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT MIN(
IFNULL((SELECT Value FROM audit_site_state WHERE Key = $key), 0),
IFNULL((SELECT MAX(rowid) FROM audit_forward_state), 0));
""";
cmd.Parameters.AddWithValue("$key", MaxServedRowIdKey);
var value = Convert.ToInt64(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture);
Volatile.Write(ref _maxServedRowId, value);
}
/// <summary>
/// Durably records the served high-water mark. Runs on the WRITE connection under
/// <c>_writeLock</c> (never nested inside <c>_readLock</c>). Best-effort: a failure leaves
/// the in-memory bound intact and only means a restart re-learns it from the next pull.
/// </summary>
private void PersistMaxServedRowId(long value, SqliteTransaction? transaction = null)
{
using var cmd = _connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = """
INSERT INTO audit_site_state (Key, Value) VALUES ($key, $value)
ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
""";
cmd.Parameters.AddWithValue("$key", MaxServedRowIdKey);
cmd.Parameters.AddWithValue("$value", value);
cmd.ExecuteNonQuery();
}
/// <summary>Test seam: the current in-memory served high-water mark.</summary>
internal long MaxServedRowIdForTests => Volatile.Read(ref _maxServedRowId);
/// <summary>
/// Executes a reconciliation-pull read whose projection carries <c>fs.rowid</c> as an
/// 11th column, materialising the canonical rows and reporting the highest rowid served.
/// The caller folds that into <see cref="_maxServedRowId"/> under <c>_writeLock</c> before
/// returning the rows — serving a row is what makes it eligible for cursor-proved
/// retirement, so the mark must cover a batch before central can see it.
/// </summary>
private IReadOnlyList<AuditEvent> ReadServedRows(SqliteCommand cmd, int capacityHint, out long maxRowId)
{
var rows = new List<AuditEvent>(Math.Min(capacityHint, 256));
maxRowId = 0;
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
rows.Add(MapRow(reader));
var rowId = reader.GetInt64(10);
if (rowId > maxRowId) maxRowId = rowId;
}
}
return rows;
}
/// <summary>
/// Normalises a wire-supplied event id to the exact textual form stored in
/// <c>audit_event.EventId</c> so the keyset comparison is apples-to-apples. An
/// unparseable value is passed through verbatim rather than throwing — a malformed
/// cursor must degrade to "serves a bit too much", never to a failed pull.
/// </summary>
private static string NormalizeEventId(string afterId) =>
Guid.TryParse(afterId, out var g) ? g.ToString() : afterId;
/// <inheritdoc />
public Task<int> MarkReconciledUpToAsync(
DateTime sinceUtc, string? afterId, CancellationToken ct = default)
{
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Everything at or before central's cursor is proven received. With no
// afterId the read contract is inclusive (>= $since), so only rows STRICTLY
// older than the cursor instant are proven — the boundary instant may be
// half-consumed and must stay servable.
var predicate = afterId is null
? "fs.OccurredAtUtc < $since"
: "(fs.OccurredAtUtc < $since OR (fs.OccurredAtUtc = $since AND fs.EventId <= $afterId))";
// INSERTION-ORDER BOUND — "only rows we actually served can retire".
//
// The timestamp cursor alone is not enough. OccurredAtUtc is stamped by the
// caller, so a row can be INSERTED after a batch was served yet carry a
// timestamp BELOW central's (by then advanced) cursor — a late-stamped insert.
// The blanket cursor UPDATE retired exactly those rows: never served, never
// servable again (ReadPendingSinceAsync's keyset has moved past them), and,
// being Reconciled, purged on age — silent audit loss, a failure mode the old
// explicit id-set flip could not produce.
//
// fs.rowid is insertion order, so bounding the flip at the highest rowid this
// instance has ever served restores the invariant exactly: rows inserted after
// that point are out of the flip's reach no matter where the cursor sits.
//
// Only the OWED states flip (Pending / PushAbandoned / Rejected — exactly what
// ReadPendingSinceAsync serves). Forwarded rows are no longer touched: central
// acked them over the push path, they are already purge-eligible, and flipping
// them was one UPDATE per pushed row for no benefit (review 2026-10-21 06/PERF-1).
//
// The bound is persisted in audit_site_state (loaded at construction, clamped to
// the live MAX(rowid)), so a site-node restart no longer resets it to 0 and strands
// rows the pull had already served. Conservative in the safe direction (it can
// only delay retirement, never lose a row); the liveness note in
// MarkReconciledUpToAsync's interface doc + Component-AuditLog.md covers what a
// permanently halted reconciliation means for such rows.
using var cmd = _connection.CreateCommand();
cmd.CommandText = $"""
UPDATE audit_forward_state AS fs
SET ForwardState = $reconciled
WHERE fs.ForwardState IN ($pending, $pushAbandoned, $rejected)
AND fs.rowid <= $maxServedRowId
AND {predicate};
""";
cmd.Parameters.AddWithValue("$reconciled", AuditForwardState.Reconciled.ToString());
AddOwedStateParameters(cmd);
cmd.Parameters.AddWithValue("$maxServedRowId", Volatile.Read(ref _maxServedRowId));
cmd.Parameters.AddWithValue("$since", EnsureUtc(sinceUtc).ToString(
"o", System.Globalization.CultureInfo.InvariantCulture));
if (afterId is not null)
{
cmd.Parameters.AddWithValue("$afterId", NormalizeEventId(afterId));
}
return Task.FromResult(cmd.ExecuteNonQuery());
}
}
/// <inheritdoc />
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventIds);
if (eventIds.Count == 0)
{
return Task.CompletedTask;
}
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Flip the sidecar from Pending/Forwarded (or a push-exit state) → Reconciled.
// Rows already Reconciled are left untouched (idempotent re-call), and the
// canonical audit_event row is never modified.
var reconciled = AuditForwardState.Reconciled.ToString();
var pending = AuditForwardState.Pending.ToString();
var forwarded = AuditForwardState.Forwarded.ToString();
// Chunk into ≤MarkForwardStateChunkSize-id UPDATEs, all inside ONE
// transaction so the whole batch commits atomically (arch-review 04, P8).
using var transaction = _connection.BeginTransaction();
for (int start = 0; start < eventIds.Count; start += MarkForwardStateChunkSize)
{
var chunk = Math.Min(MarkForwardStateChunkSize, eventIds.Count - start);
using var cmd = _connection.CreateCommand();
cmd.Transaction = transaction;
var sb = new System.Text.StringBuilder();
sb.Append("UPDATE audit_forward_state SET ForwardState = $reconciled ")
.Append("WHERE ForwardState IN ($pending, $forwarded, $pushAbandoned, $rejected) AND EventId IN (");
for (int i = 0; i < chunk; i++)
{
if (i > 0) sb.Append(',');
var p = $"$id{i}";
sb.Append(p);
cmd.Parameters.AddWithValue(p, eventIds[start + i].ToString());
}
sb.Append(");");
cmd.CommandText = sb.ToString();
cmd.Parameters.AddWithValue("$reconciled", reconciled);
cmd.Parameters.AddWithValue("$pending", pending);
cmd.Parameters.AddWithValue("$forwarded", forwarded);
cmd.Parameters.AddWithValue("$pushAbandoned", SiteAuditForwardStates.PushAbandoned);
cmd.Parameters.AddWithValue("$rejected", SiteAuditForwardStates.Rejected);
cmd.ExecuteNonQuery();
}
transaction.Commit();
return Task.CompletedTask;
}
}
/// <inheritdoc />
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
{
int pendingCount;
DateTime? oldestPending;
// Read via the dedicated _readConnection (under
// _readLock) so this probe — polled every 30 s by SiteAuditBacklogReporter
// — never blocks the batched hot-path writer on _writeLock. Under a
// central outage the Pending backlog can grow to hundreds of thousands
// of rows and the COUNT(*) scan correspondingly stretches; that no
// longer adds tail latency to user-facing audit writes.
// Count over the sidecar (audit_forward_state) — the canonical
// audit_event table carries no ForwardState. The IX_fwd index makes both
// aggregates cheap (count is a covering scan, min is the first key).
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
// "Pending" on the health report means OWED to central: the push-exit states
// (PushAbandoned / Rejected) still await the reconciliation pull and are never
// purged, so they count toward the backlog and its oldest-row age.
cmd.CommandText = """
SELECT COUNT(*), MIN(OccurredAtUtc)
FROM audit_forward_state
WHERE ForwardState IN ($pending, $pushAbandoned, $rejected);
""";
AddOwedStateParameters(cmd);
using var reader = cmd.ExecuteReader();
reader.Read();
pendingCount = reader.GetInt32(0);
oldestPending = reader.IsDBNull(1)
? null
: DateTime.Parse(reader.GetString(1),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind);
}
// File-size lookup outside the lock — the DatabasePath option is the
// canonical source. The connection-string-override branch (used by
// some tests) keeps the same DatabasePath value, so this works
// uniformly. In-memory / mode=memory paths return 0 because the file
// doesn't exist on disk.
long onDiskBytes = 0;
try
{
if (!string.IsNullOrEmpty(_options.DatabasePath) &&
!_options.DatabasePath.StartsWith(":memory:", StringComparison.Ordinal) &&
!_options.DatabasePath.Contains("mode=memory", StringComparison.OrdinalIgnoreCase) &&
File.Exists(_options.DatabasePath))
{
onDiskBytes = new FileInfo(_options.DatabasePath).Length;
}
}
catch (Exception ex)
{
// File system probe is a best-effort health-metric — never abort
// a backlog snapshot because stat() failed. Log and report 0.
_logger.LogDebug(ex,
"SqliteAuditWriter could not stat DB path {Path} for backlog snapshot.",
_options.DatabasePath);
}
return Task.FromResult(new SiteAuditBacklogSnapshot(
PendingCount: pendingCount,
OldestPendingUtc: oldestPending,
OnDiskBytes: onDiskBytes));
}
/// <inheritdoc />
/// <remarks>
/// <b>Paged (review 2026-10-21 06/MEM-1).</b> The purge deletes at most
/// <see cref="SqliteAuditWriterOptions.PurgeBatchSize"/> rows per transaction and runs a
/// bounded <c>PRAGMA incremental_vacuum(N)</c> after each page, taking <c>_writeLock</c>
/// per page and releasing it in between, so the batched hot-path writer interleaves with
/// a multi-million-row purge rather than stalling behind one giant DELETE + an unbounded
/// vacuum. Every eligible row is still removed by one call; only the lock grain changed.
/// </remarks>
public async Task<int> PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
// Cutoff uses the same round-trip "o" encoding the writer stores
// OccurredAtUtc in, so string comparison stays monotonic (see FlushBatch).
var cutoff = EnsureUtc(olderThanUtc).ToString(
"o", System.Globalization.CultureInfo.InvariantCulture);
var pageSize = Math.Max(1, _options.PurgeBatchSize);
var total = 0;
while (true)
{
ct.ThrowIfCancellationRequested();
var purged = PurgePage(cutoff, pageSize);
total += purged;
if (purged < pageSize)
{
return total;
}
// Lock already released by PurgePage; yield so the background writer (and any
// flip/read waiting on _writeLock) gets the lock before the next page.
await Task.Yield();
}
}
/// <summary>
/// Number of purge-page transactions committed since construction. Test-only
/// observability seam for asserting the purge is paged; production code never reads it.
/// </summary>
internal int PurgePageCountForTests => Volatile.Read(ref _purgePageCountForTests);
private int _purgePageCountForTests;
/// <summary>
/// Deletes ONE page (at most <paramref name="pageSize"/> rows) of purge-eligible rows in a
/// single transaction under <c>_writeLock</c>, then reclaims a bounded number of freed
/// pages. Returns the number of canonical rows deleted.
/// </summary>
private int PurgePage(string cutoff, int pageSize)
{
// Runs on the WRITE connection under _writeLock (a DELETE mutates the file),
// mirroring MarkForwardedAsync / MarkReconciledAsync. Serialising with the
// batched writer means the purge never races an in-flight FlushBatch.
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
int purged;
using (var transaction = _connection.BeginTransaction())
{
try
{
// Capture this page's eligible EventIds from the sidecar FIRST: the
// Forwarded/Reconciled + age criteria live on audit_forward_state, and
// it is the FK child of audit_event, so it must be deleted before the
// parent. A temp table (rather than an IN (...) list) keeps the page off
// the SQLite 999-parameter limit. The hard invariant is encoded here:
// only Forwarded/Reconciled rows qualify — Pending (and the pull-owed
// PushAbandoned/Rejected states) are never purged on age alone.
using (var createCmd = _connection.CreateCommand())
{
createCmd.Transaction = transaction;
createCmd.CommandText = """
DROP TABLE IF EXISTS purge_ids;
CREATE TEMP TABLE purge_ids AS
SELECT EventId FROM audit_forward_state
WHERE ForwardState IN ($forwarded, $reconciled)
AND OccurredAtUtc < $cutoff
LIMIT $limit;
""";
createCmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
createCmd.Parameters.AddWithValue("$reconciled", AuditForwardState.Reconciled.ToString());
createCmd.Parameters.AddWithValue("$cutoff", cutoff);
createCmd.Parameters.AddWithValue("$limit", pageSize);
createCmd.ExecuteNonQuery();
}
// Delete the sidecar (FK child) first, then the canonical row.
using (var delSidecar = _connection.CreateCommand())
{
delSidecar.Transaction = transaction;
delSidecar.CommandText =
"DELETE FROM audit_forward_state WHERE EventId IN (SELECT EventId FROM purge_ids);";
delSidecar.ExecuteNonQuery();
}
using (var delEvent = _connection.CreateCommand())
{
delEvent.Transaction = transaction;
delEvent.CommandText =
"DELETE FROM audit_event WHERE EventId IN (SELECT EventId FROM purge_ids);";
delEvent.ExecuteNonQuery();
}
// changes() reports rows affected by the most recent DELETE on
// this connection (the audit_event delete above); read it before
// the DROP so no later statement can perturb the count.
using (var changesCmd = _connection.CreateCommand())
{
changesCmd.Transaction = transaction;
changesCmd.CommandText = "SELECT changes();";
purged = Convert.ToInt32(changesCmd.ExecuteScalar(),
System.Globalization.CultureInfo.InvariantCulture);
}
using (var dropCmd = _connection.CreateCommand())
{
dropCmd.Transaction = transaction;
dropCmd.CommandText = "DROP TABLE IF EXISTS purge_ids;";
dropCmd.ExecuteNonQuery();
}
// Clamp the served-rows high-water mark to what is still in the table.
// SQLite hands a fresh insert MAX(rowid)+1, so a purge that empties the
// sidecar (or removes its top rows) makes rowids REUSABLE — a stale-high
// _maxServedRowId would then vouch for rows nobody has served. Cheap:
// MAX(rowid) is an index-less O(1) lookup.
if (purged > 0)
{
using var maxCmd = _connection.CreateCommand();
maxCmd.Transaction = transaction;
maxCmd.CommandText = "SELECT IFNULL(MAX(rowid), 0) FROM audit_forward_state;";
var liveMax = Convert.ToInt64(maxCmd.ExecuteScalar(),
System.Globalization.CultureInfo.InvariantCulture);
if (liveMax < Volatile.Read(ref _maxServedRowId))
{
Volatile.Write(ref _maxServedRowId, liveMax);
PersistMaxServedRowId(liveMax, transaction);
}
}
transaction.Commit();
Interlocked.Increment(ref _purgePageCountForTests);
}
catch
{
transaction.Rollback();
throw;
}
}
// Reclaim a BOUNDED number of freed pages back to the OS. A bare
// `PRAGMA incremental_vacuum;` empties the whole freelist in one step, which
// after a large purge held _writeLock for as long as the delete itself did.
// auto_vacuum=INCREMENTAL (set in InitializeSchema) makes this a cheap
// freelist trim. Runs outside the transaction; a vacuum failure must not fault
// the purge itself, so it is best-effort.
if (purged > 0)
{
try
{
using var vacuumCmd = _connection.CreateCommand();
vacuumCmd.CommandText = string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"PRAGMA incremental_vacuum({Math.Max(1, _options.PurgeVacuumPagesPerBatch)});");
vacuumCmd.ExecuteNonQuery();
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"SqliteAuditWriter incremental_vacuum after retention purge failed (non-fatal).");
}
}
return purged;
}
}
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc
? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
/// <summary>
/// Executes <paramref name="cmd"/> (one of the four reads, each already
/// projecting the 10 <c>audit_event</c> columns in canonical order) and
/// materialises the rows via <see cref="MapRow"/>.
/// </summary>
private static IReadOnlyList<AuditEvent> ReadRows(SqliteCommand cmd, int capacityHint)
{
var rows = new List<AuditEvent>(Math.Min(capacityHint, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return rows;
}
/// <summary>
/// Builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10
/// stored <c>audit_event</c> columns — no 24-column <c>Recompose</c>, because
/// <c>audit_event</c> already holds the canonical fields + <c>DetailsJson</c>.
/// <c>Outcome</c> is stored as the enum's name; the safe
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> degrades an unknown/renamed
/// value gracefully rather than throwing.
/// </summary>
private static AuditEvent MapRow(SqliteDataReader reader)
{
return new AuditEvent
{
EventId = Guid.Parse(reader.GetString(0)),
OccurredAtUtc = new DateTimeOffset(DateTime.SpecifyKind(
DateTime.Parse(reader.GetString(1),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind),
DateTimeKind.Utc)),
Actor = reader.GetString(2),
Action = reader.GetString(3),
Outcome = AuditRowProjection.ParseEnum(reader.GetString(4), AuditOutcome.Success),
Category = reader.IsDBNull(5) ? null : reader.GetString(5),
Target = reader.IsDBNull(6) ? null : reader.GetString(6),
SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
};
}
/// <summary>
/// Disposes the audit writer and releases resources.
/// </summary>
/// <remarks>
/// Prefer <see cref="DisposeAsync"/> when possible (DI honours
/// <see cref="IAsyncDisposable"/> on singletons). The sync path remains for
/// callers that only know about <see cref="IDisposable"/> (e.g. legacy
/// composition roots, <c>using</c> statements without <c>await</c>). To
/// avoid the classic sync-over-async deadlock on a captured
/// <see cref="SynchronizationContext"/> (ASP.NET request thread, Akka
/// dispatcher under some configurations), we hop to the thread pool via
/// <see cref="Task.Run(Func{Task})"/> before blocking on the result — the
/// async continuation inside <see cref="DisposeAsync"/> then resumes on a
/// pool thread with no captured context, so <c>GetResult()</c> never waits
/// on the very thread the continuation needs.
/// </remarks>
public void Dispose()
{
Task.Run(async () => await DisposeAsync().ConfigureAwait(false))
.GetAwaiter().GetResult();
}
/// <summary>Asynchronously disposes the audit writer and releases resources.</summary>
/// <returns>A <see cref="ValueTask"/> that completes when all resources have been released.</returns>
public async ValueTask DisposeAsync()
{
Task? writerLoop;
lock (_writeLock)
{
if (_disposed) return;
Volatile.Write(ref _writerCompleted, true);
// Stop accepting new events. Completing the channel writer is the
// shutdown signal: WriteAsync calls observe the completion and
// fault, and the writer loop drains any already-buffered items
// before exiting. _disposed is intentionally NOT set here — it
// flips only after the loop has fully drained (second lock block
// below), so FlushBatch's existing _disposed check guards the
// post-drain window when the connection is about to close.
_writeQueue.Writer.TryComplete();
writerLoop = _writerLoop;
}
// Wait outside the lock — the loop reacquires it for each batch.
try
{
if (writerLoop is not null)
{
await writerLoop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
}
catch (TimeoutException)
{
_logger.LogWarning("SqliteAuditWriter writer loop did not drain within 5s of dispose.");
}
catch (Exception ex)
{
// The loop's per-batch try/catch already routed individual failures
// to pending TCSes; a top-level fault here is unexpected.
_logger.LogError(ex, "SqliteAuditWriter writer loop faulted during dispose.");
}
lock (_writeLock)
{
if (_disposed) return;
_disposed = true;
_connection.Dispose();
}
// Dispose the dedicated read connection after the writer
// is fully drained and closed. _readLock is taken to fence out any
// in-flight read caller that grabbed the lock before _disposed flipped
// — they observe ObjectDisposedException on the next attempt.
lock (_readLock)
{
_readConnection.Dispose();
}
}
/// <summary>An audit event awaiting persistence by the background writer.</summary>
private sealed class PendingAuditEvent
{
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
/// <param name="evt">The canonical audit event to persist.</param>
/// <param name="forwardState">Initial site-local forwarding state written to the sidecar row (always Pending for fresh events).</param>
public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
{
Event = evt;
ForwardState = forwardState;
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>The canonical audit event to persist.</summary>
public AuditEvent Event { get; }
/// <summary>Initial forwarding state for this row's sidecar (bound to audit_forward_state.ForwardState).</summary>
public AuditForwardState ForwardState { get; }
/// <summary>Task completion source for write completion signaling.</summary>
public TaskCompletionSource Completion { get; }
}
}