using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations { /// /// Makes dbo.AuditLog's EventId uniqueness partition-aligned by /// dropping the non-aligned UX_AuditLog_EventId and leaving the clustered /// PK_AuditLog (EventId, OccurredAtUtc) — already aligned on /// ps_AuditLog_Month(OccurredAtUtc) — as the sole enforcement. /// /// /// /// Why. ALTER TABLE … SWITCH PARTITION refuses to run while a /// non-aligned index exists on the table, so the monthly retention purge /// (AuditLogRepository.SwitchOutPartitionAsync) had to DROP /// UX_AuditLog_EventId, switch, and then CREATE it again — an OFFLINE /// whole-table unique-index build, inside the switch transaction, blocking every /// audit writer for its duration. It also opened a window in which the index that /// backs ingest idempotency did not exist at all, and a mid-dance failure could /// leave the live table without it until a later tick's CATCH branch repaired it. /// With alignment there is nothing to drop, so the switch is metadata-only and the /// purge stops competing with ingest. /// /// /// Why dropping it is safe — EventId is globally unique by construction. /// The composite key enforces uniqueness of the PAIR, not of EventId alone, so in /// principle the same EventId could now be stored twice under two different /// OccurredAtUtc values (in two different partitions). That cannot happen /// here: EventId is a GUID minted ONCE at the emitting site, in the same /// operation that stamps OccurredAtUtc, and both travel together verbatim /// through telemetry and reconciliation — nothing downstream re-stamps either /// field. A given EventId therefore always arrives with the same OccurredAtUtc and /// can only ever map to one partition, which makes pair-uniqueness equivalent to /// EventId-uniqueness for every row this system produces. GUID collision across /// partitions is not a real risk. /// /// /// The idempotency probe still seeks. Both ingest forms test /// WHERE EventId = @id, which is the LEADING column of the clustered PK, so /// the probe remains an index seek. The cost changes shape rather than order: it /// becomes one seek per partition (the partition column is not in the predicate, so /// SQL Server cannot eliminate partitions) instead of a single seek on a /// non-partitioned index. Against a monthly scheme that is a couple of dozen /// shallow B-tree seeks — cheap, and paid on a path that now issues one statement /// per telemetry packet rather than one per row. /// /// /// Edition note. The alternative remedy — keeping the non-aligned index and /// rebuilding it with ONLINE = ON outside the switch transaction — requires /// Enterprise (or Azure SQL / Developer) edition; online index rebuild is not /// available on Standard, which this deployment does not guarantee. Alignment /// needs no edition-specific feature and removes the rebuild entirely, so it is /// preferred regardless of edition. /// /// /// Down is a faithful reverse and recreates the index on [PRIMARY] /// exactly as CollapseAuditLogToCanonical created it. Reverting also /// reinstates the SWITCH incompatibility, so the purge's guarded defensive /// DROP INDEX (retained in SwitchOutPartitionAsync for databases /// restored from pre-alignment backups) would remove it again on the next purge. /// The partition function/scheme (pf_AuditLog_Month / /// ps_AuditLog_Month) and every aligned index are untouched by both /// directions. /// /// public partial class AlignAuditLogEventIdUniqueness : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { // Raw, existence-guarded SQL rather than the scaffolded DropIndex: the // AuditLog table is raw-SQL managed (partition scheme, persisted computed // columns, append-only role grants), so its migrations stay explicit and // re-runnable. The guard also lets this apply cleanly to a database whose // index was already removed by the purge path's defensive cleanup. migrationBuilder.Sql(@" 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;"); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(@" 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];"); } } }