using System.Globalization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance; /// /// EF/SQL-Server implementation of that /// rolls forward pf_AuditLog_Month by issuing /// ALTER PARTITION FUNCTION … SPLIT RANGE for each missing future /// monthly boundary. /// /// /// /// The class is scoped (registered alongside the other repositories in /// AddConfigurationDatabase) because it shares /// — the hosted service opens a per-tick DI scope, resolves a fresh instance, /// and lets the scope's DbContext dispose with it. The class itself /// holds no state between calls. /// /// /// Idempotency model. Each tick reads the current max boundary from /// sys.partition_range_values 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 added list semantically precise. /// /// /// Why "first of next month". The migration seeds boundaries on the /// first-of-month at midnight UTC; we preserve that convention so the /// resulting partition layout is uniform. /// rounds an arbitrary timestamp up to the next first-of-month boundary /// (e.g. 2026-05-20 → 2026-06-01), and /// walks one month at a time from there. /// /// /// Permissions. The migration's scadabridge_audit_purger role /// already carries ALTER ON SCHEMA::dbo, which is sufficient for /// ALTER PARTITION FUNCTION SPLIT RANGE. No additional grant is /// required. /// /// 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 ScadaBridgeDbContext _context; private readonly ILogger _logger; /// Initializes the maintenance implementation with the database context and optional logger. /// The EF Core database context used to execute partition-management SQL. /// Optional logger; defaults to when null. public AuditLogPartitionMaintenance( ScadaBridgeDbContext context, ILogger? logger = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? NullLogger.Instance; } /// public async Task 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); } } } /// public async Task> 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(); } // 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(); } var added = new List(); 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}');"; // The loop pre-reads max-boundary and only issues // SPLITs for strictly-greater months, so msg 7708/7711 ("boundary // already exists") cannot happen by construction. Any OTHER // SqlException (permission revoked on the role, deadlock victim, // log full, filegroup full, transient connection drop) means the // boundary genuinely failed to create. The previous catch-and- // continue silently moved on to the next month, splitting month // N+1 successfully and leaving a permanent partition hole for // month N that blocks partition-switch purge until an operator // notices and rebuilds. Let SqlException propagate so the daily // hosted-service tick logs an Error and the next tick retries // from the same boundary (at-least-once, no holes). await _context.Database.ExecuteSqlRawAsync(sql, ct).ConfigureAwait(false); added.Add(next); next = NextMonthBoundary(next); } return added; } /// /// 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. /// 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); }