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);
}