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
110 lines
4.0 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|