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
+1
View File
@@ -182,6 +182,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- Audit-write failure NEVER aborts the user-facing action — audit is best-effort, the action's own success/failure path is authoritative.
- 365-day central retention with monthly partition-switch purge; per-channel retention overrides (`AuditLog:PerChannelRetentionDays`) expire rows earlier than the global window via a bounded, batched row DELETE on the purge actor's maintenance path — values must be shorter than the global window (M5.5 T3); 7-day site SQLite retention with a hard `ForwardState` invariant (no row purged until forwarded or reconciled).
- Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE).
- **Clustered key `(OccurredAtUtc, EventId)` + aligned unique `UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)` (migration `FlipAuditLogClusteredKey`, #111, B2 of the #78 benchmark).** The Audit Log page's newest-first page/keyset now reads an ordered range instead of the whole table (300k rows: 90,532 → 34 reads). Offline whole-table rebuild — script `docs/plans/sql/FlipAuditLogClusteredKey.sql` (+ `.rollback.sql`), both centrals stopped: the partition-switch staging table now reads the live key order, but binaries older than the flip hard-code the old one. DEV/QA not applied (sizing + window in #111).
- **Parquet cold archive (T2, ScadaBridge#42) — default OFF (`AuditLog:Archive:Enabled` + absolute `Directory`, shared by both centrals).** The purge actor writes each expired partition to `{Directory}/{YYYY}/auditlog-{YYYY-MM}-{stamp}Z.parquet` (10 stored columns + `ChainSeq`/`RowHash`, tick-exact `OccurredAtUtcTicks`) plus `.tombstones.parquet` and a `.manifest.json` (written last; chain head at archive time, SHA-256 per file), read under the chain lock SHARED; then the switch runs with a guard (under the EXCLUSIVE lock: partition row count + each month's `LastSeq` must still equal the archive's, else THROW 51002/51003 and nothing is purged). Fail closed: a partition that cannot be archived is not purged. `audit archive list|verify` = `ListAuditArchives`/`VerifyAuditArchive` (verify-chain's gate): file hashes, chain recomputed from the archive, chain end vs the DB purge anchor (`Matches`/`Superseded`/`NotPurged`/`Mismatch`), CLI exit 3. `audit export --format parquet` streams the 24 export columns (was 501). No migration; no re-import path (open decision), object storage not wired (mount it).
- **Hash-chain tamper evidence (T1, ScadaBridge#41) — default OFF (`AuditLog:HashChain:Enabled`).** One SHA-256 chain per UTC calendar month: `ChainSeq` + `RowHash = SHA-256(prev || canonical(row))` on `AuditLog` (migration `AddAuditLogHashChain`), linked by the central `AuditLogHashChainService` (both nodes, serialised by `sp_getapplock`) — never by the ingest path. `AuditLogChainHead` catches tail deletion and carries the partition-switch checkpoint anchor; the per-channel DELETE writes an `AuditLogChainTombstone` per chained row in the same transaction. `audit verify-chain --month` = management command `VerifyAuditChain` (OperationalAudit, unscoped callers only), first break with partition/ChainSeq/EventId, CLI exit 3. A chained row's `SourceNode` is frozen (backfill skips it). Unsigned; signing/external anchoring are open.
- Node-of-origin is captured alongside site-of-origin: `SourceNode` (`varchar(64)` NULL) on `AuditLog`, `Notifications`, and `SiteCalls` — `node-a`/`node-b` for site rows (qualified by `SourceSiteId`/`SourceSite`), `central-a`/`central-b` for central direct-write rows. Stamped at the writing node, carried verbatim through telemetry + reconciliation, and indexed via `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` on `AuditLog`.
@@ -1,6 +1,6 @@
# AuditLog clustered key benchmark and P4 / P6 follow-ups (ScadaBridge#78)
Date: 2026-11-09. Status: benchmark record + recommendation only; no migration written.
Date: 2026-11-09. Status: benchmark record + recommendation. **Implemented 2026-11-10 as migration `FlipAuditLogClusteredKey` (variant B2, #111)** — script `docs/plans/sql/FlipAuditLogClusteredKey.sql` + `.rollback.sql`; the partition-switch staging table now follows the live key order (the "old binaries work on either shape" claim below is wrong for that one statement — binaries before the flip cannot switch a flipped table). Reduced re-run, SQL Server 2022 dev container, 300k rows / 631 MB: page 1 90,532 → 34 logical reads (plan: `Clustered Index Scan … ORDERED BACKWARD` under `Top`, no sort), keyset page 90,532 → 46, idempotency probe 14 → 14; `FlipAuditLogClusteredKeyMigrationTests` pins the same ratio on 20k rows (76 vs 7,420 / 274 vs 7,393).
## P3 - flip `dbo.AuditLog` clustered key `(EventId, OccurredAtUtc)` -> `(OccurredAtUtc, EventId)`?
@@ -0,0 +1,50 @@
-- FlipAuditLogClusteredKey ROLLBACK (ScadaBridge#111): the inverse rebuild back to clustered
-- (EventId, OccurredAtUtc), UX_AuditLog_EventId_Occurred dropped, the twelve non-clustered
-- indexes rebuilt unchanged. Same cost and window as the forward script; a no-op on a table that
-- already has the old key. Binaries from FlipAuditLogClusteredKey on purge either shape.
-- Run with QUOTED_IDENTIFIER ON (sqlcmd -I, or keep this header).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
BEGIN TRANSACTION;
IF
(SELECT c.name FROM sys.index_columns ic
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = OBJECT_ID('dbo.AuditLog') AND ic.index_id = 1 AND ic.key_ordinal = 1) = N'OccurredAtUtc'
BEGIN
DECLARE @dropSql nvarchar(max) = N'';
SELECT @dropSql = @dropSql + N'DROP INDEX ' + QUOTENAME(i.name) + N' ON dbo.AuditLog;' + CHAR(10)
FROM sys.indexes i
WHERE i.object_id = OBJECT_ID('dbo.AuditLog') AND i.type = 2 AND i.name IN (
N'IX_AuditLog_OccurredAtUtc', N'IX_AuditLog_Site_Occurred', N'IX_AuditLog_CorrelationId',
N'IX_AuditLog_Channel_Status_Occurred', N'IX_AuditLog_Target_Occurred', N'IX_AuditLog_Execution',
N'IX_AuditLog_ParentExecution', N'IX_AuditLog_Node_Occurred', N'IX_AuditLog_Kind_Occurred',
N'IX_AuditLog_Actor_Occurred', N'IX_AuditLog_Unchained', N'IX_AuditLog_ChainSeq',
N'UX_AuditLog_EventId_Occurred');
EXEC sp_executesql @dropSql;
ALTER TABLE dbo.AuditLog DROP CONSTRAINT PK_AuditLog;
ALTER TABLE dbo.AuditLog ADD CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog (OccurredAtUtc DESC) INCLUDE (Status) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog (CorrelationId) WHERE CorrelationId IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog (Category ASC, Status ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC) WHERE Target IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution ON dbo.AuditLog (ExecutionId) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog (ParentExecutionId) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred ON dbo.AuditLog (SourceNode, OccurredAtUtc) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Kind_Occurred ON dbo.AuditLog (Kind ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Actor_Occurred ON dbo.AuditLog (Actor ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained ON dbo.AuditLog (OccurredAtUtc, EventId) WHERE ChainSeq IS NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq ON dbo.AuditLog (ChainSeq) WHERE ChainSeq IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
END
DELETE FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20261110033732_FlipAuditLogClusteredKey';
COMMIT;
GO
@@ -0,0 +1,63 @@
-- FlipAuditLogClusteredKey (2026-11-10, ScadaBridge#111, variant B2 of
-- docs/plans/2026-11-09-auditlog-clustered-key-benchmark.md). dbo.AuditLog clustered key
-- (EventId, OccurredAtUtc) -> (OccurredAtUtc, EventId) + aligned unique UX_AuditLog_EventId_Occurred
-- (EventId, OccurredAtUtc); the twelve non-clustered indexes are dropped first and rebuilt last,
-- unchanged and partition-aligned. Re-runnable (no-op on an already-flipped table).
--
-- OFFLINE whole-table rebuild: size the window from the real table first
-- SELECT SUM(used_page_count)*8/1024 AS MB FROM sys.dm_db_partition_stats WHERE object_id = OBJECT_ID('dbo.AuditLog');
-- (local reference: 631 MB / 300k rows rebuilt in ~9 s on an idle dev container; production I/O and
-- log growth differ — the whole table is rewritten in one transaction, so the log needs ~2x the
-- table size free). Take a backup first. Stop BOTH central nodes (old binaries' partition-switch
-- purge cannot switch a flipped table: their staging table hard-codes the old key — msg 4947,
-- rolled back, nothing purged). Rollback: FlipAuditLogClusteredKey.rollback.sql (same cost).
--
-- Run with QUOTED_IDENTIFIER ON (sqlcmd -I, or keep this header): AuditLog has persisted computed
-- columns and filtered indexes and SQL Server raises error 1934 otherwise.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
BEGIN TRANSACTION;
IF
(SELECT c.name FROM sys.index_columns ic
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = OBJECT_ID('dbo.AuditLog') AND ic.index_id = 1 AND ic.key_ordinal = 1) = N'EventId'
BEGIN
DECLARE @dropSql nvarchar(max) = N'';
SELECT @dropSql = @dropSql + N'DROP INDEX ' + QUOTENAME(i.name) + N' ON dbo.AuditLog;' + CHAR(10)
FROM sys.indexes i
WHERE i.object_id = OBJECT_ID('dbo.AuditLog') AND i.type = 2 AND i.name IN (
N'IX_AuditLog_OccurredAtUtc', N'IX_AuditLog_Site_Occurred', N'IX_AuditLog_CorrelationId',
N'IX_AuditLog_Channel_Status_Occurred', N'IX_AuditLog_Target_Occurred', N'IX_AuditLog_Execution',
N'IX_AuditLog_ParentExecution', N'IX_AuditLog_Node_Occurred', N'IX_AuditLog_Kind_Occurred',
N'IX_AuditLog_Actor_Occurred', N'IX_AuditLog_Unchained', N'IX_AuditLog_ChainSeq',
N'UX_AuditLog_EventId_Occurred');
EXEC sp_executesql @dropSql;
ALTER TABLE dbo.AuditLog DROP CONSTRAINT PK_AuditLog;
ALTER TABLE dbo.AuditLog ADD CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (OccurredAtUtc, EventId)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId_Occurred ON dbo.AuditLog (EventId, OccurredAtUtc)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog (OccurredAtUtc DESC) INCLUDE (Status) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog (CorrelationId) WHERE CorrelationId IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog (Category ASC, Status ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC) WHERE Target IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution ON dbo.AuditLog (ExecutionId) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog (ParentExecutionId) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred ON dbo.AuditLog (SourceNode, OccurredAtUtc) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Kind_Occurred ON dbo.AuditLog (Kind ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Actor_Occurred ON dbo.AuditLog (Actor ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained ON dbo.AuditLog (OccurredAtUtc, EventId) WHERE ChainSeq IS NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq ON dbo.AuditLog (ChainSeq) WHERE ChainSeq IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
END
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20261110033732_FlipAuditLogClusteredKey', N'10.0.13');
COMMIT;
GO
+9 -4
View File
@@ -116,7 +116,8 @@ row per lifecycle event across all channels.
**Indexes (first cut):**
- `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans.
- Clustered `PK_AuditLog (OccurredAtUtc, EventId)` + unique `UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)` (#111) — the time-leading clustered key serves the Audit Log page's newest-first TOP-N and keyset paging as an ordered range; the EventId-leading unique index serves the idempotency probe.
- `IX_AuditLog_OccurredAtUtc` — covering (`INCLUDE (Status)`) time-range index for the KPI window.
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
- `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` — per-node filters ("everything `central-a` did in window X", or pinning a misbehaving site node).
- `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation.
@@ -731,9 +732,13 @@ MS SQL for direct-write events). Unredacted secrets never persist.
on a large or contended partition and leave an orphaned staging table for the next
tick's CATCH branch to clean up.
- **Partition-aligned uniqueness:** the switch no longer drops and rebuilds an index.
`EventId` uniqueness rides the clustered `PK_AuditLog (EventId, OccurredAtUtc)`,
which is aligned on `ps_AuditLog_Month(OccurredAtUtc)`, so `SWITCH PARTITION` has no
non-aligned unique index to object to. The predecessor `UX_AuditLog_EventId` forced
`EventId` uniqueness rides the clustered `PK_AuditLog (OccurredAtUtc, EventId)` and the
unique `UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)` (migration
`FlipAuditLogClusteredKey`, #111 — before it, the PK alone, keyed `(EventId, OccurredAtUtc)`),
all aligned on `ps_AuditLog_Month(OccurredAtUtc)`, so `SWITCH PARTITION` has no
non-aligned unique index to object to. The staging table takes the live table's clustered
key order (read from `sys.index_columns` at purge time), so the purge works on either key
shape; binaries older than the flip hard-code the old order and cannot switch a flipped table. The predecessor `UX_AuditLog_EventId` forced
an offline whole-table index build inside the switch transaction — blocking every
audit writer for its duration — and left a window in which the idempotency-supporting
index did not exist at all. See migration `AlignAuditLogEventIdUniqueness`.
@@ -69,7 +69,7 @@ The configuration database stores all central system data, organized by domain a
- **New tables use UTC `DateTime` + the UTC value converter**, not `DateTimeOffset`. The `Notification` entity's `DateTimeOffset` timestamps are the **documented legacy exception** — on the SQLite test harness the `DateTimeOffset`→ISO-8601-string converter forced in-memory reductions in a few KPI paths (now pushed server-side where translatable). Prefer `datetime2` UTC columns for any new table.
- **New repositories write MSSQL-only SQL with MSSQL-backed test fixtures** (`SkippableFact` gated on the live SQL Server container). The `NotificationOutboxRepository` dual SQLite/T-SQL dialect is the **documented legacy exception** (finding S8) — it predates the MSSQL-fixture convention; do not copy that pattern.
- **AuditLog clustered key.** The `dbo.AuditLog` primary/clustered key is `(EventId, OccurredAtUtc)` — `OccurredAtUtc` is required by the monthly partition scheme (`ps_AuditLog_Month`), and `EventId` carries a separate unique index for `InsertIfNotExists` idempotency. **Tracked follow-up (P3):** benchmark whether flipping the clustered key to `(OccurredAtUtc, EventId)` improves range-scan locality once real volume exists. → #78
- **AuditLog clustered key.** The `dbo.AuditLog` primary/clustered key is `(OccurredAtUtc, EventId)` since migration `FlipAuditLogClusteredKey` (#111; variant B2 of the #78 benchmark, `docs/plans/2026-11-09-auditlog-clustered-key-benchmark.md`) — time-leading so the Audit Log page's newest-first TOP-N and keyset cursor read an ordered range instead of the whole partitioned table (300k-row local run: 90,532 → 34 logical reads for page 1, 90,532 → 46 for a keyset page). `OccurredAtUtc` is also the partition column of `ps_AuditLog_Month`. The aligned unique `UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)` keeps the `InsertIfNotExists` idempotency probe a seek (14 reads, unchanged). Before the flip the key was `(EventId, OccurredAtUtc)`. Production script `docs/plans/sql/FlipAuditLogClusteredKey.sql` (+ `.rollback.sql`): an offline whole-table rebuild, run with both centrals stopped (old binaries' partition-switch staging table hard-codes the old key); DEV/QA sizing and the write window are tracked in #111.
- **AuditLog reconciliation-pull paging (keyset, implemented).** `SiteAuditReconciliationActor` keeps a per-site `(Since, AfterId)` cursor and `GrpcPullAuditEventsClient` sends both `since_utc` and `after_id` on every `PullAuditEvents` call; the site (`ReadPendingSinceAsync`) serves rows strictly after the `(OccurredAtUtc, EventId)` pair when `after_id` is set and falls back to inclusive `>=` on the timestamp only for a first pull / empty `after_id`. Ingest stays idempotent on `EventId`, so the cursor is an exactness optimisation, not a correctness requirement. (The earlier note that central still sent a bare `sinceUtc` was stale.)
- **KPI sampling cadence.** There is a single recorder cadence (`KpiHistory:SampleInterval`, default 60 s); per-site and per-node series are sampled in the same pass as the global ones, with no separate per-node cadence. See Component-KpiHistory.md. Benchmark record: `docs/plans/2026-11-09-auditlog-clustered-key-benchmark.md` (ScadaBridge#78).
@@ -98,11 +98,12 @@ The configuration database stores all central system data, organized by domain a
- `ParentExecutionId` — `CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)` PERSISTED — spawner's `ExecutionId`; null for top-level runs.
- `IngestedAtUtc` — `CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))` — central ingest timestamp; **not** persisted (SQL Server rejects PERSISTED on the non-deterministic `SWITCHOFFSET` expression).
*Clustered primary key:* `(EventId, OccurredAtUtc)` — composite so the key is partition-aligned, and the **sole** `EventId` uniqueness enforcement backing `InsertIfNotExistsAsync` idempotency. `EventId` is a GUID minted once at the emitting site in the same operation that stamps `OccurredAtUtc`, and neither field is ever re-stamped downstream, so a given `EventId` always arrives with the same `OccurredAtUtc` and can only map to one partition — pair-uniqueness is `EventId`-uniqueness for every row the system produces. The idempotency probe (`WHERE EventId = @id`) still seeks the clustered key's leading column, at the cost of one seek per partition rather than one seek overall.
*Clustered primary key:* `(OccurredAtUtc, EventId)` (since `FlipAuditLogClusteredKey`, #111; previously `(EventId, OccurredAtUtc)`) — composite so the key is partition-aligned, time-leading so newest-first range reads seek. *EventId uniqueness:* `UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)`, unique and partition-aligned, backing `InsertIfNotExistsAsync` idempotency (the PK enforces the same pair). `EventId` is a GUID minted once at the emitting site in the same operation that stamps `OccurredAtUtc`, and neither field is ever re-stamped downstream, so a given `EventId` always arrives with the same `OccurredAtUtc` and can only map to one partition — pair-uniqueness is `EventId`-uniqueness for every row the system produces. The idempotency probe (`WHERE EventId = @id`) seeks the unique index's leading column, at the cost of one seek per partition rather than one seek overall. The partition-switch purge reads the live clustered key order and builds its staging table to match, so it works on either shape.
A non-aligned single-column `UX_AuditLog_EventId` on `[PRIMARY]` used to carry that uniqueness; it was dropped by the `AlignAuditLogEventIdUniqueness` migration because a non-aligned index blocks `ALTER TABLE … SWITCH PARTITION`, forcing the retention purge to drop it, switch, and rebuild it **offline inside the switch transaction** on every run.
*Indexes* (all non-clustered, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)`):
*Indexes* (all non-clustered, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)`; `FlipAuditLogClusteredKey` rebuilt all of them unchanged):
- `UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)` UNIQUE (EventId uniqueness + idempotency probe, #111)
- `IX_AuditLog_OccurredAtUtc` (primary time-range index for global scans)
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` (per-site filters)
- `IX_AuditLog_CorrelationId (CorrelationId) WHERE CorrelationId IS NOT NULL` (drilldown from a single operation)
@@ -118,11 +118,14 @@ public interface IAuditLogRepository
/// <remarks>
/// <para>
/// <b>Partition-aligned uniqueness — no index drop.</b> EventId uniqueness is
/// enforced by the clustered <c>PK_AuditLog (EventId, OccurredAtUtc)</c>,
/// which is aligned on <c>ps_AuditLog_Month(OccurredAtUtc)</c>, so
/// enforced by the clustered <c>PK_AuditLog (OccurredAtUtc, EventId)</c> and the
/// unique <c>UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)</c> (ScadaBridge#111;
/// before that migration the PK alone, keyed <c>(EventId, OccurredAtUtc)</c>), all
/// aligned on <c>ps_AuditLog_Month(OccurredAtUtc)</c>, so
/// <c>ALTER TABLE … SWITCH PARTITION</c> has no non-aligned unique index to
/// object to. The implementation creates a staging table with byte-identical
/// schema, switches the partition's data into staging, and drops staging
/// schema and the same clustered key order as the live table (read at run time, so
/// either key shape purges), switches the partition's data into staging, and drops staging
/// (discarding the rows). Nothing is dropped from the live table, so there is
/// no window in which the idempotency enforcement is absent and no offline
/// index rebuild inside the switch transaction.
@@ -182,18 +182,28 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
// ── Keys + indexes ───────────────────────────────────────────────────
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
// (ps_AuditLog_Month) so the clustered key is partition-aligned. It is ALSO the
// only uniqueness enforcement the ingest path needs: EventId is a GUID minted
// once at the emitting site and never re-stamped, so a given EventId always
// arrives with the same OccurredAtUtc and can only ever land in one partition.
// Uniqueness of the pair is therefore uniqueness of EventId in practice, and the
// idempotency probe (WHERE EventId = @id) still seeks the clustered key's leading
// column. The predecessor non-aligned UX_AuditLog_EventId on [PRIMARY] was
// dropped by AlignAuditLogEventIdUniqueness — it existed only to give
// single-column uniqueness and its non-alignment forced an offline drop/rebuild
// around every partition-switch purge.
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
// Clustered PK (OccurredAtUtc, EventId) — FlipAuditLogClusteredKey (ScadaBridge#111,
// variant B2 of the 2026-11-09 benchmark). Time-leading so the partitioned table can
// serve the Audit Log page's ORDER BY OccurredAtUtc DESC, EventId DESC TOP-N and its
// keyset cursor as an ordered backward range seek instead of reading the whole table
// (739k reads -> ~50 at 3M rows). OccurredAtUtc is also the partitioning column of
// ps_AuditLog_Month, so the key stays partition-aligned. Before the flip the key was
// (EventId, OccurredAtUtc); the pair - and therefore the uniqueness it enforces - is
// unchanged, only the column order (and so the physical row order) moved.
//
// EF does not manage the physical clustering of dbo.AuditLog (raw-SQL migrations own
// it); the key order here only keeps the model, snapshot and migration in agreement.
builder.HasKey(e => new { e.OccurredAtUtc, e.EventId });
// EventId-leading unique index (B2): keeps the ingest idempotency probe
// (WHERE EventId = @id) a seek - one shallow seek per partition, as before the flip -
// and keeps a real uniqueness constraint led by EventId. Partition-aligned (raw SQL
// ON ps_AuditLog_Month) so SWITCH PARTITION has nothing to object to. EventId is a GUID
// minted once at the emitting site together with OccurredAtUtc and never re-stamped,
// so pair-uniqueness is EventId-uniqueness in practice (see AlignAuditLogEventIdUniqueness).
builder.HasIndex(e => new { e.EventId, e.OccurredAtUtc })
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId_Occurred");
// Index names are locked for reconciliation/migration discoverability. The
// column SETS migrate to the canonical/computed shape (alog.md §4 semantics
@@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <summary>
/// Flips <c>dbo.AuditLog</c>'s clustered key from <c>(EventId, OccurredAtUtc)</c> to
/// <c>(OccurredAtUtc, EventId)</c> and adds the partition-aligned unique index
/// <c>UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc)</c> — variant B2 of the
/// 2026-11-09 clustered-key benchmark (ScadaBridge#78, #111;
/// <c>docs/plans/2026-11-09-auditlog-clustered-key-benchmark.md</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why.</b> With random-GUID EventIds leading the clustered key, the Audit Log page's
/// default query (<c>ORDER BY OccurredAtUtc DESC, EventId DESC</c>, newest 50) and its
/// keyset cursor could not be served as an ordered seek on the partitioned table and read
/// the whole table (739k logical reads at 3M rows); with the time-leading key the same
/// shapes read ~50 pages. The EventId-leading unique index keeps the ingest idempotency
/// probe (<c>WHERE EventId = @id</c>) a seek and keeps a uniqueness constraint led by
/// EventId. Ingest cost is unchanged (benchmark: same rows/s, same fragmentation).
/// </para>
/// <para>
/// <b>Shape of the rebuild.</b> Every non-clustered index carries the clustered key as its
/// row locator, so they are dropped first and recreated last (each is built once, not twice).
/// The clustered PK is dropped (the heap stays on <c>ps_AuditLog_Month</c>) and re-added in
/// the new order on the same scheme. All twelve non-clustered indexes are recreated with
/// their exact prior definitions, partition-aligned. This is an OFFLINE whole-table data
/// move: plan a write window and size it from the real table first (#111). Site buffers
/// retain unforwarded audit rows while central ingest is stopped.
/// </para>
/// <para>
/// <b>Grants.</b> An index-only rebuild keeps every table- and column-level permission on
/// <c>dbo.AuditLog</c> (the writer's DENY UPDATE/DELETE, the chainer's column UPDATE on
/// <c>ChainSeq, RowHash</c>, the purger's SELECT/DELETE) — pinned by
/// <c>FlipAuditLogClusteredKeyMigrationTests</c>.
/// </para>
/// <para>
/// <b>Mixed version.</b> Every query issued by old and new binaries names columns, never the
/// key, so both read and write either shape. The ONE key-sensitive statement is the
/// partition-switch purge's staging table, whose clustered key must match the table's: new
/// binaries read the live key order from <c>sys.index_columns</c> and build staging to match
/// (either shape), but OLD binaries hard-code <c>(EventId, OccurredAtUtc)</c>, so on a flipped
/// table their monthly switch-out fails (msg 4913/4947, rolled back, nothing purged, reported
/// as a purge failure and retried next tick). Ship this migration with the binaries.
/// </para>
/// <para>
/// <b>Re-runnable and reversible.</b> Up is a no-op when the clustered key already leads with
/// <c>OccurredAtUtc</c>; Down is the inverse rebuild (same cost) and a no-op when it already
/// leads with <c>EventId</c>.
/// </para>
/// </remarks>
public partial class FlipAuditLogClusteredKey : Migration
{
/// <summary>
/// The twelve non-clustered indexes, exactly as their creating migrations defined them
/// (AddAuditLogTable / CollapseAuditLogToCanonical / AddAuditLogAndNotificationCoveringIndexes /
/// ReviewRemediationIndexes / AddAuditLogHashChain), all on <c>ps_AuditLog_Month</c>.
/// </summary>
internal const string NonClusteredIndexDdl = @"
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog (OccurredAtUtc DESC) INCLUDE (Status) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog (CorrelationId) WHERE CorrelationId IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog (Category ASC, Status ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC) WHERE Target IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution ON dbo.AuditLog (ExecutionId) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog (ParentExecutionId) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred ON dbo.AuditLog (SourceNode, OccurredAtUtc) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Kind_Occurred ON dbo.AuditLog (Kind ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Actor_Occurred ON dbo.AuditLog (Actor ASC, OccurredAtUtc DESC) ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained ON dbo.AuditLog (OccurredAtUtc, EventId) WHERE ChainSeq IS NULL ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq ON dbo.AuditLog (ChainSeq) WHERE ChainSeq IS NOT NULL ON ps_AuditLog_Month(OccurredAtUtc);";
/// <summary>Drops every non-clustered index on dbo.AuditLog (the twelve above plus the B2 unique index), each existence-guarded.</summary>
internal const string DropNonClusteredIndexesSql = @"
DECLARE @dropSql nvarchar(max) = N'';
SELECT @dropSql = @dropSql + N'DROP INDEX ' + QUOTENAME(i.name) + N' ON dbo.AuditLog;' + CHAR(10)
FROM sys.indexes i
WHERE i.object_id = OBJECT_ID('dbo.AuditLog') AND i.type = 2 AND i.name IN (
N'IX_AuditLog_OccurredAtUtc', N'IX_AuditLog_Site_Occurred', N'IX_AuditLog_CorrelationId',
N'IX_AuditLog_Channel_Status_Occurred', N'IX_AuditLog_Target_Occurred', N'IX_AuditLog_Execution',
N'IX_AuditLog_ParentExecution', N'IX_AuditLog_Node_Occurred', N'IX_AuditLog_Kind_Occurred',
N'IX_AuditLog_Actor_Occurred', N'IX_AuditLog_Unchained', N'IX_AuditLog_ChainSeq',
N'UX_AuditLog_EventId_Occurred');
EXEC sp_executesql @dropSql;";
// Leading key column of the clustered index, or NULL when the table has none.
private const string LeadingKeyColumnSql = @"
(SELECT c.name FROM sys.index_columns ic
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = OBJECT_ID('dbo.AuditLog') AND ic.index_id = 1 AND ic.key_ordinal = 1)";
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Raw SQL, not the scaffolded DropPrimaryKey/AddPrimaryKey: EF's AddPrimaryKey cannot
// place the key ON ps_AuditLog_Month, and the twelve non-clustered indexes must be
// dropped first and rebuilt last. The whole body is one guarded batch so a re-run on
// an already-flipped table changes nothing.
migrationBuilder.Sql($@"
IF {LeadingKeyColumnSql} = N'EventId'
BEGIN
{DropNonClusteredIndexesSql}
ALTER TABLE dbo.AuditLog DROP CONSTRAINT PK_AuditLog;
ALTER TABLE dbo.AuditLog ADD CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (OccurredAtUtc, EventId)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId_Occurred ON dbo.AuditLog (EventId, OccurredAtUtc)
ON ps_AuditLog_Month(OccurredAtUtc);
{NonClusteredIndexDdl}
END");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// The inverse rebuild: back to (EventId, OccurredAtUtc), the B2 unique index removed
// (the clustered PK is again the only EventId-leading structure, as before).
migrationBuilder.Sql($@"
IF {LeadingKeyColumnSql} = N'OccurredAtUtc'
BEGIN
{DropNonClusteredIndexesSql}
ALTER TABLE dbo.AuditLog DROP CONSTRAINT PK_AuditLog;
ALTER TABLE dbo.AuditLog ADD CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
ON ps_AuditLog_Month(OccurredAtUtc);
{NonClusteredIndexDdl}
END");
}
}
}
@@ -1979,12 +1979,12 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
{
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("OccurredAtUtc")
.HasColumnType("datetime2");
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
@@ -2069,7 +2069,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("EventId", "OccurredAtUtc");
b.HasKey("OccurredAtUtc", "EventId");
b.HasIndex("ChainSeq")
.HasDatabaseName("IX_AuditLog_ChainSeq")
@@ -2095,6 +2095,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Actor_Occurred");
b.HasIndex("EventId", "OccurredAtUtc")
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId_Occurred");
b.HasIndex("Kind", "OccurredAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Kind_Occurred");
@@ -23,7 +23,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class AuditLogRepository : IAuditLogRepository
{
// SQL Server error numbers for duplicate-key violations on the
// partition-aligned clustered PK_AuditLog (EventId, OccurredAtUtc).
// partition-aligned clustered PK_AuditLog (OccurredAtUtc, EventId) or the unique
// UX_AuditLog_EventId_Occurred (EventId, OccurredAtUtc) — ScadaBridge#111.
// 2601 is a unique-index violation; 2627 is a primary-key/unique-constraint
// violation. The IF NOT EXISTS … INSERT pattern has a check-then-act race
// window — two sessions can both pass the EXISTS check and then both attempt
@@ -504,8 +505,9 @@ VALUES
// guarded and the staging table is GUID-suffixed, so a re-run is idempotent.
//
// ALIGNED UNIQUENESS: there is no longer an index drop/rebuild around
// the SWITCH. EventId uniqueness is now enforced solely by the clustered
// PK_AuditLog (EventId, OccurredAtUtc), which is partition-aligned on
// the SWITCH. EventId uniqueness is enforced by the clustered PK_AuditLog
// (OccurredAtUtc, EventId since FlipAuditLogClusteredKey, ScadaBridge#111) and the
// unique UX_AuditLog_EventId_Occurred, both partition-aligned on
// ps_AuditLog_Month(OccurredAtUtc) — so ALTER TABLE … SWITCH PARTITION has no
// non-aligned index to object to. The former dance dropped UX_AuditLog_EventId,
// switched, then rebuilt it OFFLINE inside the same transaction: a whole-table
@@ -595,10 +597,21 @@ VALUES
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
ChainSeq bigint NULL,
RowHash binary(32) NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
RowHash binary(32) NULL
) ON [PRIMARY];
-- 2b. The staging clustered key must match dbo.AuditLog's or SWITCH refuses
-- (msg 4913/4947). FlipAuditLogClusteredKey (ScadaBridge#111) changed it from
-- (EventId, OccurredAtUtc) to (OccurredAtUtc, EventId), so read the live key
-- order instead of assuming one: this binary purges either shape, before or
-- after that migration (and after its Down).
IF (SELECT c.name FROM sys.index_columns ic
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = OBJECT_ID('dbo.AuditLog') AND ic.index_id = 1 AND ic.key_ordinal = 1) = N'OccurredAtUtc'
ALTER TABLE dbo.[{stagingTableName}] ADD CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (OccurredAtUtc, EventId);
ELSE
ALTER TABLE dbo.[{stagingTableName}] ADD CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc);
-- 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
@@ -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);
@@ -0,0 +1,315 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using Xunit.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// SQL Server tests for the <c>FlipAuditLogClusteredKey</c> migration (ScadaBridge#111,
/// variant B2 of <c>docs/plans/2026-11-09-auditlog-clustered-key-benchmark.md</c>): the new
/// clustered key and the aligned unique EventId index, every other index and grant preserved,
/// a faithful Down, a re-runnable Up, a partition-switch purge that works on BOTH key shapes,
/// and the read win itself — the Audit Log page's page-1 and keyset queries read a small
/// fraction of the pages they read on the old key.
/// </summary>
/// <remarks>
/// The tests in this class migrate the fixture database down and back up, so they own their
/// fixture (one database per test class) and every test leaves the database fully migrated.
/// </remarks>
public sealed class FlipAuditLogClusteredKeyMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private const string FlipMigration = "20261110033732_FlipAuditLogClusteredKey";
// The migration immediately before the flip, so "Down" reverts exactly this one.
private static readonly string PreviousMigration = MigrationBefore(FlipMigration);
private static readonly string[] NonClusteredIndexes =
[
"IX_AuditLog_OccurredAtUtc", "IX_AuditLog_Site_Occurred", "IX_AuditLog_CorrelationId",
"IX_AuditLog_Channel_Status_Occurred", "IX_AuditLog_Target_Occurred", "IX_AuditLog_Execution",
"IX_AuditLog_ParentExecution", "IX_AuditLog_Node_Occurred", "IX_AuditLog_Kind_Occurred",
"IX_AuditLog_Actor_Occurred", "IX_AuditLog_Unchained", "IX_AuditLog_ChainSeq",
];
private readonly MsSqlMigrationFixture _fixture;
private readonly ITestOutputHelper _output;
/// <summary>Creates the test class with its own migrated SQL Server database.</summary>
/// <param name="fixture">The per-class migration fixture.</param>
/// <param name="output">Test output (records the measured logical reads).</param>
public FlipAuditLogClusteredKeyMigrationTests(MsSqlMigrationFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}
/// <summary>
/// After the full chain: clustered PK (OccurredAtUtc, EventId), the unique aligned
/// (EventId, OccurredAtUtc) index, all twelve non-clustered indexes with unchanged keys and
/// filters, everything on ps_AuditLog_Month, and every append-only/chain grant intact.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Up_FlipsTheClusteredKey_AddsTheAlignedUniqueIndex_AndKeepsEveryIndexAndGrant()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var indexes = await ReadIndexesAsync();
Assert.Equal("OccurredAtUtc, EventId", indexes["PK_AuditLog"].Keys);
Assert.True(indexes["PK_AuditLog"].Clustered);
Assert.Equal("EventId, OccurredAtUtc", indexes["UX_AuditLog_EventId_Occurred"].Keys);
Assert.True(indexes["UX_AuditLog_EventId_Occurred"].Unique);
Assert.Equal(NonClusteredIndexes.Length + 2, indexes.Count);
Assert.All(indexes.Values, i => Assert.Equal("ps_AuditLog_Month", i.DataSpace));
AssertTwelveNonClusteredIndexes(indexes);
Assert.Equal(ExpectedGrants, await ReadGrantsAsync());
}
/// <summary>
/// Down restores the old key and drops the B2 index; Up re-flips; re-running Up's SQL on an
/// already-flipped table is a no-op; and a partition-switch purge succeeds on each shape
/// (the staging table's key follows the live table's).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task DownUp_RoundTrips_IsReRunnable_AndThePartitionSwitchPurgesEitherShape()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await MigrateToAsync(PreviousMigration);
var down = await ReadIndexesAsync();
Assert.Equal("EventId, OccurredAtUtc", down["PK_AuditLog"].Keys);
Assert.False(down.ContainsKey("UX_AuditLog_EventId_Occurred"));
AssertTwelveNonClusteredIndexes(down);
Assert.Equal(ExpectedGrants, await ReadGrantsAsync());
// Old shape: the purge's staging table must take the old key order.
await AssertSwitchOutPurgesAsync(new DateTime(2027, 3, 1, 0, 0, 0, DateTimeKind.Utc));
await MigrateToAsync(FlipMigration);
var up = await ReadIndexesAsync();
Assert.Equal("OccurredAtUtc, EventId", up["PK_AuditLog"].Keys);
// Re-run Up's SQL against the flipped table: guarded, so nothing changes.
await using (var conn = _fixture.OpenConnection())
{
foreach (var op in new FlipAuditLogClusteredKey().UpOperations.OfType<SqlOperation>())
{
await using var cmd = new SqlCommand(op.Sql, conn) { CommandTimeout = 300 };
await cmd.ExecuteNonQueryAsync();
}
}
Assert.Equal(up.Select(kv => (kv.Key, kv.Value)).OrderBy(x => x.Key),
(await ReadIndexesAsync()).Select(kv => (kv.Key, kv.Value)).OrderBy(x => x.Key));
// New shape: same purge, now with the time-leading staging key.
await AssertSwitchOutPurgesAsync(new DateTime(2027, 4, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(ExpectedGrants, await ReadGrantsAsync());
}
/// <summary>
/// The read win, measured with the session's logical-read counter around the repository's real query: page 1
/// (newest 50, no filter) and a keyset page read far fewer AuditLog pages on the flipped
/// key than on the old one over the same rows.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PageOneAndKeysetPage_ReadAFractionOfThePages_OnTheFlippedKey()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await SeedRowsAsync(new DateTime(2027, 6, 1, 0, 0, 0, DateTimeKind.Utc), rows: 20_000);
var (flippedPage1, flippedKeyset) = await MeasurePageReadsAsync();
await MigrateToAsync(PreviousMigration);
try
{
var (oldPage1, oldKeyset) = await MeasurePageReadsAsync();
_output.WriteLine(
$"logical reads, 20k rows - page 1: {flippedPage1} flipped vs {oldPage1} old; keyset: {flippedKeyset} flipped vs {oldKeyset} old");
Assert.True(flippedPage1 * 10 < oldPage1,
$"page 1: {flippedPage1} logical reads on (OccurredAtUtc, EventId) vs {oldPage1} on (EventId, OccurredAtUtc)");
Assert.True(flippedKeyset * 10 < oldKeyset,
$"keyset: {flippedKeyset} logical reads on (OccurredAtUtc, EventId) vs {oldKeyset} on (EventId, OccurredAtUtc)");
}
finally
{
await MigrateToAsync(FlipMigration);
}
}
private static readonly string[] ExpectedGrants =
[
"scadabridge_audit_chainer|SELECT|GRANT|",
"scadabridge_audit_chainer|UPDATE|GRANT|ChainSeq",
"scadabridge_audit_chainer|UPDATE|GRANT|RowHash",
"scadabridge_audit_purger|DELETE|GRANT|",
"scadabridge_audit_purger|SELECT|GRANT|",
"scadabridge_audit_writer|DELETE|DENY|",
"scadabridge_audit_writer|INSERT|GRANT|",
"scadabridge_audit_writer|SELECT|GRANT|",
"scadabridge_audit_writer|UPDATE|DENY|",
];
private static void AssertTwelveNonClusteredIndexes(IReadOnlyDictionary<string, IndexShape> indexes)
{
Assert.Equal("OccurredAtUtc DESC", indexes["IX_AuditLog_OccurredAtUtc"].Keys);
Assert.Equal("Status", indexes["IX_AuditLog_OccurredAtUtc"].Included);
Assert.Equal("SourceSiteId, OccurredAtUtc DESC", indexes["IX_AuditLog_Site_Occurred"].Keys);
Assert.Equal("CorrelationId", indexes["IX_AuditLog_CorrelationId"].Keys);
Assert.Equal("([CorrelationId] IS NOT NULL)", indexes["IX_AuditLog_CorrelationId"].Filter);
Assert.Equal("Category, Status, OccurredAtUtc DESC", indexes["IX_AuditLog_Channel_Status_Occurred"].Keys);
Assert.Equal("Target, OccurredAtUtc DESC", indexes["IX_AuditLog_Target_Occurred"].Keys);
Assert.Equal("([Target] IS NOT NULL)", indexes["IX_AuditLog_Target_Occurred"].Filter);
Assert.Equal("ExecutionId", indexes["IX_AuditLog_Execution"].Keys);
Assert.Equal("ParentExecutionId", indexes["IX_AuditLog_ParentExecution"].Keys);
Assert.Equal("SourceNode, OccurredAtUtc", indexes["IX_AuditLog_Node_Occurred"].Keys);
Assert.Equal("Kind, OccurredAtUtc DESC", indexes["IX_AuditLog_Kind_Occurred"].Keys);
Assert.Equal("Actor, OccurredAtUtc DESC", indexes["IX_AuditLog_Actor_Occurred"].Keys);
Assert.Equal("OccurredAtUtc, EventId", indexes["IX_AuditLog_Unchained"].Keys);
Assert.Equal("([ChainSeq] IS NULL)", indexes["IX_AuditLog_Unchained"].Filter);
Assert.Equal("ChainSeq", indexes["IX_AuditLog_ChainSeq"].Keys);
Assert.Equal("([ChainSeq] IS NOT NULL)", indexes["IX_AuditLog_ChainSeq"].Filter);
Assert.All(NonClusteredIndexes, n => Assert.False(indexes[n].Clustered, n));
}
private async Task AssertSwitchOutPurgesAsync(DateTime month)
{
await SeedRowsAsync(month, rows: 50);
await using var ctx = CreateContext();
var purged = await new AuditLogRepository(ctx).SwitchOutPartitionAsync(month);
Assert.Equal(50, purged);
await using var conn = _fixture.OpenConnection();
await using var cmd = new SqlCommand(
"SELECT COUNT_BIG(*) FROM dbo.AuditLog WHERE OccurredAtUtc >= @s AND OccurredAtUtc < @e;", conn);
cmd.Parameters.AddWithValue("@s", month);
cmd.Parameters.AddWithValue("@e", month.AddMonths(1));
Assert.Equal(0L, (long)(await cmd.ExecuteScalarAsync())!);
}
private async Task<(long Page1, long Keyset)> MeasurePageReadsAsync()
{
await using var conn = new SqlConnection(_fixture.ConnectionString);
await conn.OpenAsync();
// EF reuses the already-open connection, so the session's cumulative logical-read
// counter (sys.dm_exec_sessions) brackets exactly the repository's query. The probe
// itself reads no AuditLog pages.
await using var ctx = new ScadaBridgeDbContext(
new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(conn).Options);
var repo = new AuditLogRepository(ctx);
var before = await SessionLogicalReadsAsync(conn);
var page1 = await repo.QueryAsync(new AuditLogQueryFilter(), new AuditLogPaging(50));
var page1Reads = await SessionLogicalReadsAsync(conn) - before;
Assert.Equal(50, page1.Count);
var cursor = page1[^1];
before = await SessionLogicalReadsAsync(conn);
var page2 = await repo.QueryAsync(
new AuditLogQueryFilter(),
new AuditLogPaging(50, cursor.OccurredAtUtc.UtcDateTime, cursor.EventId));
var keysetReads = await SessionLogicalReadsAsync(conn) - before;
Assert.Equal(50, page2.Count);
Assert.True(page2[0].OccurredAtUtc <= cursor.OccurredAtUtc);
return (page1Reads, keysetReads);
}
private static async Task<long> SessionLogicalReadsAsync(SqlConnection conn)
{
await using var cmd = new SqlCommand("SELECT logical_reads FROM sys.dm_exec_sessions WHERE session_id = @@SPID;", conn);
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}
private async Task SeedRowsAsync(DateTime month, int rows)
{
// Set-based seed with random EventIds (as minted at the sites) spread over the month;
// DetailsJson carries kind/status so the persisted computed columns materialise.
await using var conn = _fixture.OpenConnection();
await using var cmd = new SqlCommand(@"
WITH n AS (SELECT TOP (@rows) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS i
FROM sys.all_objects a CROSS JOIN sys.all_objects b)
INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
SELECT NEWID(), DATEADD(SECOND, CAST(i * (2419200.0 / @rows) AS int), @month), N'flip-test', 'ApiOutbound.ApiCall', 'Success',
'ApiOutbound', N'erp', 'central-a', NULL,
N'{""kind"":""ApiCall"",""status"":""Delivered"",""sourceSiteId"":""flip"",""pad"":""' + REPLICATE(N'x', 400) + N'""}'
FROM n;", conn) { CommandTimeout = 300 };
cmd.Parameters.AddWithValue("@rows", rows);
cmd.Parameters.Add(new SqlParameter("@month", System.Data.SqlDbType.DateTime2) { Scale = 7, Value = month });
await cmd.ExecuteNonQueryAsync();
}
private async Task MigrateToAsync(string migration)
{
await using var ctx = new ScadaBridgeDbContext(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString, sql => sql.CommandTimeout(600)).Options);
await ctx.GetService<IMigrator>().MigrateAsync(migration);
}
private static string MigrationBefore(string migration)
{
using var ctx = new ScadaBridgeDbContext(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer("Server=unused;Database=unused").Options);
var all = ctx.Database.GetMigrations().ToList();
return all[all.IndexOf(migration) - 1];
}
private ScadaBridgeDbContext CreateContext()
=> new(new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
private async Task<Dictionary<string, IndexShape>> ReadIndexesAsync()
{
await using var conn = _fixture.OpenConnection();
await using var cmd = new SqlCommand(@"
SELECT i.name, i.type, i.is_unique, ds.name, i.filter_definition,
STUFF((SELECT ', ' + c.name + CASE WHEN ic.is_descending_key = 1 THEN ' DESC' ELSE '' END
FROM sys.index_columns ic JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal > 0
ORDER BY ic.key_ordinal FOR XML PATH('')), 1, 2, ''),
STUFF((SELECT ', ' + c.name
FROM sys.index_columns ic JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 1
ORDER BY ic.index_column_id FOR XML PATH('')), 1, 2, '')
FROM sys.indexes i JOIN sys.data_spaces ds ON ds.data_space_id = i.data_space_id
WHERE i.object_id = OBJECT_ID('dbo.AuditLog') AND i.type IN (1, 2);", conn);
var result = new Dictionary<string, IndexShape>(StringComparer.Ordinal);
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
result[reader.GetString(0)] = new IndexShape(
Clustered: reader.GetByte(1) == 1,
Unique: reader.GetBoolean(2),
DataSpace: reader.GetString(3),
Filter: reader.IsDBNull(4) ? null : reader.GetString(4),
Keys: reader.GetString(5),
Included: reader.IsDBNull(6) ? null : reader.GetString(6));
}
return result;
}
private async Task<string[]> ReadGrantsAsync()
{
await using var conn = _fixture.OpenConnection();
await using var cmd = new SqlCommand(@"
SELECT pr.name, p.permission_name, p.state_desc, COL_NAME(p.major_id, p.minor_id)
FROM sys.database_permissions p JOIN sys.database_principals pr ON pr.principal_id = p.grantee_principal_id
WHERE p.major_id = OBJECT_ID('dbo.AuditLog') AND p.class = 1;", conn);
var list = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
list.Add($"{reader.GetString(0)}|{reader.GetString(1)}|{reader.GetString(2)}|{(reader.IsDBNull(3) ? "" : reader.GetString(3))}");
}
return list.Order(StringComparer.Ordinal).ToArray();
}
private sealed record IndexShape(bool Clustered, bool Unique, string DataSpace, string? Filter, string Keys, string? Included);
}