feat(auditlog): AuditLogPartitionMaintenanceService monthly roll-forward (#23 M6)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user