fix(configdb): replace SwitchOutPartitionAsync stub with drop-and-rebuild dance (#23 M6)
Replaces M1's NotSupportedException stub with the production drop-DROP-INDEX → CREATE-staging → SWITCH PARTITION → DROP-staging → CREATE-INDEX dance documented in alog.md §4. UX_AuditLog_EventId is intentionally non-aligned with ps_AuditLog_Month so single-column EventId uniqueness can be enforced cheaply for InsertIfNotExistsAsync; SQL Server rejects ALTER TABLE SWITCH while a non-aligned unique index is present, so the implementation drops it, switches the partition data into a GUID-suffixed staging table on [PRIMARY], drops staging (discarding the rows), and rebuilds the unique index — all inside an explicit transaction with a CATCH that guarantees the unique index is rebuilt regardless of failure point. Also adds GetPartitionBoundariesOlderThanAsync to IAuditLogRepository: a CROSS APPLY over sys.partition_range_values + per-partition MAX(OccurredAtUtc) to enumerate retention-eligible months for the M6 purge actor (next commit). Tests verify: * Old partition's rows are removed; other months untouched * UX_AuditLog_EventId is rebuilt after a successful switch * InsertIfNotExistsAsync's first-write-wins idempotency still holds after switch * On engineered SWITCH failure (inbound FK from a probe table), SqlException propagates AND UX_AuditLog_EventId is still present (CATCH branch ran) * GetPartitionBoundariesOlderThanAsync returns only boundaries whose partition's MAX(OccurredAtUtc) is strictly older than the threshold; empty partitions excluded
This commit is contained in:
@@ -45,12 +45,43 @@ public interface IAuditLogRepository
|
||||
|
||||
/// <summary>
|
||||
/// Switches out (purges) the monthly partition whose lower bound is
|
||||
/// <paramref name="monthBoundary"/>. The honest M1 implementation throws
|
||||
/// <see cref="NotSupportedException"/>: the <c>UX_AuditLog_EventId</c> unique
|
||||
/// index is non-partition-aligned (lives on <c>[PRIMARY]</c>, not on
|
||||
/// <c>ps_AuditLog_Month</c>), so SQL Server rejects
|
||||
/// <c>ALTER TABLE … SWITCH PARTITION</c> until the drop-and-rebuild dance
|
||||
/// shipped by the M6 purge actor is in place.
|
||||
/// <paramref name="monthBoundary"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Drop-and-rebuild dance.</b> <c>UX_AuditLog_EventId</c> is intentionally
|
||||
/// non-partition-aligned (it lives on <c>[PRIMARY]</c> so single-column
|
||||
/// EventId uniqueness — required by <see cref="InsertIfNotExistsAsync"/> —
|
||||
/// can be enforced cheaply). SQL Server rejects
|
||||
/// <c>ALTER TABLE … SWITCH PARTITION</c> while a non-aligned unique index
|
||||
/// is present, so the M6 implementation drops the index, creates a staging
|
||||
/// table with byte-identical schema, switches the partition's data into
|
||||
/// staging, drops staging (discarding the rows), and rebuilds the unique
|
||||
/// index. The CATCH branch guarantees the index is rebuilt even on partial
|
||||
/// failure so the table never returns to live traffic without its
|
||||
/// idempotency-supporting index.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Outage window.</b> The dance briefly removes the unique index, so
|
||||
/// concurrent <see cref="InsertIfNotExistsAsync"/> calls during the switch
|
||||
/// could in principle race past the IF NOT EXISTS check without the index
|
||||
/// catching the duplicate. This is acceptable for the daily purge cadence
|
||||
/// — the inserts that the IF NOT EXISTS check guards are themselves rare
|
||||
/// enough that a sub-second collision window is operationally negligible,
|
||||
/// and the composite PK still rejects same-(EventId, OccurredAtUtc) rows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the set of <c>pf_AuditLog_Month</c> partition lower-bound
|
||||
/// boundaries whose partitions contain only rows with
|
||||
/// <see cref="AuditEvent.OccurredAtUtc"/> strictly older than
|
||||
/// <paramref name="threshold"/>. Boundaries whose partition is empty are
|
||||
/// excluded (a no-op switch is wasted work). Used by the M6 purge actor
|
||||
/// to enumerate retention-eligible months on every tick.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -179,18 +179,199 @@ VALUES
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M1 honest contract: throws <see cref="NotSupportedException"/>. The
|
||||
/// <c>UX_AuditLog_EventId</c> unique index is non-aligned with
|
||||
/// <c>ps_AuditLog_Month</c> (it lives on <c>[PRIMARY]</c> to keep
|
||||
/// <see cref="InsertIfNotExistsAsync"/> cheap), and SQL Server rejects
|
||||
/// <c>ALTER TABLE … SWITCH PARTITION</c> when a non-aligned index is present.
|
||||
/// The drop-and-rebuild dance that makes the switch legal ships with the M6
|
||||
/// purge actor.
|
||||
/// M6-T4 production implementation of the drop-and-rebuild dance documented
|
||||
/// on <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>.
|
||||
/// </summary>
|
||||
public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The staging table name is GUID-suffixed so concurrent purge attempts on
|
||||
/// different boundaries cannot collide. The staging schema is byte-identical
|
||||
/// to the live <c>AuditLog</c> table (same column types, lengths,
|
||||
/// nullability, and clustered-key shape) — SQL Server's
|
||||
/// <c>ALTER TABLE … SWITCH PARTITION</c> rejects any drift. Keep this CREATE
|
||||
/// in sync with both the migration that ships the live table
|
||||
/// (<c>20260520142214_AddAuditLogTable</c>) and
|
||||
/// <c>AuditLogEntityTypeConfiguration</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All five steps run inside an explicit transaction so the SWITCH +
|
||||
/// staging-DROP are atomic from the perspective of a consumer reading via
|
||||
/// snapshot isolation; the CATCH rolls back and runs an idempotent
|
||||
/// "rebuild UX_AuditLog_EventId if it doesn't exist" so a partial failure
|
||||
/// never leaves the live table without its idempotency-supporting unique
|
||||
/// index.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " +
|
||||
"unique index; the drop-and-rebuild dance ships in M6 (purge actor).");
|
||||
// GUID-suffixed staging name: prevents collision with any concurrent
|
||||
// purge attempt and avoids polluting the AuditLog object namespace with
|
||||
// a predictable identifier.
|
||||
var stagingTableName = $"AuditLog_Staging_{Guid.NewGuid():N}";
|
||||
|
||||
// ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this
|
||||
// unambiguously and the value is round-trip-safe across SET DATEFORMAT
|
||||
// settings.
|
||||
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
var sql = $@"
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- 1. Drop the non-aligned unique index. ALTER TABLE SWITCH refuses
|
||||
-- to run while it exists.
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
|
||||
|
||||
-- 2. Staging table on [PRIMARY] (non-partitioned) with column shapes
|
||||
-- byte-identical to dbo.AuditLog. Any drift here causes SWITCH to
|
||||
-- reject the operation with msg 4904/4915.
|
||||
CREATE TABLE dbo.[{stagingTableName}] (
|
||||
EventId uniqueidentifier NOT NULL,
|
||||
OccurredAtUtc datetime2(7) NOT NULL,
|
||||
IngestedAtUtc datetime2(7) NULL,
|
||||
Channel varchar(32) NOT NULL,
|
||||
Kind varchar(32) NOT NULL,
|
||||
CorrelationId uniqueidentifier NULL,
|
||||
SourceSiteId varchar(64) NULL,
|
||||
SourceInstanceId varchar(128) NULL,
|
||||
SourceScript varchar(128) NULL,
|
||||
Actor varchar(128) NULL,
|
||||
Target varchar(256) NULL,
|
||||
Status varchar(32) NOT NULL,
|
||||
HttpStatus int NULL,
|
||||
DurationMs int NULL,
|
||||
ErrorMessage nvarchar(1024) NULL,
|
||||
ErrorDetail nvarchar(max) NULL,
|
||||
RequestSummary nvarchar(max) NULL,
|
||||
ResponseSummary nvarchar(max) NULL,
|
||||
PayloadTruncated bit NOT NULL,
|
||||
Extra nvarchar(max) NULL,
|
||||
ForwardState varchar(32) NULL,
|
||||
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||
) ON [PRIMARY];
|
||||
|
||||
-- 3. Switch the partition out. $partition.pf_AuditLog_Month returns
|
||||
-- the partition number that contains the supplied boundary value;
|
||||
-- SWITCH PARTITION N moves that partition's pages to the staging
|
||||
-- table (metadata-only, no row copying).
|
||||
DECLARE @partitionNumber int = $partition.pf_AuditLog_Month('{monthBoundaryStr}');
|
||||
DECLARE @sql nvarchar(max) = 'ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[{stagingTableName}];';
|
||||
EXEC sp_executesql @sql;
|
||||
|
||||
-- 4. Drop staging — the rows are discarded here. This is the purge.
|
||||
DROP TABLE dbo.[{stagingTableName}];
|
||||
|
||||
-- 5. Rebuild the non-aligned unique index. Live traffic that hit the
|
||||
-- table during steps 1-4 saw composite-PK uniqueness only; from
|
||||
-- here on, single-column EventId uniqueness is restored.
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY];
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
|
||||
|
||||
-- Best-effort staging cleanup. The DROP INDEX in step 1 is now
|
||||
-- rolled back (so the index is back), but the staging table from
|
||||
-- step 2 may or may not survive the rollback depending on the
|
||||
-- failure point. Guard the DROP so a missing staging table doesn't
|
||||
-- mask the original error.
|
||||
IF OBJECT_ID('dbo.[{stagingTableName}]', 'U') IS NOT NULL DROP TABLE dbo.[{stagingTableName}];
|
||||
|
||||
-- Idempotent index rebuild — covers the niche case where ROLLBACK
|
||||
-- failed to restore UX_AuditLog_EventId (or the failure happened
|
||||
-- AFTER the COMMIT, which shouldn't be possible inside this TRY
|
||||
-- but is cheap insurance). Without this, a failed switch could
|
||||
-- leave the live table without its idempotency-supporting index.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY];
|
||||
|
||||
-- Surface the original error to the caller — the purge actor logs
|
||||
-- and continues with the next boundary.
|
||||
THROW;
|
||||
END CATCH;";
|
||||
|
||||
await _context.Database.ExecuteSqlRawAsync(sql, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the set of <c>pf_AuditLog_Month</c> boundaries whose partition's
|
||||
/// <c>MAX(OccurredAtUtc)</c> is strictly older than <paramref name="threshold"/>.
|
||||
/// Boundaries with empty partitions are excluded — purging an empty
|
||||
/// partition is wasted I/O.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The CTE pulls every boundary value defined by the partition function and
|
||||
/// joins it (via <c>$PARTITION.pf_AuditLog_Month</c>) to the live AuditLog
|
||||
/// to compute per-partition <c>MAX(OccurredAtUtc)</c>. The outer filter
|
||||
/// keeps only those whose MAX is non-NULL (partition has rows) AND strictly
|
||||
/// less than the threshold (every row is past retention).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Note: the query scans the live <c>OccurredAtUtc</c> column to compute
|
||||
/// the MAX per partition. With <c>IX_AuditLog_OccurredAtUtc</c> on the
|
||||
/// partition-aligned scheme this is a single index seek per partition; for
|
||||
/// 24 partitions and a daily purge cadence the cost is negligible.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var thresholdUtc = threshold.ToUniversalTime();
|
||||
var thresholdStr = thresholdUtc.ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
|
||||
// Per-partition MAX over the live table. We materialise the boundary
|
||||
// list first (24 rows) then LEFT JOIN to the MAX aggregate so empty
|
||||
// partitions surface as NULL and get filtered out by the WHERE clause.
|
||||
var sql = $@"
|
||||
WITH Boundaries AS (
|
||||
SELECT CAST(rv.value AS datetime2(7)) AS BoundaryValue,
|
||||
rv.boundary_id AS BoundaryId
|
||||
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'
|
||||
)
|
||||
SELECT b.BoundaryValue
|
||||
FROM Boundaries b
|
||||
CROSS APPLY (
|
||||
SELECT MAX(a.OccurredAtUtc) AS MaxOccurredAt
|
||||
FROM dbo.AuditLog a
|
||||
WHERE $PARTITION.pf_AuditLog_Month(a.OccurredAtUtc) = b.BoundaryId + 1
|
||||
) x
|
||||
WHERE x.MaxOccurredAt IS NOT NULL
|
||||
AND x.MaxOccurredAt < CAST('{thresholdStr}' AS datetime2(7))
|
||||
ORDER BY b.BoundaryValue;";
|
||||
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
}
|
||||
|
||||
var results = new List<DateTime>();
|
||||
try
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(reader.GetDateTime(0));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,5 +216,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
|
||||
public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
|
||||
_inner.SwitchOutPartitionAsync(monthBoundary, ct);
|
||||
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
|
||||
public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
@@ -309,21 +310,221 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(allIds));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests
|
||||
// ------------------------------------------------------------------------
|
||||
//
|
||||
// The partition-switch path replaces M1's NotSupportedException stub with
|
||||
// the production drop-DROP-INDEX → CREATE-staging → SWITCH PARTITION →
|
||||
// DROP-staging → CREATE-INDEX dance documented in alog.md §4. These tests
|
||||
// verify the side effects an outsider can observe:
|
||||
// * rows in the targeted month are removed
|
||||
// * rows in OTHER months are NOT touched
|
||||
// * UX_AuditLog_EventId still exists after a successful switch
|
||||
// * InsertIfNotExistsAsync's first-write-wins idempotency still holds
|
||||
// after a switch (the rebuilt index is real)
|
||||
// * a thrown SqlException leaves UX_AuditLog_EventId rebuilt (the CATCH
|
||||
// branch's recovery path runs)
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1()
|
||||
public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// Three distinct months — Jan, Feb, Mar 2026 — so the switch on Jan's
|
||||
// boundary purges exactly one month's worth of rows. Boundary values
|
||||
// come from the partition function's pre-seeded list (alog.md §4).
|
||||
var janEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc));
|
||||
var febEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 2, 15, 10, 0, 0, DateTimeKind.Utc));
|
||||
var marEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc));
|
||||
await repo.InsertIfNotExistsAsync(janEvt);
|
||||
await repo.InsertIfNotExistsAsync(febEvt);
|
||||
await repo.InsertIfNotExistsAsync(marEvt);
|
||||
|
||||
// Boundary value '2026-01-01' identifies the January 2026 partition under
|
||||
// RANGE RIGHT semantics ($PARTITION returns the partition into which the
|
||||
// boundary value itself falls — the partition whose lower bound is the
|
||||
// boundary).
|
||||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var remaining = await readContext.Set<AuditEvent>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.DoesNotContain(remaining, e => e.EventId == janEvt.EventId);
|
||||
Assert.Contains(remaining, e => e.EventId == febEvt.EventId);
|
||||
Assert.Contains(remaining, e => e.EventId == marEvt.EventId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SwitchOutPartitionAsync_RebuildsUxIndex_AfterSwitch()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// The partition-switch path is intentionally blocked in M1 because
|
||||
// UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships
|
||||
// with the M6 purge actor.
|
||||
var ex = await Assert.ThrowsAsync<NotSupportedException>(
|
||||
() => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||
// Pick a different month per test so successive test runs (which share
|
||||
// the fixture's MSSQL database) don't tread on each other.
|
||||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await using var verifyContext = CreateContext();
|
||||
var indexExists = await ScalarAsync<int>(
|
||||
verifyContext,
|
||||
"SELECT COUNT(*) FROM sys.indexes " +
|
||||
"WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
|
||||
Assert.Equal(1, indexExists);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SwitchOutPartitionAsync_InsertIfNotExistsAsync_StillEnforcesFirstWriteWins_AfterSwitch()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// Pre-existing row in May 2026 — must survive a switch on a different
|
||||
// (older) partition.
|
||||
var preExisting = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc));
|
||||
await repo.InsertIfNotExistsAsync(preExisting);
|
||||
|
||||
// Switch out the June 2026 partition (different month, empty).
|
||||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
// Re-attempting the same EventId after the switch must STILL be a no-op
|
||||
// (UX_AuditLog_EventId is the index that enables idempotency; if the
|
||||
// rebuild left it broken, this insert would silently produce a duplicate
|
||||
// row and the count assertion below would catch it).
|
||||
var dup = preExisting with { ErrorMessage = "second-should-be-ignored-after-switch" };
|
||||
await repo.InsertIfNotExistsAsync(dup);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditEvent>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(rows);
|
||||
Assert.Equal(preExisting.EventId, rows[0].EventId);
|
||||
// First-write-wins: the original ErrorMessage (null) survives.
|
||||
Assert.Null(rows[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SwitchOutPartitionAsync_PartialFailure_RebuildsUxIndex_RaisesException()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// Force a deterministic switch failure with an inbound FOREIGN KEY:
|
||||
// ALTER TABLE … SWITCH refuses to move rows out of a partition that's
|
||||
// referenced by an FK from another table, raising msg 4928
|
||||
// ("ALTER TABLE SWITCH statement failed because target table … has a
|
||||
// foreign key …"). The CATCH branch then rolls back and rebuilds the
|
||||
// unique index — which the assertion below verifies.
|
||||
//
|
||||
// The probe table is uniquely named with a guid suffix so reruns of
|
||||
// this test inside the same fixture DB never collide. We clean it up
|
||||
// in the finally so the constraint never leaks into other tests.
|
||||
var probeTable = $"AuditFkProbe_{Guid.NewGuid():N}".Substring(0, 32);
|
||||
await using (var setup = new SqlConnection(_fixture.ConnectionString))
|
||||
{
|
||||
await setup.OpenAsync();
|
||||
await using var cmd = setup.CreateCommand();
|
||||
// Composite FK references AuditLog's composite PK (EventId, OccurredAtUtc).
|
||||
cmd.CommandText =
|
||||
$"CREATE TABLE dbo.[{probeTable}] ( " +
|
||||
$" EventId uniqueidentifier NOT NULL, " +
|
||||
$" OccurredAtUtc datetime2(7) NOT NULL, " +
|
||||
$" CONSTRAINT FK_{probeTable}_AuditLog FOREIGN KEY (EventId, OccurredAtUtc) " +
|
||||
$" REFERENCES dbo.AuditLog(EventId, OccurredAtUtc));";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ex = await Assert.ThrowsAnyAsync<SqlException>(
|
||||
() => repo.SwitchOutPartitionAsync(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||
// Smoke-check the message references the SWITCH statement so we
|
||||
// know we hit the engineered failure, not some unrelated error.
|
||||
Assert.Contains("SWITCH", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always drop the probe table so the FK is gone before the next
|
||||
// test runs against the shared fixture.
|
||||
await using var cleanup = new SqlConnection(_fixture.ConnectionString);
|
||||
await cleanup.OpenAsync();
|
||||
await using var cmd = cleanup.CreateCommand();
|
||||
cmd.CommandText =
|
||||
$"IF OBJECT_ID('dbo.[{probeTable}]', 'U') IS NOT NULL DROP TABLE dbo.[{probeTable}];";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// The CATCH block in the production SQL guarantees UX_AuditLog_EventId
|
||||
// is rebuilt regardless of which step failed inside the TRY.
|
||||
await using var verifyContext = CreateContext();
|
||||
var indexExists = await ScalarAsync<int>(
|
||||
verifyContext,
|
||||
"SELECT COUNT(*) FROM sys.indexes " +
|
||||
"WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
|
||||
Assert.Equal(1, indexExists);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetPartitionBoundariesOlderThanAsync_ReturnsBoundaries_WithMaxOccurredOlderThanThreshold()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// Seed events in two months: July 2026 (old) and August 2026 (new).
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 7, 10, 0, 0, 0, DateTimeKind.Utc)));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc)));
|
||||
|
||||
// Threshold = Aug 1 2026 — July partition's MAX (July 10) is older;
|
||||
// August partition's MAX (August 10) is newer. We expect only the July
|
||||
// boundary back.
|
||||
var threshold = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var boundaries = await repo.GetPartitionBoundariesOlderThanAsync(threshold);
|
||||
|
||||
// The repo may also return EARLIER boundaries that have no data (their
|
||||
// MAX is NULL → treated as "no data, nothing to purge" by the contract).
|
||||
// We only assert the inclusion/exclusion that matters for our seeded
|
||||
// rows.
|
||||
Assert.Contains(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
|
||||
Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
|
||||
}
|
||||
|
||||
private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
|
||||
{
|
||||
var conn = context.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync();
|
||||
}
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user