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
12 KiB
AuditLog clustered key benchmark and P4 / P6 follow-ups (ScadaBridge#78)
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)?
Facts from the code
- Clustered PK today:
PK_AuditLog (EventId, OccurredAtUtc)onps_AuditLog_Month(OccurredAtUtc)(monthly RANGE RIGHT partitions); 12 partition-aligned NC indexes (seeAuditLogEntityTypeConfiguration, migrationsCollapseAuditLogToCanonical,AlignAuditLogEventIdUniqueness,ReviewRemediationIndexes,AddAuditLogHashChain). EventIdisGuid.NewGuid()(ScadaBridgeAuditEventFactory, minted at the emitting site): fully random, so the current clustered key inserts at random positions inside the active month partition. Every NC index also carries the clustered key as row locator.- Idempotency (
InsertIfNotExists,InsertChunkAsync, reconciliation) probesWHERE EventId = @id- today the clustered key's leading column. SinceAlignAuditLogEventIdUniquenessthere is no separate EventId index. - Query shapes that range-scan or fetch rows: Audit Log page (
QueryAsync:ORDER BY OccurredAtUtc DESC, EventId DESC, keyset cursor on the same pair, filters on channel/kind/status/site/node/target/actor/execution/correlation + time range), KPI snapshot (GetKpiSnapshotAsync, covered byIX_AuditLog_OccurredAtUtc INCLUDE (Status)), execution tree/trace (IX_AuditLog_Execution+ window), hash-chain job (AuditLogHashChain:WHERE ChainSeq IS NULL AND month ORDER BY OccurredAtUtc, EventId, then UPDATE joined on(EventId, OccurredAtUtc), verificationORDER BY ChainSeq), per-channel purge (DELETE TOP (n) WHERE Category=@c AND OccurredAtUtc<@t), month archive read, partition-switch purge (metadata only, unaffected).
Method
Throwaway SQL Server 2022 (RTM-CU22, Developer, amd64 image under emulation on an Apple-silicon Mac, 6 GB cap, SIMPLE recovery) container, removed afterwards. Table recreated with the production DDL: partition function/scheme, 10 canonical columns, the persisted computed columns, ChainSeq/RowHash, all 12 NC indexes (+ variant-specific). 3,000,000 rows, ~1.9 KB/row (JSON DetailsJson ~480 chars), spread over 2026-07-01..2026-11-01, loaded in 3000 chronological batches of 1000 (each row 0-120 s older than its batch, imitating site telemetry delay), random NEWID() EventIds, using the production idempotent pattern INSERT ... WHERE NOT EXISTS (EventId). Variants:
- A current: clustered
(EventId, OccurredAtUtc). - B clustered
(OccurredAtUtc, EventId), no EventId index (plain insert, no probe) - shows the pure clustered-key effect. - B2 clustered
(OccurredAtUtc, EventId)+ alignedUNIQUE (EventId, OccurredAtUtc)NC index - the only variant that keeps cheap idempotency probes and a real EventId uniqueness guarantee. Queries measured cold (DROPCLEANBUFFERS) and warm withSET STATISTICS IO, TIME. Absolute times are inflated by emulation; compare ratios and logical reads. Single run each, no repetition - treat small differences as noise.
Insert, size, fragmentation (3M rows)
| Variant | Insert rows/s | Page splits (counter delta) | Clustered pages / MB | Clustered fragmentation | Clustered page fill |
|---|---|---|---|---|---|
| A (current) | 4,022 | 1,122,830 | 735,066 / 5,743 | 99.2% | 64.0% |
| B (no EventId index) | 7,631 | 816,961 | 500,001 / 3,906 | 0.6% | 94.2% |
| B2 (B + unique EventId NC) | 4,065 | 1,141,726 | 735,769 / 5,748 | 99.1% | 63.9% |
B2's clustered index ended up as fragmented as A's even though its key is time-ordered. The most likely cause (plan not inspected) is that the NOT EXISTS (EventId) probe against the new EventId index makes the optimizer feed the insert in EventId order, so the clustered inserts become random again; the unique EventId index itself is 16,099 pages / 99.2% fragmented. The NC indexes keyed on site/target/channel etc. fragment in every variant because their leading columns are not time-ordered.
Query shapes (logical reads, warm elapsed ms; cold in brackets)
| Shape | A | B | B2 |
|---|---|---|---|
| Q1 Audit Log page 1, newest 50, no filter | 739,300 reads / 2,313 ms [3,855] | 54 / 14 [22] | 51 / 15 [25] |
| Q2 channel IN (..) + 1 day, top 50 | 55,217 / 80 [171] | 58 / 12 [12] | 70 / 16 [18] |
| Q3 site + 1 week, top 100 | 199,502 / 732 [793] | 212 / 14 [15] | 287 / 20 [21] |
| Q4 deep keyset page (cursor in Sept) | 739,300 / 2,750 [1,862] | 70 / 13 [14] | 72 / 18 [20] |
| Q5 KPI last hour (covered) | 7 / 0 | 7 / 0 | 8 / 0 |
| Q6 actor filter, top 50 | 281,077 / 329 [548] | 432 / 1 [13] | 642 / 6 [21] |
| Q9 idempotency probe, 1000 new ids | 24,988 / 159 | 15,444 / 62 (scans a narrow NC index - no EventId index) | 16,204 / 101 |
| Q10 idempotency probe, 1000 existing ids | 24,988 / 85 | 15,444 / 47 | 16,204 / 85 |
| Q11 hash-chain select 5000 unchained | 24,574 / 313 | 1,195 / 290 | 1,678 / 306 |
| Q12 hash-chain stamp 5000 rows | 66,468 / 125 | 66,460 / 107 | 66,466 / 109 |
| Q13 month ordered read (window row_number) | 6,515 / 71 | 3,982 / 56 | 4,108 / 67 |
| Q14 chain verify read, 50k by ChainSeq | 196,306 / 1,213 [1,010] | 136,131 / 153 [688] | 200,724 / 185 [964] |
| Q15 per-channel purge batch 5000 (rolled back) | 195,129 / 458 [654] | 195,140 / 361 [1,592] | 210,211 / 383 [540] |
| Q7/Q8 (execution/correlation lookups) returned 0 rows in this synthetic data (id not found) and are not reported; they are NC seeks whose key-lookup locality would favour B slightly. |
Important side finding: in variant A the unfiltered/time-range Audit Log queries (Q1, Q4) read the entire table (739k pages at 3M rows) because the ORDER BY (OccurredAtUtc DESC, EventId DESC) cannot be satisfied by IX_AuditLog_OccurredAtUtc (its implicit trailing key is EventId ASC) over a partitioned table. This is a latent performance bug that grows linearly with table size, independent of the flip. A follow-up test adding IX_alt (OccurredAtUtc DESC, EventId DESC) INCLUDE (Status) to variant A did NOT fix Q1/Q4 (still 739k reads; forced with a hint it was worse at 12.6M reads), though it cut Q3 from 199k to 4k reads. So an index tweak is not a cheap substitute; the clustered-key flip is what makes the partitioned ordered TOP-N work.
Findings
- The flip is a large, real win for read paths, orders of magnitude on the Audit Log page (the default view) and on every time-range query, plus ~13x fewer reads for the hash-chain job's selection, and sequential month reads.
- The flip alone (B) roughly halves insert cost and removes clustered fragmentation (0.6% vs 99.2%, fill 94% vs 64%, 32% smaller table) because the clustered insert becomes append-only - but B is not viable without an EventId index (finding 3).
- But without an EventId index, idempotent ingest (
WHERE NOT EXISTS (EventId)) degrades to scanning a narrow NC index (B) - the cost grows with table size, and the table loses its uniqueness guarantee. Restoring it with a unique aligned(EventId, OccurredAtUtc)index (B2) re-introduces a random-GUID B-tree, and in this test also made the clustered inserts random: insert throughput, size and fragmentation matched A. So with B2 the flip does not help ingest (no regression either). Whether an insert path that does not sort by EventId (e.g. skipping the NOT EXISTS probe and catching 2627) would keep the clustered side append-only was not tested. - Chain stamp (Q12) and purge (Q15) are unchanged within noise; the chain verify read is better in B, equal in B2.
Recommendation
Flip: yes, as B2 (clustered (OccurredAtUtc, EventId) + aligned unique NC (EventId, OccurredAtUtc)), but schedule it deliberately - it is a priority-appropriate (P3) performance fix, not an urgent one, and the read win (default Audit Log page doing a full-table scan) justifies it as table size grows. B alone is not recommended (no cheap idempotency probe, no uniqueness guard). Ingest stays at today's cost under B2 (no regression), reads improve by 2-4 orders of magnitude. An optional later experiment: drop the pre-insert probe on the hot telemetry path in favour of catching 2627, which might recover B's insert win - not benchmarked.
Migration cost, risk and compatibility (not implemented)
- Rebuild is an offline-ish data move of the whole table (
DROP/CREATEclustered index, or theCollapseAuditLogToCanonical-style new-table-and-copy pattern on the preserved partition scheme). Every one of the 12 NC indexes is rebuilt too (locator changes). On a large table that is hours of I/O and log, and a write outage for the audit path unlessONLINE = ON(Enterprise/Developer only; the deployment does not guarantee it - seeAlignAuditLogEventIdUniqueness). Prefer a maintenance window or per-partition copy while draining ingest (site buffers retain unforwarded rows, so a window is tolerable). - The hash chain, tombstones, partition-switch purge staging table (
SwitchOutPartitionAsynchard-codes a byte-identical column list and must declare the same clustered key; a staging-table key mismatch fails SWITCH), the append-only role grants (DENY UPDATE/DELETE,GRANT UPDATE (ChainSeq, RowHash)), andAuditLogPartitionArchivemust be re-verified; grants on the table survive an index-only rebuild but not a drop/rename table swap (re-grant as CollapseAuditLogToCanonical did). - Code: the repository queries need no change (all predicates are on columns, not the key); the EF model declares
HasKey(EventId, OccurredAtUtc)- EF does not manage the physical clustering (raw SQL), so only the model comment/index declaration should be adjusted. The hash-chain UPDATE join on(EventId, OccurredAtUtc)seeks via the unique NC index. - Mixed-version / rollback: old binaries only issue SQL by column name, so a flipped table works with both old and new binaries, and the flip can be applied before or after an app upgrade; the new-table swap must run with all central nodes stopped or ingest paused. Rollback is the inverse rebuild (same cost) - the migration is reversible in principle, unlike
CollapseAuditLogToCanonical. Take a backup first. QA/prod must not be touched without the usual deployment gate.
P4 - per-node KPI sampling cadence
Verified in code: KpiHistoryOptions.SampleInterval (default 60 s, KpiHistoryRecorderActor timer at lines ~218-223) is the single cadence; all IKpiSampleSources (including the per-site/per-node scoped series of the Notification Outbox and Site Call Audit sources) are enumerated once per pass and stamped with one shared tick time, with SourceTimeout (10 s) bounding each source. No per-node or per-source cadence exists and no requirement document asks for one (Component-KpiHistory.md, Component-HealthMonitoring.md). Nothing is actually open beyond an optional design preference ("should per-node series sample on a different interval?") - recommend closing the P4 item as not-a-defect unless someone supplies a use case. The stale "tracked follow-up" text in Component-ConfigurationDatabase.md has been replaced.
P6 - stale spec line
Component-ConfigurationDatabase.md claimed central still sent a bare sinceUtc on AuditLog reconciliation pulls. In fact SiteAuditReconciliationActor keeps a (Since, AfterId) cursor per site and GrpcPullAuditEventsClient sends AfterId; the site's ReadPendingSinceAsync serves strictly-after rows when it is set. The spec line is corrected (same commit as this record).