feat(audit): flip AuditLog clustered key to (OccurredAtUtc, EventId) (ScadaBridge#111)

Variant B2 of the #78 benchmark: migration FlipAuditLogClusteredKey drops the
twelve non-clustered indexes, re-adds PK_AuditLog as (OccurredAtUtc, EventId)
on ps_AuditLog_Month, adds the aligned unique UX_AuditLog_EventId_Occurred
(EventId, OccurredAtUtc) for the idempotency probe, and rebuilds the twelve
indexes unchanged. Guarded (re-runnable) Up, inverse-rebuild Down; production
scripts docs/plans/sql/FlipAuditLogClusteredKey{,.rollback}.sql.

The partition-switch purge staging table now reads the live clustered key
order from sys.index_columns, so it switches either shape (old binaries
hard-code the old order: ship with the binaries, both centrals stopped).

Evidence: 300k rows, page 1 90,532 -> 34 logical reads (ordered backward
clustered scan under Top), keyset page 90,532 -> 46, probe 14 -> 14;
FlipAuditLogClusteredKeyMigrationTests pins indexes, grants, Down/Up, the
staging key (mutation: hard-coded old key fails SWITCH) and the read ratio.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 22:50:35 -05:00
parent 559e02f5c8
commit 5e7d5389d4
14 changed files with 3032 additions and 43 deletions
@@ -39,9 +39,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
[Fact]
public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey()
{
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
// AuditLog table — the clustered key must include the partition column
// (OccurredAtUtc) so each row can be located in its partition.
// Composite PK {OccurredAtUtc, EventId} (FlipAuditLogClusteredKey, ScadaBridge#111):
// the clustered key must include the partition column (OccurredAtUtc), and leading
// with it lets the Audit Log page's newest-first TOP-N / keyset read an ordered range
// instead of the whole partitioned table.
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
@@ -51,12 +52,15 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.NotNull(pk);
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, pkPropertyNames);
Assert.Equal(new[] { nameof(AuditLogRow.OccurredAtUtc), nameof(AuditLogRow.EventId) }, pkPropertyNames);
}
/// <summary>Verifies that no non-aligned EventId index is declared and uniqueness rides the aligned clustered key.</summary>
/// <summary>
/// Verifies that no single-column (non-aligned) EventId index is declared, and that EventId
/// uniqueness rides the aligned unique (EventId, OccurredAtUtc) index.
/// </summary>
[Fact]
public void Configure_DeclaresNoNonAlignedEventIdIndex_UniquenessRidesTheAlignedClusteredKey()
public void Configure_DeclaresNoNonAlignedEventIdIndex_UniquenessRidesTheAlignedEventIdIndex()
{
// AlignAuditLogEventIdUniqueness: the standalone non-aligned
// UX_AuditLog_EventId is gone. EventId uniqueness now rides the clustered
@@ -69,6 +73,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
//
// Re-declaring a single-column unique index here would silently reinstate
// the SWITCH incompatibility, so this test pins its absence.
//
// FlipAuditLogClusteredKey (ScadaBridge#111) moved OccurredAtUtc to the front of the
// clustered key, so the EventId-leading seek now comes from UX_AuditLog_EventId_Occurred:
// unique, and keyed (EventId, OccurredAtUtc) so it stays partition-aligned.
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
@@ -76,11 +84,11 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
entity!.GetIndexes(),
i => i.GetDatabaseName() == "UX_AuditLog_EventId");
var pk = entity.FindPrimaryKey();
Assert.NotNull(pk);
var ux = Assert.Single(entity.GetIndexes(), i => i.GetDatabaseName() == "UX_AuditLog_EventId_Occurred");
Assert.True(ux.IsUnique);
Assert.Equal(
new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) },
pk!.Properties.Select(p => p.Name).ToArray());
ux.Properties.Select(p => p.Name).ToArray());
}
/// <summary>Verifies that the configuration maps the expected number of properties.</summary>
@@ -189,8 +197,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred",
"IX_AuditLog_Unchained", // hash chain (ScadaBridge#41)
// UX_AuditLog_EventId is intentionally absent — dropped by
// AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it.
"UX_AuditLog_EventId_Occurred", // FlipAuditLogClusteredKey (ScadaBridge#111)
// UX_AuditLog_EventId (single-column, non-aligned) is intentionally absent —
// dropped by AlignAuditLogEventIdUniqueness.
};
Assert.Equal(expected, indexNames);