feat(configdb): AuditLogRepository (EF) + DI registration, append-only with M6-deferred SwitchOutPartition (#23)

EF Core implementation of IAuditLogRepository:
- InsertIfNotExistsAsync: single IF NOT EXISTS ... INSERT via
  ExecuteSqlInterpolatedAsync, bypasses the change tracker. Enum
  values converted to string in C# (columns are varchar(32) via
  HasConversion<string>).
- QueryAsync: AsNoTracking, predicate-per-non-null-filter, keyset
  paging on (OccurredAtUtc DESC, EventId DESC) — EF Core 10
  translates Guid.CompareTo to a uniqueidentifier < comparison
  natively (verified against MSSQL 2022).
- SwitchOutPartitionAsync: throws NotSupportedException naming M6;
  the non-aligned UX_AuditLog_EventId unique index blocks
  ALTER TABLE SWITCH PARTITION until the drop-and-rebuild dance
  ships with the purge actor.

DI: AddScoped<IAuditLogRepository, AuditLogRepository>() added after
the NotificationOutboxRepository registration; existing DI smoke test
extended with an IAuditLogRepository assertion.

Integration tests (8 new) use the Bundle C MsSqlMigrationFixture and
scope by a per-test SourceSiteId guid so they neither collide nor
require cleanup.

Bundle D of the Audit Log #23 M1 Foundation plan.
This commit is contained in:
Joseph Doherty
2026-05-20 11:05:18 -04:00
parent db32a149d3
commit de839627ed
4 changed files with 432 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
/// for the append-only contract; this class only adds notes on the data-access
/// strategy used by each method.
/// </summary>
public class AuditLogRepository : IAuditLogRepository
{
private readonly ScadaLinkDbContext _context;
public AuditLogRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <summary>
/// Issues a single <c>IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)</c>
/// via <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>.
/// Bypasses the EF change tracker so the row never enters a tracked state and
/// the enum-as-string conversion is done explicitly in C# (the columns are
/// declared <c>varchar(32)</c> via <c>HasConversion&lt;string&gt;()</c> in
/// <see cref="ScadaLink.ConfigurationDatabase.Configurations.AuditLogEntityTypeConfiguration"/>).
/// </summary>
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt is null)
{
throw new ArgumentNullException(nameof(evt));
}
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
// the conversion in C# rather than relying on parameter type inference —
// SqlClient would otherwise bind enums as int by default.
var channel = evt.Channel.ToString();
var kind = evt.Kind.ToString();
var status = evt.Status.ToString();
var forwardState = evt.ForwardState?.ToString();
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
ct);
}
/// <summary>
/// Builds an <c>AsNoTracking</c> queryable over <see cref="AuditEvent"/>, applies
/// every non-null filter predicate, and pages by keyset on
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>. The keyset clause is expressed
/// directly (<c>occurred &lt; after || (occurred == after &amp;&amp; eventId.CompareTo(afterId) &lt; 0)</c>)
/// — EF Core 10 translates <see cref="Guid.CompareTo(Guid)"/> against SQL Server's
/// <c>uniqueidentifier</c> sort order.
/// </summary>
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
{
if (filter is null)
{
throw new ArgumentNullException(nameof(filter));
}
if (paging is null)
{
throw new ArgumentNullException(nameof(paging));
}
var query = _context.Set<AuditEvent>().AsNoTracking();
if (filter.Channel is { } channel)
{
query = query.Where(e => e.Channel == channel);
}
if (filter.Kind is { } kind)
{
query = query.Where(e => e.Kind == kind);
}
if (filter.Status is { } status)
{
query = query.Where(e => e.Status == status);
}
if (!string.IsNullOrEmpty(filter.SourceSiteId))
{
var siteId = filter.SourceSiteId;
query = query.Where(e => e.SourceSiteId == siteId);
}
if (!string.IsNullOrEmpty(filter.Target))
{
var target = filter.Target;
query = query.Where(e => e.Target == target);
}
if (!string.IsNullOrEmpty(filter.Actor))
{
var actor = filter.Actor;
query = query.Where(e => e.Actor == actor);
}
if (filter.CorrelationId is { } correlationId)
{
query = query.Where(e => e.CorrelationId == correlationId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
}
if (filter.ToUtc is { } toUtc)
{
query = query.Where(e => e.OccurredAtUtc <= toUtc);
}
// Keyset cursor on (OccurredAtUtc desc, EventId desc).
if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
{
query = query.Where(e =>
e.OccurredAtUtc < afterOccurred
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
}
return await query
.OrderByDescending(e => e.OccurredAtUtc)
.ThenByDescending(e => e.EventId)
.Take(paging.PageSize)
.ToListAsync(ct);
}
/// <summary>
/// M1 honest contract: throws <see cref="NotSupportedException"/>. The
/// <c>UX_AuditLog_EventId</c> unique index is non-aligned with
/// <c>ps_AuditLog_Month</c> (it lives on <c>[PRIMARY]</c> to keep
/// <see cref="InsertIfNotExistsAsync"/> cheap), and SQL Server rejects
/// <c>ALTER TABLE … SWITCH PARTITION</c> when a non-aligned index is present.
/// The drop-and-rebuild dance that makes the switch legal ships with the M6
/// purge actor.
/// </summary>
public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
throw new NotSupportedException(
"AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " +
"unique index; the drop-and-rebuild dance ships in M6 (purge actor).");
}
}

View File

@@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();