feat(auditlog): AuditLogPartitionMaintenanceService monthly roll-forward (#23 M6)

This commit is contained in:
Joseph Doherty
2026-05-20 18:51:43 -04:00
parent cc2d6e91f1
commit 75b060e0a8
9 changed files with 834 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Central;
using ScadaLink.Commons.Interfaces;
using Xunit;
namespace ScadaLink.AuditLog.Tests.Central;
/// <summary>
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
/// the real EF/MSSQL implementation is exercised by the
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
/// <c>ScadaLink.ConfigurationDatabase.Tests</c>. This file is purely
/// about the hosted service's policy decisions (start/stop, exception
/// containment).
/// </summary>
public class AuditLogPartitionMaintenanceServiceTests
{
/// <summary>
/// Recording stub — counts EnsureLookaheadAsync invocations and lets the
/// test inject an exception per invocation to drive the catch-all path.
/// </summary>
private sealed class RecordingMaintenance : IPartitionMaintenance
{
public int EnsureCallCount;
public Exception? ThrowOnce;
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
{
Interlocked.Increment(ref EnsureCallCount);
if (ThrowOnce is { } ex)
{
ThrowOnce = null;
throw ex;
}
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
}
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
}
/// <summary>
/// Captures logged exceptions so the catch-all assertion can prove
/// the exception was actually logged (not silently swallowed) and was
/// the exact instance the stub threw.
/// </summary>
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
{
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, exception, formatter(state, exception)));
}
}
private static IServiceProvider BuildProvider(IPartitionMaintenance maintenance)
{
var services = new ServiceCollection();
// IPartitionMaintenance is registered as scoped by AddConfigurationDatabase;
// we mirror that here so the hosted service's CreateAsyncScope +
// GetRequiredService resolves the stub the test injected.
services.AddScoped(_ => maintenance);
return services.BuildServiceProvider();
}
[Fact]
public async Task StartStop_NoExceptions()
{
// Long interval so only the eager startup tick fires inside the test
// window — keeps assertions deterministic without relying on
// multiple cadence loops.
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = 1,
});
var maintenance = new RecordingMaintenance();
var sp = BuildProvider(maintenance);
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
await svc.StartAsync(CancellationToken.None);
// Spin briefly until the startup tick has fired — the loop's first
// SafeMaintainAsync runs on a background Task.Run continuation, so
// we can't synchronously rely on its completion.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (Volatile.Read(ref maintenance.EnsureCallCount) < 1 && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
}
[Fact]
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
{
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = 1,
});
// The injected exception fires on the FIRST EnsureLookaheadAsync call
// (the startup tick) — the hosted service must contain it and
// continue running.
var boom = new InvalidOperationException("simulated maintenance failure");
var maintenance = new RecordingMaintenance { ThrowOnce = boom };
var sp = BuildProvider(maintenance);
var logger = new CapturingLogger();
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
logger);
// StartAsync must not throw even though the very first tick will fail.
await svc.StartAsync(CancellationToken.None);
// Wait for the error to surface in the logger.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (!logger.Entries.Any(e => e.Exception == boom) && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
var errorEntry = Assert.Single(logger.Entries, e => e.Exception == boom);
Assert.Equal(LogLevel.Error, errorEntry.Level);
Assert.Equal(1, maintenance.EnsureCallCount);
}
}

View File

