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
This commit is contained in:
@@ -8,7 +8,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
109
src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs
Normal file
109
src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs
Normal file
27
src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ScadaLink.AuditLog.Site;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="System.Threading.Channels.Channel{T}"/> so script-thread enqueues
|
||||||
|
/// never block on disk I/O.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqliteAuditWriterOptions
|
||||||
|
{
|
||||||
|
/// <summary>SQLite database path (or in-memory URI for tests).</summary>
|
||||||
|
public string DatabasePath { get; set; } = "auditlog.db";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Capacity of the bounded write queue. Set high enough that ordinary
|
||||||
|
/// script bursts never fill it; <see cref="System.Threading.Channels.BoundedChannelFullMode.Wait"/>
|
||||||
|
/// applies when the writer falls behind.
|
||||||
|
/// </summary>
|
||||||
|
public int ChannelCapacity { get; set; } = 4096;
|
||||||
|
|
||||||
|
/// <summary>Max number of pending events the writer drains in one transaction.</summary>
|
||||||
|
public int BatchSize { get; set; } = 256;
|
||||||
|
|
||||||
|
/// <summary>Soft flush interval the writer enforces when fewer than BatchSize events are queued.</summary>
|
||||||
|
public int FlushIntervalMs { get; set; } = 50;
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="NSubstitute" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle B (M2-T1) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>.
|
||||||
|
/// 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>
|
||||||
|
public class SqliteAuditWriterSchemaTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<SqliteAuditWriter>.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<string>();
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user