9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
219 lines
9.8 KiB
C#
219 lines
9.8 KiB
C#
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;
|
|
|
|
/// <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="ScadaBridgeDbContext"/>
|
|
/// — 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>scadabridge_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 ScadaBridgeDbContext _context;
|
|
private readonly ILogger<AuditLogPartitionMaintenance> _logger;
|
|
|
|
/// <summary>Initializes the maintenance implementation with the database context and optional logger.</summary>
|
|
/// <param name="context">The EF Core database context used to execute partition-management SQL.</param>
|
|
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/> when null.</param>
|
|
public AuditLogPartitionMaintenance(
|
|
ScadaBridgeDbContext 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}');";
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <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);
|
|
}
|