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,218 @@
using System.Globalization;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Interfaces;
namespace ScadaLink.ConfigurationDatabase.Maintenance;
/// <summary>
/// EF/SQL-Server implementation of <see cref="IPartitionMaintenance"/> that
/// rolls forward <c>pf_AuditLog_Month</c> by issuing
/// <c>ALTER PARTITION FUNCTION … SPLIT RANGE</c> for each missing future
/// monthly boundary.
/// </summary>
/// <remarks>
/// <para>
/// The class is scoped (registered alongside the other repositories in
/// <c>AddConfigurationDatabase</c>) because it shares <see cref="ScadaLinkDbContext"/>
/// — the hosted service opens a per-tick DI scope, resolves a fresh instance,
/// and lets the scope's <c>DbContext</c> dispose with it. The class itself
/// holds no state between calls.
/// </para>
/// <para>
/// <b>Idempotency model.</b> Each tick reads the current max boundary from
/// <c>sys.partition_range_values</c> and only issues SPLIT RANGE for
/// boundaries that strictly follow it — a boundary already covered is never
/// re-issued, so the "boundary already exists" failure (SQL Server msg 7708
/// / 7711) is avoided by construction rather than caught. The pre-check is
/// cheaper than the alternative TRY/CATCH around every SPLIT call and also
/// keeps the returned <c>added</c> list semantically precise.
/// </para>
/// <para>
/// <b>Why "first of next month".</b> The migration seeds boundaries on the
/// first-of-month at midnight UTC; we preserve that convention so the
/// resulting partition layout is uniform. <see cref="NormalizeToFirstOfMonth"/>
/// rounds an arbitrary timestamp up to the next first-of-month boundary
/// (e.g. 2026-05-20 → 2026-06-01), and <see cref="NextMonthBoundary"/>
/// walks one month at a time from there.
/// </para>
/// <para>
/// <b>Permissions.</b> The migration's <c>scadalink_audit_purger</c> role
/// already carries <c>ALTER ON SCHEMA::dbo</c>, which is sufficient for
/// <c>ALTER PARTITION FUNCTION SPLIT RANGE</c>. No additional grant is
/// required.
/// </para>
/// </remarks>
public sealed class AuditLogPartitionMaintenance : IPartitionMaintenance
{
private const string PartitionFunctionName = "pf_AuditLog_Month";
private const string PartitionSchemeName = "ps_AuditLog_Month";
private const string TargetFileGroup = "PRIMARY";
private readonly ScadaLinkDbContext _context;
private readonly ILogger<AuditLogPartitionMaintenance> _logger;
public AuditLogPartitionMaintenance(
ScadaLinkDbContext context,
ILogger<AuditLogPartitionMaintenance>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<AuditLogPartitionMaintenance>.Instance;
}
/// <inheritdoc />
public async Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default)
{
// CAST the sql_variant `value` column to datetime2(7) — every boundary in
// pf_AuditLog_Month is declared as datetime2(7) by the migration, so the
// cast never loses precision.
const string sql = @"
SELECT MAX(CAST(rv.value AS datetime2(7)))
FROM sys.partition_range_values rv
INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id
WHERE pf.name = 'pf_AuditLog_Month';";
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var raw = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
if (raw is null || raw is DBNull)
{
return null;
}
// ExecuteScalarAsync materialises datetime2 as DateTime with
// DateTimeKind.Unspecified; the boundary values are stored at
// UTC midnight by convention (migration seeds with 'T00:00:00'),
// so we re-tag the kind so downstream comparisons against
// DateTime.UtcNow stay in the same kind space.
var dt = (DateTime)raw;
return DateTime.SpecifyKind(dt, DateTimeKind.Utc);
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(
int lookaheadMonths,
CancellationToken ct = default)
{
if (lookaheadMonths < 1)
{
throw new ArgumentOutOfRangeException(
nameof(lookaheadMonths),
lookaheadMonths,
"Lookahead must be at least one month — the partition function would otherwise be allowed to fall behind 'now'.");
}
var nowUtc = DateTime.UtcNow;
// Horizon: the FIRST-OF-MONTH that must be the strictly-greater-than
// max boundary after this call. Example: nowUtc = 2026-05-20 and
// lookaheadMonths = 1 → horizon = 2026-07-01 (so the partition for
// June 2026 is already in place by mid-May).
var horizon = NormalizeToFirstOfMonth(nowUtc).AddMonths(lookaheadMonths);
var max = await GetMaxBoundaryAsync(ct).ConfigureAwait(false);
if (max is null)
{
// No partition function (e.g. migrations not applied) — nothing
// we can safely SPLIT against. Log and return; the absence is a
// genuine misconfiguration that other parts of the system will
// surface louder than we could here.
_logger.LogWarning(
"EnsureLookaheadAsync: partition function {PartitionFunctionName} not found; skipping.",
PartitionFunctionName);
return Array.Empty<DateTime>();
}
// Start splitting from the FIRST month strictly after max — if max is
// already first-of-month (the common case), that's max + 1 month;
// otherwise NormalizeToFirstOfMonth rounds up.
var next = NormalizeToFirstOfMonth(max.Value.AddDays(1));
// Edge case: max already past horizon → no work to do.
if (next > horizon)
{
return Array.Empty<DateTime>();
}
var added = new List<DateTime>();
while (next <= horizon)
{
// Boundary literal must be a deterministic, culture-invariant ISO
// string — SQL Server parses it as datetime2 via implicit conversion.
// SPLIT RANGE does NOT accept @-parameters; the value is part of the
// DDL statement, so we render it directly. The format is
// guaranteed (yyyy-MM-ddTHH:mm:ss.fffffff) so there is no injection
// surface.
var literal = next.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture);
// Before every SPLIT we must (re-)set the NEXT USED filegroup on
// ps_AuditLog_Month. Even though the scheme was created with
// `ALL TO ([PRIMARY])` (which auto-populates NEXT USED once), SQL
// Server consumes that hint on the FIRST split — subsequent splits
// raise msg 7707 ("partition scheme … does not have any next used
// filegroup") unless NEXT USED is explicitly re-set. Re-issuing it
// before every split is idempotent and keeps the loop simple.
var sql = $@"
ALTER PARTITION SCHEME {PartitionSchemeName} NEXT USED [{TargetFileGroup}];
ALTER PARTITION FUNCTION {PartitionFunctionName}() SPLIT RANGE ('{literal}');";
try
{
await _context.Database.ExecuteSqlRawAsync(sql, ct).ConfigureAwait(false);
added.Add(next);
}
catch (SqlException ex)
{
// Belt-and-braces: even though we read max-boundary first, an
// ALTER from another process could have raced us. Logging at
// Warning rather than Error because the desired end state
// (boundary present) is satisfied by either path.
_logger.LogWarning(
ex,
"EnsureLookaheadAsync: SPLIT RANGE for boundary {Boundary:o} failed; continuing.",
next);
}
next = NextMonthBoundary(next);
}
return added;
}
/// <summary>
/// Rounds an arbitrary instant UP to the next first-of-month UTC. Inputs
/// that ARE already a first-of-month at midnight are returned as-is so
/// callers can compose this freely without double-incrementing.
/// </summary>
private static DateTime NormalizeToFirstOfMonth(DateTime instant)
{
var utc = instant.Kind == DateTimeKind.Utc
? instant
: DateTime.SpecifyKind(instant, DateTimeKind.Utc);
var firstOfThisMonth = new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
return utc == firstOfThisMonth ? firstOfThisMonth : firstOfThisMonth.AddMonths(1);
}
private static DateTime NextMonthBoundary(DateTime boundary) =>
boundary.AddMonths(1);
}

View File

@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.ConfigurationDatabase.Maintenance;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Services;
@@ -52,6 +54,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();
// #23 M6 Bundle D: IPartitionMaintenance drives the daily roll-forward
// of pf_AuditLog_Month from the central AuditLogPartitionMaintenanceService
// hosted service. Scoped because the implementation reuses the per-scope
// ScadaLinkDbContext for raw-SQL execution; the hosted service opens a
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
services.AddDataProtection()
.PersistKeysToDbContext<ScadaLinkDbContext>();