diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index 7b15962..9932c5c 100644
--- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -45,12 +45,43 @@ public interface IAuditLogRepository
///
/// Switches out (purges) the monthly partition whose lower bound is
- /// . The honest M1 implementation throws
- /// : the UX_AuditLog_EventId unique
- /// index is non-partition-aligned (lives on [PRIMARY], not on
- /// ps_AuditLog_Month), so SQL Server rejects
- /// ALTER TABLE … SWITCH PARTITION until the drop-and-rebuild dance
- /// shipped by the M6 purge actor is in place.
+ /// .
///
+ ///
+ ///
+ /// Drop-and-rebuild dance. UX_AuditLog_EventId is intentionally
+ /// non-partition-aligned (it lives on [PRIMARY] so single-column
+ /// EventId uniqueness — required by —
+ /// can be enforced cheaply). SQL Server rejects
+ /// ALTER TABLE … SWITCH PARTITION 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.
+ ///
+ ///
+ /// Outage window. The dance briefly removes the unique index, so
+ /// concurrent 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.
+ ///
+ ///
Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
+
+ ///
+ /// Returns the set of pf_AuditLog_Month partition lower-bound
+ /// boundaries whose partitions contain only rows with
+ /// strictly older than
+ /// . 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.
+ ///
+ Task> GetPartitionBoundariesOlderThanAsync(
+ DateTime threshold,
+ CancellationToken ct = default);
}
diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index d88271f..9dc2f41 100644
--- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -179,18 +179,199 @@ VALUES
}
///
- /// M1 honest contract: throws . The
- /// UX_AuditLog_EventId unique index is non-aligned with
- /// ps_AuditLog_Month (it lives on [PRIMARY] to keep
- /// cheap), and SQL Server rejects
- /// ALTER TABLE … SWITCH PARTITION 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 .
///
- public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
+ ///
+ ///
+ /// 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 AuditLog table (same column types, lengths,
+ /// nullability, and clustered-key shape) — SQL Server's
+ /// ALTER TABLE … SWITCH PARTITION rejects any drift. Keep this CREATE
+ /// in sync with both the migration that ships the live table
+ /// (20260520142214_AddAuditLogTable) and
+ /// AuditLogEntityTypeConfiguration.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ 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);
+ }
+
+ ///
+ /// Returns the set of pf_AuditLog_Month boundaries whose partition's
+ /// MAX(OccurredAtUtc) is strictly older than .
+ /// Boundaries with empty partitions are excluded — purging an empty
+ /// partition is wasted I/O.
+ ///
+ ///
+ ///
+ /// The CTE pulls every boundary value defined by the partition function and
+ /// joins it (via $PARTITION.pf_AuditLog_Month) to the live AuditLog
+ /// to compute per-partition MAX(OccurredAtUtc). 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).
+ ///
+ ///
+ /// Note: the query scans the live OccurredAtUtc column to compute
+ /// the MAX per partition. With IX_AuditLog_OccurredAtUtc 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.
+ ///
+ ///
+ public async Task> 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();
+ 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;
}
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
index 36de05f..203fb6d 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
@@ -216,5 +216,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture
_inner.SwitchOutPartitionAsync(monthBoundary, ct);
+
+ public Task> GetPartitionBoundariesOlderThanAsync(
+ DateTime threshold, CancellationToken ct = default) =>
+ _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
}
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
index 2d77dcd..d1dad16 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
@@ -89,6 +89,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture
Task.CompletedTask;
+
+ public Task> GetPartitionBoundariesOlderThanAsync(
+ DateTime threshold, CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
}
///
diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
index 958b2b1..df1daeb 100644
--- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
+++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
@@ -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
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()
+ .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(
- () => 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(
+ 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()
+ .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(
+ () => 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(
+ 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 ScalarAsync(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 ------------------------------------------------------------