@@ -0,0 +1,182 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.ConfigurationDatabase.Maintenance;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// Bundle D (#23 M6-T5) integration tests for
/// <see cref="AuditLogPartitionMaintenance"/>. Uses the same
/// <see cref="MsSqlMigrationFixture"/> as the AuditLog migration / repository
/// tests so the ALTER PARTITION FUNCTION DDL runs against the actual seeded
/// <c>pf_AuditLog_Month</c>.
/// </summary>
/// <remarks>
/// The migration seeds boundaries for every month in 2026 and 2027 (Jan 2026
/// through Dec 2027). Tests pick a lookahead relative to the current
/// max-boundary at test start (rather than a fixed-target date) so each test
/// is robust against earlier tests in the class having added boundaries to
/// the shared fixture DB. Tests run sequentially within the class via xunit's
/// per-class collection serialisation.
/// </remarks>
public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogPartitionMaintenanceTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaLinkDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
private AuditLogPartitionMaintenance NewMaintenance(ScadaLinkDbContext ctx) =>
new(ctx, NullLogger<AuditLogPartitionMaintenance>.Instance);
/// <summary>
/// Computes the lookahead-in-months required to fall strictly inside the
/// already-covered boundary range. Picks something well below the
/// distance from "now" to the current max — guaranteed not to need any
/// new SPLIT.
/// </summary>
private static int LookaheadInsideExistingRange(DateTime max)
{
var now = DateTime.UtcNow;
// (max - now) in whole months, minus a 1-month safety margin so we
// never accidentally hit the boundary horizon edge case.
var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1;
return Math.Max(1, months);
}
/// <summary>
/// Computes the lookahead-in-months required to add exactly
/// <paramref name="extraBoundaries"/> new boundaries past the current max.
/// </summary>
/// <remarks>
/// EnsureLookaheadAsync defines horizon =
/// <c>NormalizeToFirstOfMonth(UtcNow) + lookaheadMonths</c>. The new
/// boundaries it issues are first-of-month values strictly greater than
/// max, up to and including horizon. So
/// <c>lookaheadMonths = monthsBetween(NormalizeToFirstOfMonth(UtcNow), max) + extraBoundaries</c>
/// is the exact value that lands horizon on <c>max + extraBoundaries</c>
/// months.
/// </remarks>
private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries)
{
var nowFirstOfMonth = FirstOfNextMonth(DateTime.UtcNow);
var monthsToMax = ((max.Year - nowFirstOfMonth.Year) * 12) + max.Month - nowFirstOfMonth.Month;
return monthsToMax + extraBoundaries;
}
private static DateTime FirstOfNextMonth(DateTime instant)
{
var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc);
return firstOfThisMonth.AddMonths(1);
}
[SkippableFact]
public async Task EnsureLookahead_AlreadyHasFutureRange_NoSplit_ReturnsEmpty()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var ctx = CreateContext();
var maintenance = NewMaintenance(ctx);
var max = await maintenance.GetMaxBoundaryAsync();
Assert.NotNull(max);
// Pick a lookahead small enough that horizon (NormalizeToFirstOfMonth(now)
// + lookahead) lands well INSIDE the already-covered range — no SPLIT
// should fire.
var lookahead = LookaheadInsideExistingRange(max.Value);
var added = await maintenance.EnsureLookaheadAsync(lookahead);
Assert.Empty(added);
// Sanity: the max boundary is unchanged after the no-op call.
var maxAfter = await maintenance.GetMaxBoundaryAsync();
Assert.Equal(max, maxAfter);
}
[SkippableFact]
public async Task EnsureLookahead_NeedsOneMoreBoundary_Splits_Returns1Boundary()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var ctx = CreateContext();
var maintenance = NewMaintenance(ctx);
var maxBefore = await maintenance.GetMaxBoundaryAsync();
Assert.NotNull(maxBefore);
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1);
var expectedAdded = maxBefore.Value.AddMonths(1);
var added = await maintenance.EnsureLookaheadAsync(lookahead);
Assert.Single(added);
Assert.Equal(expectedAdded, added[0]);
var maxAfter = await maintenance.GetMaxBoundaryAsync();
Assert.Equal(expectedAdded, maxAfter);
}
[SkippableFact]
public async Task EnsureLookahead_NeedsThreeBoundaries_Splits_Returns3Boundaries()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var ctx = CreateContext();
var maintenance = NewMaintenance(ctx);
var maxBefore = await maintenance.GetMaxBoundaryAsync();
Assert.NotNull(maxBefore);
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 3);
var added = await maintenance.EnsureLookaheadAsync(lookahead);
Assert.Equal(3, added.Count);
Assert.Equal(maxBefore.Value.AddMonths(1), added[0]);
Assert.Equal(maxBefore.Value.AddMonths(2), added[1]);
Assert.Equal(maxBefore.Value.AddMonths(3), added[2]);
var maxAfter = await maintenance.GetMaxBoundaryAsync();
Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter);
}
[SkippableFact]
public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var ctx1 = CreateContext();
var m1 = NewMaintenance(ctx1);
var maxStart = await m1.GetMaxBoundaryAsync();
Assert.NotNull(maxStart);
// First call: add one boundary.
var lookahead = LookaheadForExtraBoundaries(maxStart.Value, extraBoundaries: 1);
var firstAdded = await m1.EnsureLookaheadAsync(lookahead);
Assert.Single(firstAdded);
// Second call: the boundary just added is now part of pf_AuditLog_Month,
// so the same lookahead value should be a no-op — no exception, no
// duplicate SPLIT.
await using var ctx2 = CreateContext();
var m2 = NewMaintenance(ctx2);
var secondAdded = await m2.EnsureLookaheadAsync(lookahead);
Assert.Empty(secondAdded);
// The max boundary is unchanged across the second call.
var maxAfter = await m2.GetMaxBoundaryAsync();
Assert.Equal(firstAdded[0], maxAfter);
}
}