You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Enable equality-seek acceleration for snapshot/RCSI reads on tables with live version chains by materializing seek candidates through the version store instead of declining table-wide to a full scan.
Copy file name to clipboardExpand all lines: docs/claude/indexes.md
+10-2Lines changed: 10 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -27,12 +27,20 @@ A prefix is bounded at the first key column that either has no equality conjunct
27
27
28
28
This is the path that collapses a **correlated `EXISTS` / `IN` / scalar subquery** (and an unindexed-inner `APPLY`) from O(outer × inner) toward linear: the inner re-executes per outer row, but a per-table cache (keyed on the `Heap`, built lazily on first seek) persists across those calls. Measured: a correlated `EXISTS` over a 4000 × 16000-row pair dropped from ~1480 ms to ~5 ms, on par with live SQL Server.
29
29
30
-
The cache is a **read-only acceleration structure, not a maintained index**: buckets are tagged with the heap's `MutationGeneration` (bumped by every `Heap.Insert` / `DeleteAt`) and rebuilt when it moves, so the heap stays the single source of truth and the write path — including its row locking and lock-escalation accounting — is completely untouched. The seek isn't a free pass around concurrency control: it routes each seeked candidate row through the same `BatchContext.TouchRowForRead` lock / conflict pipeline the full scan uses, so it acquires locks on only the rows it touches (matching a real index seek's footprint), and it**declines** — falling back to the full scan — for:
30
+
The cache is a **read-only acceleration structure, not a maintained index**: buckets are tagged with the heap's `MutationGeneration` (bumped by every `Heap.Insert` / `DeleteAt`) and rebuilt when it moves, so the heap stays the single source of truth and the write path — including its row locking and lock-escalation accounting — is completely untouched. The seek isn't a free pass around concurrency control: under a lock-based plan it routes each seeked candidate row through the same `BatchContext.TouchRowForRead` lock / conflict pipeline the full scan uses, so it acquires locks on only the rows it touches (matching a real index seek's footprint). It**declines** — falling back to the full scan — for:
31
31
32
-
-**snapshot / RCSI reads on a table that carries a version chain** (`HeapTable.RowVersions` non-empty), where the version visible to the reader can carry a different key value than the live heap row (a current-heap index would miss it). When the version store is empty — the common read-mostly / bacpac-loaded case — every row is implicitly committed at Xmin 0 (visible to every snapshot) and the live heap *is* the visible version, so the seek is allowed; otherwise an RCSI point lookup would full-scan on every read. `ResolveSnapshotXidForRead` is still called unconditionally for its snapshot-pinning side effects;
33
32
-**tx-scoped row-lock plans** (`REPEATABLE READ` / `SERIALIZABLE` / `UPDLOCK` / `HOLDLOCK` …), where the scan deliberately locks every row it reads to end of transaction;
34
33
- non-base-table sources (derived tables, table variables, `FOR SYSTEM_TIME`), a WHERE with no equality conjunct on any index's leading column, range predicates (`>` / `<` / `BETWEEN`), and `NULL` / non-resolvable-collation value sides.
A SNAPSHOT / RCSI reader sees the version visible at its snapshot, which can carry a *different key* than the live heap row — so a live-key-only bucket lookup could miss a row whose key was changed out from under the snapshot. Rather than declining table-wide whenever the version store is non-empty (which under a busy RCSI workload meant nearly every point lookup full-scanned — a measured ~90–235× regression from a single live version chain), the seek runs and materializes through the version store (`MaterializeSnapshotCandidates`):
38
+
39
+
-**Bucket candidates** (live rows whose *current* key matched the probe) each resolve to their snapshot-visible version via `VersionStore.ResolveVisibleVersion`, dropping out when not visible.
40
+
-**A version-chain sweep** over `HeapTable.RowVersions` adds every slot carrying a chain — those are exactly the rows whose snapshot-visible key can differ from their live key, plus tombstoned slots a pre-delete snapshot still sees (`ResolveTombstonedSlotForSnapshot`). Deduplicated by slot against the bucket candidates.
41
+
42
+
The matched equality conjuncts stay in the residual WHERE, so any candidate whose *resolved* version doesn't actually match the probe (e.g. a live-bucket row whose snapshot-visible version carries the old key) is filtered there — no false positives, and the sweep eliminates false negatives. The extra cost is O(|`RowVersions`|), which a read-mostly RCSI workload keeps small (the version-store GC trims versions no open snapshot needs); the whole-table scan this replaces *already* walks `RowVersions` in its own second pass, so the version-aware seek is never more expensive than the scan it supplants. `ResolveSnapshotXidForRead` is called unconditionally regardless of whether the read seeks or scans, for its snapshot-pinning side effects.
43
+
36
44
All three single-table projectors — non-aggregate (`ProjectSqlRows`), aggregate (`BuildAggregateProjectionRows`), and window (`ProjectWindowedRows`) — narrow a single-base-table source through the seek, so `SELECT COUNT(*) … WHERE indexedcol = x` and `SELECT … OVER (…) FROM t WHERE indexedcol = x` both seek like their non-aggregate counterpart (without this the window projector silently full-scanned even when its WHERE was perfectly sargable, the regression that made running-total-per-parent EF queries scan the table). They also push single-source WHERE equality predicates onto the **leftmost** source of a multi-source FROM before the join (`NarrowLeftmostJoinSource` — the leftmost is always preserved, so this never changes outer-join semantics), which shrinks the join's driving rowset. The INNER / LEFT equi-join operator then **seeks the inner side per outer row** when that rowset is small and the inner is indexed on the join key — see [`joins.md`](joins.md). Not modeled: range seeks, ORDER BY elimination.
0 commit comments