Files
scadalink-design/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs
Joseph Doherty 7173a79ad7 feat(auditlog): SqliteAuditWriter schema bootstrap (#23)
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
2026-05-20 12:17:02 -04:00

110 lines
4.0 KiB
C#

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;
/// <summary>
/// Site-side SQLite hot-path writer for Audit Log (#23) events. Mirrors the
/// <see cref="ScadaLink.SiteEventLogging.SiteEventLogger"/> design — a single
/// owned <see cref="SqliteConnection"/> serialised behind a write lock, fed by a
/// bounded <see cref="System.Threading.Channels.Channel{T}"/> drained on a
/// dedicated background writer task — so script-thread callers never block on
/// disk I/O.
/// </summary>
/// <remarks>
/// Bundle B (M2-T1) ships only the schema bootstrap; the channel + writer loop
/// land in Bundle B (M2-T2).
/// </remarks>
public class SqliteAuditWriter : IAuditWriter, IDisposable
{
private readonly SqliteConnection _connection;
private readonly SqliteAuditWriterOptions _options;
private readonly ILogger<SqliteAuditWriter> _logger;
private readonly object _writeLock = new();
private bool _disposed;
public SqliteAuditWriter(
IOptions<SqliteAuditWriterOptions> options,
ILogger<SqliteAuditWriter> 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();
}
/// <summary>
/// Persist an audit event. Bundle B (M2-T2) replaces this stub with a
/// non-blocking Channel-based enqueue draining on a background writer task.
/// </summary>
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();
}
}
}