fix(auditlog): evolve existing site auditlog.db schema for ExecutionId

This commit is contained in:
Joseph Doherty
2026-05-21 16:18:17 -04:00
parent fd76c19007
commit 5198b114b4
3 changed files with 161 additions and 1 deletions

View File

@@ -103,7 +103,7 @@ row per lifecycle event across all channels.
- `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans.
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
- `IX_AuditLog_Correlation (CorrelationId)` — drilldown from a single operation.
- `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation.
- `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request.
- `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles.
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".

View File

@@ -121,6 +121,46 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
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");
}
/// <summary>
/// Audit Log #23 (ExecutionId): adds a column to <c>AuditLog</c> only when
/// it is not already present. 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;
}
using var alter = _connection.CreateCommand();
// Column name + definition are caller-controlled constants, never user
// input — safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}";
alter.ExecuteNonQuery();
}
/// <summary>

View File

@@ -2,6 +2,8 @@ using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Site;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site;
@@ -125,4 +127,122 @@ public class SqliteAuditWriterSchemaTests
Assert.Equal(2, value);
}
}
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreExecutionIdSchema = """
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,
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 = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
ExecutionId = executionId,
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.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"));
}
}
}