feat(audit): ScadaBridge C4 — site SQLite two-table (audit_event canonical + audit_forward_state sidecar), forwarding on sidecar, IsCachedKind drain split (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 13:11:20 -04:00
parent c27b2c3d5f
commit 946d3e2aef
3 changed files with 689 additions and 704 deletions
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent; using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -19,15 +20,27 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The schema is bootstrapped in the constructor (Bundle B-T1). The /// <b>C4 (Task 2.5) — two-table schema.</b> The site store is now two tables:
/// Channel-based <see cref="WriteAsync"/> hot-path + Bundle D /// the append-only canonical <c>audit_event</c> (the 10 canonical
/// <see cref="ReadPendingAsync"/> / <see cref="MarkForwardedAsync"/> support /// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
/// surface are wired in Bundle B-T2. /// 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 C4's 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 a pre-C4 <c>AuditLog</c> table are within the retention
/// window and are discarded by the drop.
/// </para> /// </para>
/// <para> /// <para>
/// Site rows always carry <see cref="AuditForwardState.Pending"/> on first /// Site rows always carry <see cref="AuditForwardState.Pending"/> on first
/// insert; the central row-shape's <c>IngestedAtUtc</c> column does NOT live in /// insert; the central row-shape's <c>IngestedAtUtc</c> is a DetailsJson field
/// the site SQLite schema — central stamps it on ingest. /// stamped by central on ingest, not a site column.
/// </para> /// </para>
/// </remarks> /// </remarks>
public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable, IDisposable public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable, IDisposable
@@ -36,8 +49,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY) // on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY)
// is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably // is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably
// surfaced across all SQLite builds. We treat any constraint error on insert // surfaced across all SQLite builds. We treat any constraint error on insert
// as a duplicate-eventid race and swallow it (first-write-wins) — the index // as a duplicate-eventid race and swallow it (first-write-wins) — the PRIMARY
// on EventId is the only constraint on this table, so this scope is precise. // 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; private const int SqliteErrorConstraint = 19;
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
@@ -141,95 +156,63 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery(); pragmaCmd.ExecuteNonQuery();
} }
using var cmd = _connection.CreateCommand(); // C4 (Task 2.5) — in-place reset. The site store is EPHEMERAL (≈7-day
cmd.CommandText = """ // retention, recreated per deployment), so we do NOT migrate the old
CREATE TABLE IF NOT EXISTS AuditLog ( // single 24-column AuditLog table to the new two-table shape: any rows
EventId TEXT NOT NULL, // it holds are within the retention window and discarded. DROP it if a
OccurredAtUtc TEXT NOT NULL, // pre-C4 deployment left it behind, then CREATE the two new tables. This
Channel TEXT NOT NULL, // is safe precisely BECAUSE the site store is ephemeral — never do this
Kind TEXT NOT NULL, // on a durable store (the central SQL Server side keeps its shim until
CorrelationId TEXT NULL, // C5 and is migrated, not reset).
SourceSiteId TEXT NULL, using (var dropCmd = _connection.CreateCommand())
SourceNode TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
ParentExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
cmd.ExecuteNonQuery();
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
// table that already exists from a pre-ExecutionId build, so an
// auditlog.db created by an older build needs the column ALTER-ed in.
// The file is durable across restart/failover by design (7-day
// retention), so without this step every WriteAsync on an upgraded
// deployment would bind $ExecutionId against a missing column and the
// best-effort write path would silently drop every site audit row.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. The column is
// nullable with no default, so any row written before this migration
// reads back ExecutionId = null (back-compat).
AddColumnIfMissing("ExecutionId", "TEXT NULL");
// Audit Log #23 (ParentExecutionId): same idempotent upgrade path as
// ExecutionId above. A deployment that already ran the ExecutionId
// branch has an auditlog.db with the 21-column schema and no
// ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it,
// so it is ALTER-ed in here. Nullable with no default — rows written
// before this migration read back ParentExecutionId = null.
AddColumnIfMissing("ParentExecutionId", "TEXT NULL");
// SourceNode stamping: same idempotent upgrade path as ExecutionId /
// ParentExecutionId above. A deployment that already ran the
// ParentExecutionId branch has an auditlog.db with the 22-column
// schema and no SourceNode column; CREATE TABLE IF NOT EXISTS cannot
// add it, so it is ALTER-ed in here. Nullable with no default — rows
// written before this migration read back SourceNode = null.
AddColumnIfMissing("SourceNode", "TEXT NULL");
}
/// <summary>
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when
/// it is not already present (used for <c>ExecutionId</c> and
/// <c>ParentExecutionId</c>). SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
/// here to match the rest of this writer's bootstrap DDL.
/// </summary>
private void AddColumnIfMissing(string columnName, string columnDefinition)
{
using var probe = _connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
probe.Parameters.AddWithValue("$name", columnName);
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
if (exists)
{ {
return; dropCmd.CommandText = "DROP TABLE IF EXISTS AuditLog;";
dropCmd.ExecuteNonQuery();
} }
using var alter = _connection.CreateCommand(); using var cmd = _connection.CreateCommand();
// Column name + definition are caller-controlled constants, never user cmd.CommandText = """
// input — safe to interpolate (parameters are not permitted in DDL). -- Canonical, append-only / write-once: the 10 fields of the canonical
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}"; -- ZB.MOM.WW.Audit.AuditEvent stored directly (DetailsJson carries the
alter.ExecuteNonQuery(); -- 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);
""";
cmd.ExecuteNonQuery();
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -237,9 +220,9 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
// C3 transitional shim: the canonical record carries no ForwardState // The canonical record carries no ForwardState (a site-storage-only
// (a site-storage-only concern). Site rows always start Pending; the // concern). Site rows always start Pending; the sidecar row is written
// forwarding columns + queries are unchanged from the 24-column schema. // alongside the canonical row in the same transaction.
var pending = new PendingAuditEvent(evt, AuditForwardState.Pending); var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather // CreateBounded(FullMode=Wait) means WriteAsync will await room rather
@@ -313,101 +296,99 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
using var transaction = _connection.BeginTransaction(); using var transaction = _connection.BeginTransaction();
try try
{ {
using var cmd = _connection.CreateCommand(); // INSERT 1: the canonical row, stored DIRECTLY (the 10 canonical
cmd.Transaction = transaction; // fields straight off the AuditEvent — no Decompose; audit_event
cmd.CommandText = """ // holds canonical shape, not the legacy 24-column shape).
INSERT INTO AuditLog ( using var eventCmd = _connection.CreateCommand();
EventId, OccurredAtUtc, Channel, Kind, CorrelationId, eventCmd.Transaction = transaction;
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, eventCmd.CommandText = """
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, INSERT INTO audit_event (
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, EventId, OccurredAtUtc, Actor, Action, Outcome,
ExecutionId, ParentExecutionId Category, Target, SourceNode, CorrelationId, DetailsJson
) VALUES ( ) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, $EventId, $OccurredAtUtc, $Actor, $Action, $Outcome,
$SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target, $Category, $Target, $SourceNode, $CorrelationId, $DetailsJson
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId, $ParentExecutionId
); );
"""; """;
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);
var pEventId = cmd.Parameters.Add("$EventId", SqliteType.Text); // INSERT 2: the operational sidecar row. ForwardState=Pending,
var pOccurredAt = cmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text); // OccurredAtUtc duplicated for the drain index, IsCachedKind
var pChannel = cmd.Parameters.Add("$Channel", SqliteType.Text); // precomputed (so the read split never parses DetailsJson),
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text); // AttemptCount=0, LastAttemptUtc=NULL.
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text); using var fwdCmd = _connection.CreateCommand();
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text); fwdCmd.Transaction = transaction;
var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text); fwdCmd.CommandText = """
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text); INSERT INTO audit_forward_state (
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text); EventId, ForwardState, OccurredAtUtc, IsCachedKind, AttemptCount, LastAttemptUtc
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text); ) VALUES (
var pTarget = cmd.Parameters.Add("$Target", SqliteType.Text); $EventId, $ForwardState, $OccurredAtUtc, $IsCachedKind, 0, NULL
var pStatus = cmd.Parameters.Add("$Status", SqliteType.Text); );
var pHttpStatus = cmd.Parameters.Add("$HttpStatus", SqliteType.Integer); """;
var pDurationMs = cmd.Parameters.Add("$DurationMs", SqliteType.Integer); var fEventId = fwdCmd.Parameters.Add("$EventId", SqliteType.Text);
var pErrorMessage = cmd.Parameters.Add("$ErrorMessage", SqliteType.Text); var fForwardState = fwdCmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pErrorDetail = cmd.Parameters.Add("$ErrorDetail", SqliteType.Text); var fOccurredAt = fwdCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
var pRequestSummary = cmd.Parameters.Add("$RequestSummary", SqliteType.Text); var fIsCachedKind = fwdCmd.Parameters.Add("$IsCachedKind", SqliteType.Integer);
var pResponseSummary = cmd.Parameters.Add("$ResponseSummary", SqliteType.Text);
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text);
foreach (var pending in batch) foreach (var pending in batch)
{ {
// C3 transitional shim: decompose the canonical record into var evt = pending.Event;
// the typed 24-column values the existing SQLite schema // Canonical OccurredAtUtc is UTC by construction; store the
// expects (Channel/Kind/Status + the DetailsJson domain // round-trip "o" form so string comparison stays monotonic
// fields). ForwardState rides alongside the canonical record // (the drain range-scan and ORDER BY rely on it).
// (site-storage-only) and is bound from pending.ForwardState. var occurredText = evt.OccurredAtUtc.UtcDateTime.ToString(
var r = AuditRowProjection.Decompose(pending.Event); "o", System.Globalization.CultureInfo.InvariantCulture);
pEventId.Value = r.EventId.ToString();
pOccurredAt.Value = r.OccurredAtUtc.ToString("o"); eEventId.Value = evt.EventId.ToString();
pChannel.Value = r.Channel.ToString(); eOccurredAt.Value = occurredText;
pKind.Value = r.Kind.ToString(); // Canonical Actor is a required non-null string.
pCorrelationId.Value = (object?)r.CorrelationId?.ToString() ?? DBNull.Value; eActor.Value = evt.Actor ?? string.Empty;
pSourceSiteId.Value = (object?)r.SourceSiteId ?? DBNull.Value; 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 // SourceNode-stamping: caller-provided value wins (preserves
// rows reconciled in from other nodes via the same writer); // rows reconciled in from other nodes via the same writer);
// otherwise stamp from the local INodeIdentityProvider. The // otherwise stamp from the local INodeIdentityProvider. The
// event record itself is NOT mutated — stamping is at write // event record itself is NOT mutated — stamping is at write
// time only. If the provider also returns null (unconfigured // time only. If the provider also returns null (unconfigured
// node), the row's SourceNode stays NULL — operators see // node), the column stays NULL — operators see "needs config"
// "needs config" via the schema, not a magic fallback string. // via the schema, not a magic fallback string.
var sourceNode = r.SourceNode ?? _nodeIdentity.NodeName; var sourceNode = evt.SourceNode ?? _nodeIdentity.NodeName;
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value; eSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
pSourceInstanceId.Value = (object?)r.SourceInstanceId ?? DBNull.Value; eCorrelationId.Value = (object?)evt.CorrelationId?.ToString() ?? DBNull.Value;
pSourceScript.Value = (object?)r.SourceScript ?? DBNull.Value; eDetailsJson.Value = (object?)evt.DetailsJson ?? DBNull.Value;
pActor.Value = (object?)r.Actor ?? DBNull.Value;
pTarget.Value = (object?)r.Target ?? DBNull.Value; fEventId.Value = evt.EventId.ToString();
pStatus.Value = r.Status.ToString(); fForwardState.Value = pending.ForwardState.ToString();
pHttpStatus.Value = (object?)r.HttpStatus ?? DBNull.Value; fOccurredAt.Value = occurredText;
pDurationMs.Value = (object?)r.DurationMs ?? DBNull.Value; fIsCachedKind.Value = IsCachedKind(evt.DetailsJson) ? 1 : 0;
pErrorMessage.Value = (object?)r.ErrorMessage ?? DBNull.Value;
pErrorDetail.Value = (object?)r.ErrorDetail ?? DBNull.Value;
pRequestSummary.Value = (object?)r.RequestSummary ?? DBNull.Value;
pResponseSummary.Value = (object?)r.ResponseSummary ?? DBNull.Value;
pPayloadTruncated.Value = r.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)r.Extra ?? DBNull.Value;
pForwardState.Value = pending.ForwardState.ToString();
pExecutionId.Value = (object?)r.ExecutionId?.ToString() ?? DBNull.Value;
pParentExecutionId.Value = (object?)r.ParentExecutionId?.ToString() ?? DBNull.Value;
try try
{ {
cmd.ExecuteNonQuery(); eventCmd.ExecuteNonQuery();
fwdCmd.ExecuteNonQuery();
pending.Completion.TrySetResult(); pending.Completion.TrySetResult();
} }
catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint) catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint)
{ {
// Duplicate EventId — first-write-wins (alog.md §11). // Duplicate EventId — first-write-wins (alog.md §11). The
// Treat as success: the lifecycle event is durably // audit_event PRIMARY KEY throws before the sidecar insert
// recorded under the first writer's payload. // runs, so neither table gains a second row. Treat as
// success: the lifecycle event is durably recorded under
// the first writer's payload.
_logger.LogDebug(ex, _logger.LogDebug(ex,
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter", "Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
r.EventId); evt.EventId);
pending.Completion.TrySetResult(); pending.Completion.TrySetResult();
} }
} }
@@ -429,17 +410,36 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry // AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry
// drain (joined with the operational tracking row + pushed via // drain (joined with the operational tracking row + pushed via
// IngestCachedTelemetryAsync into the central dual-write transaction). // IngestCachedTelemetryAsync into the central dual-write transaction).
// ReadPendingAsync EXCLUDES these so the audit-only drain doesn't double-emit // C4: this is the SAME set the pre-C4 ReadPendingCachedTelemetryAsync query
// them; ReadPendingCachedTelemetryAsync below is the dedicated read surface // filtered on (Kind IN (...)); it is now precomputed into the sidecar's
// the new SiteAuditTelemetryActor cached-drain uses. // IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
private static readonly string[] CachedTelemetryKindNames = // integer predicate, not a JSON parse. ReadPendingAsync drains everything
// with IsCachedKind=0; ReadPendingCachedTelemetryAsync drains IsCachedKind=1.
private static readonly HashSet<AuditKind> CachedTelemetryKinds = new()
{ {
nameof(AuditKind.CachedSubmit), AuditKind.CachedSubmit,
nameof(AuditKind.ApiCallCached), AuditKind.ApiCallCached,
nameof(AuditKind.DbWriteCached), AuditKind.DbWriteCached,
nameof(AuditKind.CachedResolve), AuditKind.CachedResolve,
}; };
/// <summary>
/// C4: 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);
}
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default) public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
{ {
@@ -451,47 +451,35 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-005: read via the dedicated _readConnection so this scan // AuditLog-005: read via the dedicated _readConnection so this scan
// (which can be expensive when the backlog grows under a central // (which can be expensive when the backlog grows under a central
// outage) does not block the batched writer on _writeLock. WAL mode // outage) does not block the batched writer on _writeLock. WAL mode
// gives us a stable snapshot of the table while writes proceed on the // gives us a stable snapshot of the tables while writes proceed on the
// writer connection. _readLock serialises this connection across // writer connection. _readLock serialises this connection across
// multiple concurrent read callers since SqliteConnection itself is // multiple concurrent read callers since SqliteConnection itself is
// not thread-safe. // not thread-safe.
// AuditLog-001: NOT IN ($cached1,$cached2,$cached3,$cached4) excludes the // C4: JOIN the sidecar and filter on IsCachedKind=0 — the cached-
// cached-lifecycle kinds — they flow through ReadPendingCachedTelemetryAsync // lifecycle kinds (IsCachedKind=1) flow through
// + the combined-telemetry drain. Kind is stored as the enum's name (see // ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
// FlushBatch's pKind.Value), so a string-IN against the constant kind // split is a precomputed integer predicate on the indexed sidecar, not
// names matches the on-disk shape exactly. // a DetailsJson parse. Ordering is by the sidecar's OccurredAtUtc with
// EventId as the deterministic tiebreaker.
lock (_readLock) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState = $pending
FROM AuditLog AND fs.IsCachedKind = 0
WHERE ForwardState = $pending ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
AND Kind NOT IN ($k0, $k1, $k2, $k3)
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
cmd.Parameters.AddWithValue("$limit", limit); cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256)); return Task.FromResult(ReadRows(cmd, limit));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
@@ -504,42 +492,29 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0."); throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
} }
// AuditLog-001: dedicated read surface for the cached-call lifecycle // AuditLog-001 / C4: dedicated read surface for the cached-call lifecycle
// drain — symmetric to ReadPendingAsync but filtered to the four // drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
// cached AuditKinds. Same _readConnection + _readLock pattern so the // Same _readConnection + _readLock pattern so the hot-path writer is not
// hot-path writer is not contended. // contended.
lock (_readLock) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState = $pending
FROM AuditLog AND fs.IsCachedKind = 1
WHERE ForwardState = $pending ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
AND Kind IN ($k0, $k1, $k2, $k3)
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
cmd.Parameters.AddWithValue("$limit", limit); cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256)); return Task.FromResult(ReadRows(cmd, limit));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
@@ -565,34 +540,27 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection / // AuditLog-005: mirror ReadPendingAsync — read via _readConnection /
// _readLock so this query never contends with the batched writer on // _readLock so this query never contends with the batched writer on
// _writeLock. // _writeLock. C4: JOIN the sidecar and filter on ForwardState='Forwarded'
// (no IsCachedKind split — both cached and non-cached Forwarded rows are
// returned, as before).
lock (_readLock) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState = $forwarded
FROM AuditLog ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString()); cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
cmd.Parameters.AddWithValue("$limit", limit); cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256)); return Task.FromResult(ReadRows(cmd, limit));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
@@ -610,11 +578,16 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand(); using var cmd = _connection.CreateCommand();
// Build a single IN (...) parameter list so we issue one UPDATE per // C4: flip the sidecar — UPDATE audit_forward_state, not the canonical
// batch regardless of size. Each id is bound as its own parameter, // audit_event (which is append-only / write-once). Bump AttemptCount +
// so no string concatenation of user data ever enters the SQL. // stamp LastAttemptUtc so operators can see how many drain passes a row
// took to forward. Build a single IN (...) parameter list so we issue
// one UPDATE per batch regardless of size. Each id is bound as its own
// parameter, so no string concatenation of user data ever enters the SQL.
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
sb.Append("UPDATE AuditLog SET ForwardState = $forwarded WHERE EventId IN ("); sb.Append("UPDATE audit_forward_state SET ForwardState = $forwarded, ")
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
.Append("WHERE EventId IN (");
for (int i = 0; i < eventIds.Count; i++) for (int i = 0; i < eventIds.Count; i++)
{ {
if (i > 0) sb.Append(','); if (i > 0) sb.Append(',');
@@ -625,6 +598,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
sb.Append(");"); sb.Append(");");
cmd.CommandText = sb.ToString(); cmd.CommandText = sb.ToString();
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString()); cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString(
"o", System.Globalization.CultureInfo.InvariantCulture));
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
return Task.CompletedTask; return Task.CompletedTask;
@@ -641,22 +616,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
// AuditLog-005: read via _readConnection / _readLock — same lock- // AuditLog-005: read via _readConnection / _readLock — same lock-
// decoupling as ReadPendingAsync. // decoupling as ReadPendingAsync. C4: JOIN the sidecar; the range scan
// is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd.
// Both Pending and Forwarded rows are returned (the central reconciliation
// puller dedups on EventId; re-shipping a Forwarded-but-not-yet-ingested
// row is safe).
lock (_readLock) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState IN ($pending, $forwarded)
FROM AuditLog AND fs.OccurredAtUtc >= $since
WHERE ForwardState IN ($pending, $forwarded) ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
AND OccurredAtUtc >= $since
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
@@ -668,14 +645,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
"o", System.Globalization.CultureInfo.InvariantCulture)); "o", System.Globalization.CultureInfo.InvariantCulture));
cmd.Parameters.AddWithValue("$limit", batchSize); cmd.Parameters.AddWithValue("$limit", batchSize);
var rows = new List<AuditEvent>(Math.Min(batchSize, 256)); return Task.FromResult(ReadRows(cmd, batchSize));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
@@ -693,8 +663,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand(); using var cmd = _connection.CreateCommand();
// C4: flip the sidecar from Pending/Forwarded → Reconciled. Rows
// already Reconciled are left untouched (idempotent re-call), and the
// canonical audit_event row is never modified.
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
sb.Append("UPDATE AuditLog SET ForwardState = $reconciled ") sb.Append("UPDATE audit_forward_state SET ForwardState = $reconciled ")
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN ("); .Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
for (int i = 0; i < eventIds.Count; i++) for (int i = 0; i < eventIds.Count; i++)
{ {
@@ -726,18 +699,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// central outage the Pending backlog can grow to hundreds of thousands // central outage the Pending backlog can grow to hundreds of thousands
// of rows and the COUNT(*) scan correspondingly stretches; that no // of rows and the COUNT(*) scan correspondingly stretches; that no
// longer adds tail latency to user-facing audit writes. // longer adds tail latency to user-facing audit writes.
// C4: 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) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
// Single round-trip — COUNT(*) + MIN(OccurredAtUtc) over the same
// index range avoids a second scan. The IX_SiteAuditLog_ForwardState_Occurred
// index makes both aggregates cheap (count is a covering scan, min
// is the first key).
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT COUNT(*), MIN(OccurredAtUtc) SELECT COUNT(*), MIN(OccurredAtUtc)
FROM AuditLog FROM audit_forward_state
WHERE ForwardState = $pending; WHERE ForwardState = $pending;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
@@ -788,38 +760,49 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
? value ? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc); : 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>
/// C4: 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) private static AuditEvent MapRow(SqliteDataReader reader)
{ {
// C3 transitional shim: recompose the canonical record from the 24 return new AuditEvent
// columns. The ForwardState column (ordinal 20) is read for the {
// schema's sake but NOT placed on the canonical record — it stays a EventId = Guid.Parse(reader.GetString(0)),
// site-storage-only concern (the forwarding queries below own it). OccurredAtUtc = new DateTimeOffset(DateTime.SpecifyKind(
return AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues( DateTime.Parse(reader.GetString(1),
EventId: Guid.Parse(reader.GetString(0)), System.Globalization.CultureInfo.InvariantCulture,
OccurredAtUtc: DateTime.Parse(reader.GetString(1), System.Globalization.DateTimeStyles.RoundtripKind),
System.Globalization.CultureInfo.InvariantCulture, DateTimeKind.Utc)),
System.Globalization.DateTimeStyles.RoundtripKind), Actor = reader.GetString(2),
IngestedAtUtc: null, Action = reader.GetString(3),
Channel: AuditRowProjection.ParseEnum<AuditChannel>(reader.GetString(2), AuditChannel.ApiInbound), Outcome = AuditRowProjection.ParseEnum(reader.GetString(4), AuditOutcome.Success),
Kind: AuditRowProjection.ParseEnum<AuditKind>(reader.GetString(3), AuditKind.InboundRequest), Category = reader.IsDBNull(5) ? null : reader.GetString(5),
Status: AuditRowProjection.ParseEnum<AuditStatus>(reader.GetString(11), AuditStatus.Submitted), Target = reader.IsDBNull(6) ? null : reader.GetString(6),
CorrelationId: reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)), SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
ExecutionId: reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
ParentExecutionId: reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)), DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
SourceSiteId: reader.IsDBNull(5) ? null : reader.GetString(5), };
SourceNode: reader.IsDBNull(6) ? null : reader.GetString(6),
SourceInstanceId: reader.IsDBNull(7) ? null : reader.GetString(7),
SourceScript: reader.IsDBNull(8) ? null : reader.GetString(8),
Actor: reader.IsDBNull(9) ? null : reader.GetString(9),
Target: reader.IsDBNull(10) ? null : reader.GetString(10),
HttpStatus: reader.IsDBNull(12) ? null : reader.GetInt32(12),
DurationMs: reader.IsDBNull(13) ? null : reader.GetInt32(13),
ErrorMessage: reader.IsDBNull(14) ? null : reader.GetString(14),
ErrorDetail: reader.IsDBNull(15) ? null : reader.GetString(15),
RequestSummary: reader.IsDBNull(16) ? null : reader.GetString(16),
ResponseSummary: reader.IsDBNull(17) ? null : reader.GetString(17),
PayloadTruncated: reader.GetInt32(18) != 0,
Extra: reader.IsDBNull(19) ? null : reader.GetString(19)));
} }
/// <summary> /// <summary>
@@ -903,7 +886,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
{ {
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary> /// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
/// <param name="evt">The canonical audit event to persist.</param> /// <param name="evt">The canonical audit event to persist.</param>
/// <param name="forwardState">Site-local forwarding state stored alongside the canonical row (C3 shim — not a canonical field).</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) public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
{ {
Event = evt; Event = evt;
@@ -913,7 +896,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary>The canonical audit event to persist.</summary> /// <summary>The canonical audit event to persist.</summary>
public AuditEvent Event { get; } public AuditEvent Event { get; }
/// <summary>Site-local forwarding state for this row (C3 shim — bound to the ForwardState column).</summary> /// <summary>Initial forwarding state for this row's sidecar (bound to audit_forward_state.ForwardState).</summary>
public AuditForwardState ForwardState { get; } public AuditForwardState ForwardState { get; }
/// <summary>Task completion source for write completion signaling.</summary> /// <summary>Task completion source for write completion signaling.</summary>
public TaskCompletionSource Completion { get; } public TaskCompletionSource Completion { get; }
@@ -10,9 +10,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Bundle B (M2-T1) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>. /// C4 (Task 2.5) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s
/// Uses an in-memory shared-cache SQLite database so the same connection name /// two-table site schema — the append-only canonical <c>audit_event</c> table +
/// reaches the same file-less db across both the writer and the verifier. /// the mutable operational <c>audit_forward_state</c> sidecar + the <c>IX_fwd</c>
/// drain index. Uses an in-memory shared-cache SQLite database so the same
/// connection name reaches the same file-less db across both the writer and the
/// verifier.
/// </summary> /// </summary>
public class SqliteAuditWriterSchemaTests public class SqliteAuditWriterSchemaTests
{ {
@@ -38,6 +41,16 @@ public class SqliteAuditWriterSchemaTests
return (writer, dataSource); return (writer, dataSource);
} }
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static SqliteConnection OpenVerifierConnection(string dataSource) private static SqliteConnection OpenVerifierConnection(string dataSource)
{ {
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
@@ -45,15 +58,37 @@ public class SqliteAuditWriterSchemaTests
return connection; return connection;
} }
[Fact] private static List<string> ColumnNames(SqliteConnection connection, string table)
public void Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId()
{ {
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId)); using var cmd = connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
using var reader = cmd.ExecuteReader();
var names = new List<string>();
while (reader.Read())
{
names.Add(reader.GetString(1));
}
return names;
}
private static bool TableExists(SqliteConnection connection, string table)
{
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;";
cmd.Parameters.AddWithValue("$name", table);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId));
using (writer) using (writer)
{ {
using var connection = OpenVerifierConnection(dataSource); using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand(); using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA table_info(AuditLog);"; cmd.CommandText = "PRAGMA table_info(audit_event);";
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk)>(); var columns = new List<(string Name, int Pk)>();
@@ -62,16 +97,13 @@ public class SqliteAuditWriterSchemaTests
columns.Add((reader.GetString(1), reader.GetInt32(5))); columns.Add((reader.GetString(1), reader.GetInt32(5)));
} }
Assert.Equal(23, columns.Count); // The 10 canonical ZB.MOM.WW.Audit.AuditEvent fields, stored directly.
var expected = new[] var expected = new[]
{ {
"EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "EventId", "OccurredAtUtc", "Actor", "Action", "Outcome",
"SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", "Actor", "Target", "Category", "Target", "SourceNode", "CorrelationId", "DetailsJson",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState", "ExecutionId", "ParentExecutionId",
}; };
Assert.Equal(10, columns.Count);
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n)); Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
// PK is EventId only. // PK is EventId only.
@@ -82,27 +114,46 @@ public class SqliteAuditWriterSchemaTests
} }
[Fact] [Fact]
public void Initialize_creates_AuditLog_with_SourceNode_column() public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns()
{ {
var (writer, dataSource) = CreateWriter(nameof(Initialize_creates_AuditLog_with_SourceNode_column)); var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
Assert.True(
ColumnExists(connection, "SourceNode"),
"Fresh AuditLog schema must include the SourceNode column.");
}
}
[Fact]
public void Opens_Creates_IX_ForwardState_Occurred_Index()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_ForwardState_Occurred_Index));
using (writer) using (writer)
{ {
using var connection = OpenVerifierConnection(dataSource); using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand(); using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA index_list(AuditLog);"; cmd.CommandText = "PRAGMA table_info(audit_forward_state);";
using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk)>();
while (reader.Read())
{
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
var expected = new[]
{
"EventId", "ForwardState", "OccurredAtUtc",
"IsCachedKind", "AttemptCount", "LastAttemptUtc",
};
Assert.Equal(6, columns.Count);
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
// PK is EventId only.
var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList();
Assert.Single(pkColumns);
Assert.Equal("EventId", pkColumns[0]);
}
}
[Fact]
public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA index_list(audit_forward_state);";
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
var indexNames = new List<string>(); var indexNames = new List<string>();
@@ -111,11 +162,12 @@ public class SqliteAuditWriterSchemaTests
indexNames.Add(reader.GetString(1)); indexNames.Add(reader.GetString(1));
} }
Assert.Contains("IX_SiteAuditLog_ForwardState_Occurred", indexNames); Assert.Contains("IX_fwd", indexNames);
// Verify the index columns are ForwardState, OccurredAtUtc in that order. // Verify the index columns are ForwardState, IsCachedKind, OccurredAtUtc
// in that order.
using var infoCmd = connection.CreateCommand(); using var infoCmd = connection.CreateCommand();
infoCmd.CommandText = "PRAGMA index_info(IX_SiteAuditLog_ForwardState_Occurred);"; infoCmd.CommandText = "PRAGMA index_info(IX_fwd);";
using var infoReader = infoCmd.ExecuteReader(); using var infoReader = infoCmd.ExecuteReader();
var indexColumns = new List<string>(); var indexColumns = new List<string>();
@@ -124,7 +176,7 @@ public class SqliteAuditWriterSchemaTests
indexColumns.Add(infoReader.GetString(2)); indexColumns.Add(infoReader.GetString(2));
} }
Assert.Equal(new[] { "ForwardState", "OccurredAtUtc" }, indexColumns); Assert.Equal(new[] { "ForwardState", "IsCachedKind", "OccurredAtUtc" }, indexColumns);
} }
} }
@@ -144,258 +196,17 @@ public class SqliteAuditWriterSchemaTests
} }
} }
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- // // ----- C4 ephemeral in-place reset: old single-table schema is dropped ----- //
/// <summary> /// <summary>
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column /// The OLD pre-C4 single 24-column <c>AuditLog</c> table — exactly the shape a
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's /// pre-C4 deployment's on-disk <c>auditlog.db</c> contains. The site store is
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and /// ephemeral (≈7-day retention, recreated per deployment), so C4 RESETS in
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it. /// place: the new two-table schema is created and this old table is DROP-ped.
/// No SQLite data migration is performed (or needed) — any rows it holds are
/// within the retention window and discarded.
/// </summary> /// </summary>
private const string OldPreExecutionIdSchema = """ private const string OldSingleTableSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the OLD 20-column schema and
/// returns the open connection. The connection MUST stay open for the
/// lifetime of the test: a shared-cache in-memory database is dropped once
/// its last connection closes, so closing this would discard the seeded
/// schema before the writer opens its own connection.
/// </summary>
private static SqliteConnection SeedOldSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", columnName);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public async Task Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A pre-branch deployment: auditlog.db already exists with the 20-column
// schema and NO ExecutionId column.
using var seedConnection = SeedOldSchemaDatabase(dataSource);
Assert.False(ColumnExists(seedConnection, "ExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ExecutionId column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
var executionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ExecutionId"),
"SqliteAuditWriter must ALTER the ExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
// without the ALTER it would fail with "no such column: ExecutionId"
// and — because audit writes are best-effort — silently drop the row.
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
executionId: executionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.AsRow().ExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
}
}
// ----- ParentExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-ParentExecutionId-branch <c>AuditLog</c> schema — the 21-column
/// CREATE TABLE that HAS <c>ExecutionId</c> but is WITHOUT
/// <c>ParentExecutionId</c>. A deployment that ran the ExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreParentExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the pre-ParentExecutionId
/// 21-column schema and returns the open connection. The connection MUST
/// stay open for the lifetime of the test — a shared-cache in-memory
/// database is dropped once its last connection closes.
/// </summary>
private static SqliteConnection SeedPreParentExecutionIdSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreParentExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
[Fact]
public async Task Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ExecutionId branch: auditlog.db already
// exists with the 21-column schema and NO ParentExecutionId column.
using var seedConnection = SeedPreParentExecutionIdSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
Assert.False(ColumnExists(seedConnection, "ParentExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ParentExecutionId column in —
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
// table.
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ParentExecutionId"),
"SqliteAuditWriter must ALTER the ParentExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ParentExecutionId must now succeed and
// round-trip; without the ALTER it would fail with "no such column:
// ParentExecutionId" and — because audit writes are best-effort —
// silently drop the row.
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
executionId: executionId,
parentExecutionId: parentExecutionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.AsRow().ExecutionId);
Assert.Equal(parentExecutionId, row.AsRow().ParentExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ParentExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
}
}
[Fact]
public async Task WriteAsync_NullParentExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull));
await using (writer)
{
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.Notification,
kind: AuditKind.NotifySend,
status: AuditStatus.Submitted);
// ParentExecutionId left null (not a factory arg → defaults null)
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.AsRow().ParentExecutionId);
}
}
// ----- SourceNode schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-SourceNode <c>AuditLog</c> schema — the 22-column CREATE TABLE
/// that HAS <c>ExecutionId</c> + <c>ParentExecutionId</c> but is WITHOUT
/// <c>SourceNode</c>. A deployment that ran the ParentExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreSourceNodeSchema = """
CREATE TABLE IF NOT EXISTS AuditLog ( CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL, EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL, OccurredAtUtc TEXT NOT NULL,
@@ -403,6 +214,7 @@ public class SqliteAuditWriterSchemaTests
Kind TEXT NOT NULL, Kind TEXT NOT NULL,
CorrelationId TEXT NULL, CorrelationId TEXT NULL,
SourceSiteId TEXT NULL, SourceSiteId TEXT NULL,
SourceNode TEXT NULL,
SourceInstanceId TEXT NULL, SourceInstanceId TEXT NULL,
SourceScript TEXT NULL, SourceScript TEXT NULL,
Actor TEXT NULL, Actor TEXT NULL,
@@ -426,67 +238,84 @@ public class SqliteAuditWriterSchemaTests
"""; """;
/// <summary> /// <summary>
/// Seeds a shared-cache in-memory database with the pre-SourceNode 22-column /// Seeds a shared-cache in-memory database with the OLD single-table schema
/// schema and returns the open connection. The connection MUST stay open for /// and returns the open connection. The connection MUST stay open for the
/// the lifetime of the test a shared-cache in-memory database is dropped /// lifetime of the test: a shared-cache in-memory database is dropped once its
/// once its last connection closes. /// last connection closes, so closing this would discard the seeded schema
/// before the writer opens its own connection.
/// </summary> /// </summary>
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource) private static SqliteConnection SeedOldSingleTableDatabase(string dataSource)
{ {
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open(); connection.Open();
using var cmd = connection.CreateCommand(); using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreSourceNodeSchema; cmd.CommandText = OldSingleTableSchema;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
// Seed one row so we can prove the reset discards it (ephemeral store).
using var insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO AuditLog (
EventId, OccurredAtUtc, Channel, Kind, Status, PayloadTruncated, ForwardState
) VALUES (
$id, '2026-05-20T12:00:00.0000000Z', 'ApiOutbound', 'ApiCall', 'Delivered', 0, 'Pending'
);
""";
insert.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
insert.ExecuteNonQuery();
return connection; return connection;
} }
[Fact] [Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema() public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema()
{ {
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared"; var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ParentExecutionId branch: auditlog.db // A pre-C4 deployment: auditlog.db already exists with the old single
// already exists with the 22-column schema and NO SourceNode column. // 24-column AuditLog table (and a seeded row inside it).
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource); using var seedConnection = SeedOldSingleTableDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId")); Assert.True(TableExists(seedConnection, "AuditLog"));
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
Assert.False(ColumnExists(seedConnection, "SourceNode"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its // Upgrade: a C4 SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing SourceNode column in — the // InitializeSchema RESETS in place — the old AuditLog table is dropped and
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table. // the two new tables (+ IX_fwd) are created. No data is migrated.
await using (var writer = CreateWriterOver(dataSource)) await using (var writer = CreateWriterOver(dataSource))
{ {
Assert.True( Assert.False(
ColumnExists(seedConnection, "SourceNode"), TableExists(seedConnection, "AuditLog"),
"SqliteAuditWriter must ALTER the SourceNode column into a pre-existing AuditLog table."); "C4 must DROP the old single-table AuditLog on init (ephemeral in-place reset).");
Assert.True(TableExists(seedConnection, "audit_event"));
Assert.True(TableExists(seedConnection, "audit_forward_state"));
// A WriteAsync binding $SourceNode must now succeed and round-trip; // The two new tables start EMPTY — the old row was discarded, not
// without the ALTER it would fail with "no such column: SourceNode" // migrated (the site store is ephemeral).
// and — because audit writes are best-effort — silently drop the row. Assert.Empty(await writer.ReadPendingAsync(limit: 100));
// And a fresh WriteAsync round-trips through the new schema.
var evt = ScadaBridgeAuditEventFactory.Create( var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(), eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow, occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound, channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall, kind: AuditKind.ApiCall,
status: AuditStatus.Delivered, status: AuditStatus.Delivered);
sourceNode: "node-a");
await writer.WriteAsync(evt); await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10); var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows); var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode); Assert.Equal(evt.EventId, row.EventId);
} }
// Idempotency: a second writer over the now-upgraded DB must not error // Idempotency: a second writer over the now-two-table DB must not error
// (the probe sees SourceNode already present and skips the ALTER). // (DROP TABLE IF EXISTS is a no-op when AuditLog is already gone, and the
// CREATE TABLE IF NOT EXISTS statements are no-ops too).
await using (var writerAgain = CreateWriterOver(dataSource)) await using (var writerAgain = CreateWriterOver(dataSource))
{ {
Assert.True(ColumnExists(seedConnection, "SourceNode")); Assert.True(TableExists(seedConnection, "audit_event"));
Assert.True(TableExists(seedConnection, "audit_forward_state"));
} }
} }
// ----- Canonical / sidecar field persistence ----- //
[Fact] [Fact]
public async Task WriteAsync_persists_SourceNode_field() public async Task WriteAsync_persists_SourceNode_field()
{ {
@@ -528,4 +357,31 @@ public class SqliteAuditWriterSchemaTests
Assert.Null(row.SourceNode); Assert.Null(row.SourceNode);
} }
} }
[Fact]
public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson));
await using (writer)
{
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
executionId: executionId,
parentExecutionId: parentExecutionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
// ExecutionId / ParentExecutionId ride inside DetailsJson; AsRow()
// decomposes them back out.
Assert.Equal(executionId, row.AsRow().ExecutionId);
Assert.Equal(parentExecutionId, row.AsRow().ParentExecutionId);
}
}
} }
@@ -11,12 +11,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Bundle B (M2-T2) hot-path tests for <see cref="SqliteAuditWriter"/>. Exercise /// C4 (Task 2.5) hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
/// the Channel-based enqueue, the background writer's batch INSERTs, duplicate- /// two-table site schema. Exercise the Channel-based enqueue, the background
/// EventId swallowing, ForwardState defaulting, and the /// writer's per-event canonical(<c>audit_event</c>) + sidecar
/// <see cref="SqliteAuditWriter.ReadPendingAsync"/> / /// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> support surface that /// <c>IsCachedKind</c> drain split, the four reads, and the
/// Bundle D's telemetry actor will call. /// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> /
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> sidecar flips.
/// </summary> /// </summary>
public class SqliteAuditWriterWriteTests public class SqliteAuditWriterWriteTests
{ {
@@ -52,10 +53,40 @@ public class SqliteAuditWriterWriteTests
return connection; return connection;
} }
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared /// <summary>
// factory. The SQLite writer's transitional shim decomposes it into the 24 columns /// Reads the sidecar <c>ForwardState</c> for one EventId (the column moved off
// (defaulting ForwardState=Pending) on INSERT and recomposes the canonical record /// the single legacy table onto <c>audit_forward_state</c> in C4).
// on read. ExecutionId/SourceNode ride through DetailsJson / the top-level field. /// </summary>
private static string? ReadForwardState(string dataSource, Guid eventId)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", eventId.ToString());
return cmd.ExecuteScalar() as string;
}
/// <summary>Sidecar ForwardState → row-count, grouped (replaces the legacy single-table GROUP BY).</summary>
private static Dictionary<string, long> ForwardStateCounts(string dataSource)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT ForwardState, COUNT(*) FROM audit_forward_state GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
return byState;
}
// C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// factory. The SQLite writer stores the 10 canonical fields directly in
// audit_event and writes a Pending sidecar row into audit_forward_state, with
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
// record directly from audit_event's columns.
private static AuditEvent NewEvent( private static AuditEvent NewEvent(
Guid? id = null, Guid? id = null,
DateTime? occurredAtUtc = null, DateTime? occurredAtUtc = null,
@@ -70,22 +101,62 @@ public class SqliteAuditWriterWriteTests
executionId: executionId, executionId: executionId,
sourceNode: sourceNode); sourceNode: sourceNode);
/// <summary>A cached-lifecycle event (IsCachedKind=1) — drains via the cached read surface.</summary>
private static AuditEvent NewCachedEvent(
Guid? id = null,
DateTime? occurredAtUtc = null,
AuditKind kind = AuditKind.ApiCallCached)
// Status is independent of IsCachedKind (which is derived from Kind);
// Submitted is the natural first-row status for a cached lifecycle.
=> ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: kind,
status: AuditStatus.Submitted,
eventId: id ?? Guid.NewGuid(),
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow);
[Fact] [Fact]
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending() public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
{ {
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsWithForwardStatePending)); var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending));
await using var _ = writer; await using var _ = writer;
var evt = NewEvent(); var evt = NewEvent();
await writer.WriteAsync(evt); await writer.WriteAsync(evt);
// Canonical row landed in audit_event.
using var connection = OpenVerifierConnection(dataSource); using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand(); using var eventCmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;"; eventCmd.CommandText = "SELECT Action FROM audit_event WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); eventCmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
var actual = cmd.ExecuteScalar() as string; Assert.Equal(evt.Action, eventCmd.ExecuteScalar() as string);
Assert.Equal(AuditForwardState.Pending.ToString(), actual); // Sidecar row landed Pending.
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
}
[Fact]
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_Roundtrips_Canonical_Fields_Through_Read));
await using var _w = writer;
var evt = NewEvent() with { Target = "target-1", Actor = "user-1" };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(evt.EventId, row.EventId);
Assert.Equal(evt.OccurredAtUtc, row.OccurredAtUtc);
Assert.Equal("user-1", row.Actor);
Assert.Equal(evt.Action, row.Action);
Assert.Equal(evt.Outcome, row.Outcome);
Assert.Equal(evt.Category, row.Category);
Assert.Equal("target-1", row.Target);
Assert.Equal(evt.CorrelationId, row.CorrelationId);
// DetailsJson is stored verbatim and round-trips byte-for-byte.
Assert.Equal(evt.DetailsJson, row.DetailsJson);
} }
[Fact] [Fact]
@@ -100,11 +171,14 @@ public class SqliteAuditWriterWriteTests
async (evt, ct) => await writer.WriteAsync(evt, ct)); async (evt, ct) => await writer.WriteAsync(evt, ct));
using var connection = OpenVerifierConnection(dataSource); using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand(); using var eventCmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM AuditLog;"; eventCmd.CommandText = "SELECT COUNT(*) FROM audit_event;";
var count = Convert.ToInt64(cmd.ExecuteScalar()); Assert.Equal(1000, Convert.ToInt64(eventCmd.ExecuteScalar()));
Assert.Equal(1000, count); // Every canonical row has its matching sidecar row.
using var sidecarCmd = connection.CreateCommand();
sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state;";
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
} }
[Fact] [Fact]
@@ -122,36 +196,98 @@ public class SqliteAuditWriterWriteTests
using var connection = OpenVerifierConnection(dataSource); using var connection = OpenVerifierConnection(dataSource);
using var countCmd = connection.CreateCommand(); using var countCmd = connection.CreateCommand();
countCmd.CommandText = "SELECT COUNT(*) FROM AuditLog WHERE EventId = $id;"; countCmd.CommandText = "SELECT COUNT(*) FROM audit_event WHERE EventId = $id;";
countCmd.Parameters.AddWithValue("$id", sharedId.ToString()); countCmd.Parameters.AddWithValue("$id", sharedId.ToString());
var count = Convert.ToInt64(countCmd.ExecuteScalar()); Assert.Equal(1, Convert.ToInt64(countCmd.ExecuteScalar()));
Assert.Equal(1, count); // The sidecar likewise gained exactly one row (the canonical PK throws
// before the sidecar insert runs, so neither table double-inserts).
using var sidecarCmd = connection.CreateCommand();
sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state WHERE EventId = $id;";
sidecarCmd.Parameters.AddWithValue("$id", sharedId.ToString());
Assert.Equal(1, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
using var targetCmd = connection.CreateCommand(); using var targetCmd = connection.CreateCommand();
targetCmd.CommandText = "SELECT Target FROM AuditLog WHERE EventId = $id;"; targetCmd.CommandText = "SELECT Target FROM audit_event WHERE EventId = $id;";
targetCmd.Parameters.AddWithValue("$id", sharedId.ToString()); targetCmd.Parameters.AddWithValue("$id", sharedId.ToString());
Assert.Equal("first", targetCmd.ExecuteScalar() as string); Assert.Equal("first", targetCmd.ExecuteScalar() as string);
} }
[Fact] [Fact]
public async Task WriteAsync_ForcesForwardStatePending_IfNull() public async Task WriteAsync_ForcesSidecarForwardStatePending()
{ {
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull)); var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
await using var _ = writer; await using var _ = writer;
// C3 (Task 2.5): ForwardState is no longer a field on the canonical record; // C4 (Task 2.5): ForwardState is not a field on the canonical record; a
// a fresh canonical event carries none, and the SQLite shim defaults it to // fresh event's sidecar row defaults to Pending on INSERT.
// Pending on INSERT — exactly the behaviour this test pins.
var evt = NewEvent(); var evt = NewEvent();
await writer.WriteAsync(evt); await writer.WriteAsync(evt);
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
}
// ----- IsCachedKind drain split (precomputed at insert) ----- //
[Fact]
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0));
await using var _ = writer;
var cached = NewCachedEvent(); // ApiCallCached → cached
var nonCached = NewEvent(); // ApiCall → not cached
await writer.WriteAsync(cached);
await writer.WriteAsync(nonCached);
using var connection = OpenVerifierConnection(dataSource); using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand(); using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;"; cmd.CommandText = "SELECT IsCachedKind FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); var p = cmd.Parameters.Add("$id", SqliteType.Text);
Assert.Equal(AuditForwardState.Pending.ToString(), cmd.ExecuteScalar() as string); p.Value = cached.EventId.ToString();
Assert.Equal(1L, Convert.ToInt64(cmd.ExecuteScalar()));
p.Value = nonCached.EventId.ToString();
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
}
[Theory]
[InlineData(AuditKind.CachedSubmit)]
[InlineData(AuditKind.ApiCallCached)]
[InlineData(AuditKind.DbWriteCached)]
[InlineData(AuditKind.CachedResolve)]
public async Task CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending(AuditKind kind)
{
var (writer, _) = CreateWriter($"{nameof(CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending)}-{kind}");
await using var _w = writer;
var cached = NewCachedEvent(kind: kind);
await writer.WriteAsync(cached);
// The cached kind appears in the cached read surface...
var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10);
Assert.Single(cachedRows, r => r.EventId == cached.EventId);
// ...and NOT in the audit-only read surface.
var pendingRows = await writer.ReadPendingAsync(limit: 10);
Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId);
}
[Fact]
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
{
var (writer, _) = CreateWriter(nameof(NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry));
await using var _w = writer;
var nonCached = NewEvent(); // ApiCall — not a cached kind
await writer.WriteAsync(nonCached);
var pendingRows = await writer.ReadPendingAsync(limit: 10);
Assert.Single(pendingRows, r => r.EventId == nonCached.EventId);
var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10);
Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId);
} }
[Fact] [Fact]
@@ -184,9 +320,9 @@ public class SqliteAuditWriterWriteTests
} }
[Fact] [Fact]
public async Task MarkForwardedAsync_FlipsRowsToForwarded() public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
{ {
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsRowsToForwarded)); var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsSidecarRowsToForwarded));
await using var _ = writer; await using var _ = writer;
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
@@ -197,20 +333,33 @@ public class SqliteAuditWriterWriteTests
await writer.MarkForwardedAsync(ids); await writer.MarkForwardedAsync(ids);
using var connection = OpenVerifierConnection(dataSource); var byState = ForwardStateCounts(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
Assert.Equal(3, byState[AuditForwardState.Forwarded.ToString()]); Assert.Equal(3, byState[AuditForwardState.Forwarded.ToString()]);
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString())); Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
} }
[Fact]
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
{
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc));
await using var _ = writer;
var evt = NewEvent();
await writer.WriteAsync(evt);
await writer.MarkForwardedAsync(new[] { evt.EventId });
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT AttemptCount, LastAttemptUtc FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.Equal(1, reader.GetInt32(0)); // AttemptCount bumped 0 → 1
Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped
}
[Fact] [Fact]
public async Task MarkForwardedAsync_NonExistentId_NoThrow() public async Task MarkForwardedAsync_NonExistentId_NoThrow()
{ {
@@ -223,6 +372,23 @@ public class SqliteAuditWriterWriteTests
// No assertion needed: the call must complete without throwing. // No assertion needed: the call must complete without throwing.
} }
[Fact]
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
{
var (writer, _) = CreateWriter(nameof(ReadForwardedAsync_Returns_Only_Forwarded_Rows));
await using var _w = writer;
var forwarded = NewEvent();
var pending = NewEvent();
await writer.WriteAsync(forwarded);
await writer.WriteAsync(pending);
await writer.MarkForwardedAsync(new[] { forwarded.EventId });
var rows = await writer.ReadForwardedAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(forwarded.EventId, row.EventId);
}
// ----- M6 reconciliation pull surface ----- // // ----- M6 reconciliation pull surface ----- //
[Fact] [Fact]
@@ -327,16 +493,7 @@ public class SqliteAuditWriterWriteTests
await writer.MarkReconciledAsync(new[] { a.EventId, b.EventId, c.EventId }); await writer.MarkReconciledAsync(new[] { a.EventId, b.EventId, c.EventId });
using var connection = OpenVerifierConnection(dataSource); var byState = ForwardStateCounts(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
Assert.Equal(3, byState[AuditForwardState.Reconciled.ToString()]); Assert.Equal(3, byState[AuditForwardState.Reconciled.ToString()]);
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString())); Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString())); Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
@@ -354,12 +511,7 @@ public class SqliteAuditWriterWriteTests
// Re-call must not throw and must leave the single row Reconciled. // Re-call must not throw and must leave the single row Reconciled.
await writer.MarkReconciledAsync(new[] { a.EventId }); await writer.MarkReconciledAsync(new[] { a.EventId });
using var connection = OpenVerifierConnection(dataSource); Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId));
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", a.EventId.ToString());
Assert.Equal(AuditForwardState.Reconciled.ToString(), cmd.ExecuteScalar() as string);
} }
[Fact] [Fact]
@@ -372,12 +524,12 @@ public class SqliteAuditWriterWriteTests
// Completes without throwing. // Completes without throwing.
} }
// ----- ExecutionId column (universal per-run correlation value) ----- // // ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
[Fact] [Fact]
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow() public async Task WriteAsync_NonNullExecutionId_RoundTrips()
{ {
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow)); var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTrips));
await using var _w = writer; await using var _w = writer;
var executionId = Guid.NewGuid(); var executionId = Guid.NewGuid();
@@ -458,46 +610,40 @@ public class SqliteAuditWriterWriteTests
Assert.Null(row.SourceNode); Assert.Null(row.SourceNode);
} }
// ----- C3 hardening: safe enum-parse in MapRow ----- // // ----- C4 hardening: safe enum-parse in MapRow ----- //
/// <summary> /// <summary>
/// C3 hardening (Task 2.5): a row whose Channel/Kind/Status columns hold /// C4 hardening (Task 2.5): a row whose stored <c>Outcome</c> column holds an
/// an unknown/renamed enum string must NOT fault the read path; it degrades /// unknown/renamed enum string must NOT fault the read path; it degrades
/// gracefully to the same fallbacks used by <c>AuditRowProjection.Decompose</c> /// gracefully to <see cref="AuditOutcome.Success"/> (the safe
/// (ApiInbound / InboundRequest / Submitted). The read is exercised via the /// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
/// public <see cref="SqliteAuditWriter.ReadPendingAsync"/> surface which calls /// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
/// the private <c>MapRow</c>. /// surface which calls the private <c>MapRow</c>.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues() public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
{ {
var (writer, dataSource) = CreateWriter( var (writer, dataSource) = CreateWriter(
nameof(ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues)); nameof(ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback));
await using var _ = writer; await using var _ = writer;
var evt = NewEvent(); var evt = NewEvent();
await writer.WriteAsync(evt); await writer.WriteAsync(evt);
// Tamper: overwrite the three enum columns with unknown strings that are // Tamper: overwrite the canonical Outcome column with a string that is not
// not declared AuditChannel/AuditKind/AuditStatus member names. // a declared AuditOutcome member name.
using (var conn = OpenVerifierConnection(dataSource)) using (var conn = OpenVerifierConnection(dataSource))
using (var cmd = conn.CreateCommand()) using (var cmd = conn.CreateCommand())
{ {
cmd.CommandText = cmd.CommandText = "UPDATE audit_event SET Outcome = 'RenamedOutcome99' WHERE EventId = $id;";
"UPDATE AuditLog SET Channel = 'ObsoleteChannelV0', " +
"Kind = 'LegacyKindName', Status = 'RenamedStatus99' " +
"WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
// Must not throw (previously would throw ArgumentException from Enum.Parse). // Must not throw (a raw Enum.Parse would throw ArgumentException).
var rows = await writer.ReadPendingAsync(limit: 10); var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows); var row = Assert.Single(rows);
var typedRow = row.AsRow(); Assert.Equal(AuditOutcome.Success, row.Outcome);
Assert.Equal(AuditChannel.ApiInbound, typedRow.Channel);
Assert.Equal(AuditKind.InboundRequest, typedRow.Kind);
Assert.Equal(AuditStatus.Submitted, typedRow.Status);
} }
} }