From 7173a79ad7dd7f12f6e1f4bfad2802c7d87565d6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 12:17:02 -0400 Subject: [PATCH] feat(auditlog): SqliteAuditWriter schema bootstrap (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the site-side SqliteAuditWriter skeleton with schema bootstrap — 20-column AuditLog table + IX_SiteAuditLog_ForwardState_Occurred index + PRAGMA auto_vacuum = INCREMENTAL — and the SqliteAuditWriterOptions companion type. Mirrors the SiteEventLogger pattern: single owned SqliteConnection serialised behind a write lock; the Channel-based hot-path lands in Bundle B-T2. Adds Microsoft.Data.Sqlite + Microsoft.Extensions.Logging.Abstractions project refs to ScadaLink.AuditLog; adds Microsoft.Data.Sqlite + Microsoft.Extensions.Logging.Abstractions + NSubstitute test refs. Tests (3 new, total 13 -> 16): - Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId - Opens_Creates_IX_ForwardState_Occurred_Index - PRAGMA_auto_vacuum_Is_INCREMENTAL --- .../ScadaLink.AuditLog.csproj | 2 + .../Site/SqliteAuditWriter.cs | 109 +++++++++++++++ .../Site/SqliteAuditWriterOptions.cs | 27 ++++ .../ScadaLink.AuditLog.Tests.csproj | 3 + .../Site/SqliteAuditWriterSchemaTests.cs | 128 ++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs create mode 100644 src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs create mode 100644 tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs diff --git a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj index 4999344..91c296c 100644 --- a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj +++ b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj @@ -8,7 +8,9 @@ + + diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs new file mode 100644 index 0000000..1db6b1b --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -0,0 +1,109 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.AuditLog.Site; + +/// +/// Site-side SQLite hot-path writer for Audit Log (#23) events. Mirrors the +/// design — a single +/// owned serialised behind a write lock, fed by a +/// bounded drained on a +/// dedicated background writer task — so script-thread callers never block on +/// disk I/O. +/// +/// +/// Bundle B (M2-T1) ships only the schema bootstrap; the channel + writer loop +/// land in Bundle B (M2-T2). +/// +public class SqliteAuditWriter : IAuditWriter, IDisposable +{ + private readonly SqliteConnection _connection; + private readonly SqliteAuditWriterOptions _options; + private readonly ILogger _logger; + private readonly object _writeLock = new(); + private bool _disposed; + + public SqliteAuditWriter( + IOptions options, + ILogger logger, + string? connectionStringOverride = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + _options = options.Value; + _logger = logger; + + var connectionString = connectionStringOverride + ?? $"Data Source={_options.DatabasePath};Cache=Shared"; + _connection = new SqliteConnection(connectionString); + _connection.Open(); + + InitializeSchema(); + } + + private void InitializeSchema() + { + // auto_vacuum must be set before any table is created for it to take + // effect on a fresh database. INCREMENTAL lets a future + // `PRAGMA incremental_vacuum` shrink the file after the 7-day retention + // purge — see alog.md §10. + using (var pragmaCmd = _connection.CreateCommand()) + { + pragmaCmd.CommandText = "PRAGMA auto_vacuum = INCREMENTAL"; + pragmaCmd.ExecuteNonQuery(); + } + + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + 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); + """; + cmd.ExecuteNonQuery(); + } + + /// + /// Persist an audit event. Bundle B (M2-T2) replaces this stub with a + /// non-blocking Channel-based enqueue draining on a background writer task. + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + throw new NotImplementedException("Channel-based hot-path lands in Bundle B-T2."); + } + + public void Dispose() + { + lock (_writeLock) + { + if (_disposed) return; + _disposed = true; + _connection.Dispose(); + } + } +} diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs new file mode 100644 index 0000000..8f1fdc1 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs @@ -0,0 +1,27 @@ +namespace ScadaLink.AuditLog.Site; + +/// +/// Options for the site-side SQLite hot-path audit writer. +/// Mirrors the ScadaLink.SiteEventLogging pattern: a single SQLite connection +/// fed by a background writer task draining a bounded +/// so script-thread enqueues +/// never block on disk I/O. +/// +public sealed class SqliteAuditWriterOptions +{ + /// SQLite database path (or in-memory URI for tests). + public string DatabasePath { get; set; } = "auditlog.db"; + + /// + /// Capacity of the bounded write queue. Set high enough that ordinary + /// script bursts never fill it; + /// applies when the writer falls behind. + /// + public int ChannelCapacity { get; set; } = 4096; + + /// Max number of pending events the writer drains in one transaction. + public int BatchSize { get; set; } = 256; + + /// Soft flush interval the writer enforces when fewer than BatchSize events are queued. + public int FlushIntervalMs { get; set; } = 50; +} diff --git a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj index 9a866be..539e9a5 100644 --- a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj +++ b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj @@ -10,8 +10,11 @@ + + + diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs new file mode 100644 index 0000000..b02dd63 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Site; + +namespace ScadaLink.AuditLog.Tests.Site; + +/// +/// Bundle B (M2-T1) schema-bootstrap tests for . +/// 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. +/// +public class SqliteAuditWriterSchemaTests +{ + /// + /// Each test uses a unique shared-cache in-memory database. The + /// "Mode=Memory;Cache=Shared" syntax lets two SqliteConnections see the same + /// in-memory store as long as both use the same Data Source name. + /// + private static (SqliteAuditWriter writer, string dataSource) CreateWriter(string testName) + { + var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + var options = new SqliteAuditWriterOptions + { + DatabasePath = dataSource, + }; + // The writer uses raw "Data Source={path}" by appending Cache=Shared. Override + // by passing the full connection string via the connectionStringOverride hook. + var writer = new SqliteAuditWriter( + Options.Create(options), + NullLogger.Instance, + connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); + return (writer, dataSource); + } + + private static SqliteConnection OpenVerifierConnection(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + return connection; + } + + [Fact] + public void Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId() + { + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId)); + using (writer) + { + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA table_info(AuditLog);"; + using var reader = cmd.ExecuteReader(); + + var columns = new List<(string Name, int Pk)>(); + while (reader.Read()) + { + columns.Add((reader.GetString(1), reader.GetInt32(5))); + } + + Assert.Equal(20, columns.Count); + + var expected = new[] + { + "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", + "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", + "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", + "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", + "ForwardState", + }; + 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_ForwardState_Occurred_Index() + { + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_ForwardState_Occurred_Index)); + using (writer) + { + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA index_list(AuditLog);"; + using var reader = cmd.ExecuteReader(); + + var indexNames = new List(); + while (reader.Read()) + { + indexNames.Add(reader.GetString(1)); + } + + Assert.Contains("IX_SiteAuditLog_ForwardState_Occurred", indexNames); + + // Verify the index columns are ForwardState, OccurredAtUtc in that order. + using var infoCmd = connection.CreateCommand(); + infoCmd.CommandText = "PRAGMA index_info(IX_SiteAuditLog_ForwardState_Occurred);"; + using var infoReader = infoCmd.ExecuteReader(); + + var indexColumns = new List(); + while (infoReader.Read()) + { + indexColumns.Add(infoReader.GetString(2)); + } + + Assert.Equal(new[] { "ForwardState", "OccurredAtUtc" }, indexColumns); + } + } + + [Fact] + public void PRAGMA_auto_vacuum_Is_INCREMENTAL() + { + var (writer, dataSource) = CreateWriter(nameof(PRAGMA_auto_vacuum_Is_INCREMENTAL)); + using (writer) + { + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA auto_vacuum;"; + var value = Convert.ToInt32(cmd.ExecuteScalar()); + + // INCREMENTAL = 2 (0 = NONE, 1 = FULL, 2 = INCREMENTAL). + Assert.Equal(2, value); + } + } +}