Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.cs
T

94 lines
5.2 KiB
C#

using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <summary>
/// Makes <c>dbo.AuditLog</c>'s EventId uniqueness <b>partition-aligned</b> by
/// dropping the non-aligned <c>UX_AuditLog_EventId</c> and leaving the clustered
/// <c>PK_AuditLog (EventId, OccurredAtUtc)</c> — already aligned on
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> — as the sole enforcement.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why.</b> <c>ALTER TABLE … SWITCH PARTITION</c> refuses to run while a
/// non-aligned index exists on the table, so the monthly retention purge
/// (<c>AuditLogRepository.SwitchOutPartitionAsync</c>) had to DROP
/// <c>UX_AuditLog_EventId</c>, 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.
/// </para>
/// <para>
/// <b>Why dropping it is safe — EventId is globally unique by construction.</b>
/// 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
/// <c>OccurredAtUtc</c> values (in two different partitions). That cannot happen
/// here: <c>EventId</c> is a GUID minted ONCE at the emitting site, in the same
/// operation that stamps <c>OccurredAtUtc</c>, 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.
/// </para>
/// <para>
/// <b>The idempotency probe still seeks.</b> Both ingest forms test
/// <c>WHERE EventId = @id</c>, 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.
/// </para>
/// <para>
/// <b>Edition note.</b> The alternative remedy — keeping the non-aligned index and
/// rebuilding it with <c>ONLINE = ON</c> 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.
/// </para>
/// <para>
/// <b>Down is a faithful reverse</b> and recreates the index on <c>[PRIMARY]</c>
/// exactly as <c>CollapseAuditLogToCanonical</c> created it. Reverting also
/// reinstates the SWITCH incompatibility, so the purge's guarded defensive
/// <c>DROP INDEX</c> (retained in <c>SwitchOutPartitionAsync</c> for databases
/// restored from pre-alignment backups) would remove it again on the next purge.
/// The partition function/scheme (<c>pf_AuditLog_Month</c> /
/// <c>ps_AuditLog_Month</c>) and every aligned index are untouched by both
/// directions.
/// </para>
/// </remarks>
public partial class AlignAuditLogEventIdUniqueness : Migration
{
/// <inheritdoc />
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;");
}
/// <inheritdoc />
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];");
}
}
}