Skip to content

Commit c10702f

Browse files
committed
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.
1 parent 79a896b commit c10702f

3 files changed

Lines changed: 155 additions & 35 deletions

File tree

SqlServerSimulator.Tests.Internal/IndexSeekTests.cs

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -578,12 +578,13 @@ public void RcsiRead_NoVersions_Seeks()
578578
}
579579

580580
[TestMethod]
581-
public void RcsiRead_WithLiveVersionChain_Declines()
581+
public void RcsiRead_WithLiveVersionChain_Seeks()
582582
{
583-
// An open writer leaves a version chain on the table, so an RCSI
584-
// reader's visible version can diverge from the live heap row — the
585-
// seek declines back to the full scan, and the reader still sees the
586-
// pre-write committed value.
583+
// An open writer leaves a version chain on the table. The RCSI reader
584+
// still seeks (it no longer declines table-wide on any version chain),
585+
// materializing each candidate through the version store: it sees the
586+
// pre-write committed value of the row the writer touched and the
587+
// unaffected value of another, both via the seek.
587588
var sim = new Simulation();
588589
using var reader = sim.CreateDbConnection();
589590
reader.Open();
@@ -602,21 +603,84 @@ public void RcsiRead_WithLiveVersionChain_Declines()
602603
IndexSeekDiagnostics.Sink = [];
603604
try
604605
{
605-
using var command = reader.CreateCommand();
606-
command.CommandText = "select val from t where id = 2";
607-
using var r = command.ExecuteReader();
608-
var rows = new List<object?>();
609-
while (r.Read())
610-
rows.Add(r.GetValue(0));
611-
Contains("Scan(t)", IndexSeekDiagnostics.Sink);
612-
DoesNotContain("Seek(t)", IndexSeekDiagnostics.Sink);
613-
HasCount(1, rows);
614-
AreEqual(50, rows[0]);
606+
AreEqual(50, ReadVal(reader, "select val from t where id = 2"));
607+
Contains("Seek(t)", IndexSeekDiagnostics.Sink);
608+
DoesNotContain("Scan(t)", IndexSeekDiagnostics.Sink);
609+
610+
// The touched row reads its last-committed value (5), not the
611+
// writer's uncommitted 999 — resolved through the version store on
612+
// the seek path.
613+
AreEqual(5, ReadVal(reader, "select val from t where id = 1"));
614+
}
615+
finally
616+
{
617+
IndexSeekDiagnostics.Sink = null;
618+
}
619+
}
620+
621+
[TestMethod]
622+
public void SnapshotRead_AfterCommittedKeyChange_SeekStaysCorrect()
623+
{
624+
// The false-negative case the table-wide decline used to guard against:
625+
// a committed UPDATE moves a row's PRIMARY KEY (2 -> 99) after a SNAPSHOT
626+
// reader pins its snapshot. The reader must still find the row by its
627+
// old key (the live heap no longer has id = 2, so the bucket misses it —
628+
// the version-chain sweep is what recovers it) and must NOT see it under
629+
// the new key (the snapshot predates that commit). Both stay seeks.
630+
var sim = new Simulation();
631+
using var reader = sim.CreateDbConnection();
632+
reader.Open();
633+
using (var setup = reader.CreateCommand())
634+
{
635+
setup.CommandText =
636+
$"alter database simulated set allow_snapshot_isolation on; {TableT}";
637+
_ = setup.ExecuteNonQuery();
638+
}
639+
640+
// Pin the snapshot before the writer commits.
641+
Exec(reader, "set transaction isolation level snapshot");
642+
Exec(reader, "begin tran");
643+
_ = ReadVal(reader, "select count(*) from t");
644+
645+
using (var writer = sim.CreateDbConnection())
646+
{
647+
writer.Open();
648+
Exec(writer, "update t set id = 99 where id = 2");
649+
}
650+
651+
IndexSeekDiagnostics.Sink = [];
652+
try
653+
{
654+
// Old key still resolves to the snapshot-visible row (val 50).
655+
AreEqual(50, ReadVal(reader, "select val from t where id = 2"));
656+
// New key is invisible to this snapshot — found in the live bucket
657+
// but the resolved version carries the old key, so the residual
658+
// WHERE drops it.
659+
IsNull(ReadVal(reader, "select val from t where id = 99"));
660+
Contains("Seek(t)", IndexSeekDiagnostics.Sink);
661+
DoesNotContain("Scan(t)", IndexSeekDiagnostics.Sink);
615662
}
616663
finally
617664
{
618665
IndexSeekDiagnostics.Sink = null;
619666
}
667+
668+
Exec(reader, "commit");
669+
}
670+
671+
private static void Exec(SimulatedDbConnection c, string sql)
672+
{
673+
using var cmd = c.CreateCommand();
674+
cmd.CommandText = sql;
675+
_ = cmd.ExecuteNonQuery();
676+
}
677+
678+
private static object? ReadVal(SimulatedDbConnection c, string sql)
679+
{
680+
using var cmd = c.CreateCommand();
681+
cmd.CommandText = sql;
682+
using var r = cmd.ExecuteReader();
683+
return r.Read() ? (r.IsDBNull(0) ? null : r.GetValue(0)) : null;
620684
}
621685

622686
[TestMethod]

SqlServerSimulator/Parser/Selection.Execution.IndexSeek.cs

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,17 @@ private static FromSource[] MaybeApplyIndexSeek(
6363
return sources;
6464
}
6565

66-
// A snapshot / RCSI reader's visible version can carry a different key
67-
// than the live heap row, so a current-heap index could miss it — but
68-
// only once the table actually has version chains. With an empty version
69-
// store every row is implicitly committed at Xmin 0 (visible to every
70-
// snapshot) and the live heap IS the visible version, so the seek is
71-
// safe; this is the common case for read-mostly / bacpac-loaded data,
72-
// where declining would force a full scan on every point lookup.
73-
// ResolveSnapshotXidForRead is called unconditionally for its side
74-
// effects (pins the statement/tx snapshot xid, registers the active
75-
// snapshot reader) — the row-touch path relies on that bookkeeping
76-
// whether the read seeks or scans.
77-
if (batch.ResolveSnapshotXidForRead(table) is not null && !table.RowVersions.IsEmpty)
78-
{
79-
IndexSeekDiagnostics.Sink?.Add($"Scan({table.Name})");
80-
return sources;
81-
}
66+
// A snapshot / RCSI reader sees the version visible at its snapshot, not
67+
// necessarily the live heap row. ResolveSnapshotXidForRead returns that
68+
// snapshot xid (and pins the statement / tx snapshot as a side effect the
69+
// row-touch path relies on whether the read seeks or scans). When it's
70+
// non-null the seek still runs, but materializes each candidate through
71+
// the version store and additionally sweeps the rows carrying a version
72+
// chain — those are the only rows whose snapshot-visible key can differ
73+
// from their live key, so a live-key-only seek could miss them. With an
74+
// empty version store the sweep is empty and every candidate resolves to
75+
// its live bytes. See MaterializeSnapshotCandidates.
76+
var snapshotXid = batch.ResolveSnapshotXidForRead(table);
8277

8378
var conjuncts = new List<BooleanExpression>();
8479
foreach (var excluder in excluders)
@@ -102,7 +97,7 @@ private static FromSource[] MaybeApplyIndexSeek(
10297
}
10398

10499
if (equalities.Count != 0
105-
&& TrySeekByLongestPrefix(source, table, plan, batch, outerResolver, equalities, out var seekRows, out var width))
100+
&& TrySeekByLongestPrefix(source, table, plan, batch, snapshotXid, outerResolver, equalities, out var seekRows, out var width))
106101
{
107102
IndexSeekDiagnostics.Sink?.Add($"Seek({table.Name})");
108103
IndexSeekDiagnostics.Sink?.Add($"SeekWidth({table.Name},{width})");
@@ -234,6 +229,7 @@ private static bool TrySeekByLongestPrefix(
234229
HeapTable table,
235230
DataLockPlan plan,
236231
BatchContext batch,
232+
long? snapshotXid,
237233
Func<MultiPartName, SqlValue>? outerResolver,
238234
Dictionary<int, Expression[]> equalities,
239235
out IEnumerable<byte[]> seekRows,
@@ -299,7 +295,9 @@ private static bool TrySeekByLongestPrefix(
299295
candidates.AddRange(bucket);
300296
}
301297

302-
seekRows = MaterializeWithLockChecks(table, batch, plan, candidates);
298+
seekRows = snapshotXid is { } sx
299+
? MaterializeSnapshotCandidates(table, batch, sx, candidates)
300+
: MaterializeWithLockChecks(table, batch, plan, candidates);
303301
width = bestLen;
304302
return true;
305303

@@ -441,6 +439,56 @@ private static IEnumerable<byte[]> MaterializeWithLockChecks(
441439
}
442440
}
443441

442+
// Snapshot / RCSI candidate materialization. Mirrors the snapshot branch of
443+
// BatchContext.WrapWithRowConflictChecks, but over the seeked candidate set
444+
// rather than the whole heap. Two sources, deduplicated by slot:
445+
// 1. Bucket candidates — live rows whose CURRENT key matched the probe.
446+
// Each resolves to the version visible at the snapshot (live bytes when
447+
// the row carries no chain), or drops out when not visible.
448+
// 2. Version-chain sweep — every slot in RowVersions. These are the only
449+
// rows whose snapshot-visible key can differ from their live key (so a
450+
// live-key-only bucket lookup could miss them) plus tombstoned slots
451+
// whose pre-delete version a pre-delete snapshot still sees. Bounded by
452+
// |RowVersions|, which a read-mostly RCSI workload keeps small (the GC
453+
// trims versions no open snapshot needs). The whole-table scan this
454+
// replaces already walks RowVersions in its own second pass, so the
455+
// seek is never more expensive than the scan it supplants.
456+
// The matched equality conjuncts stay in the residual WHERE, so any candidate
457+
// whose resolved version doesn't actually match the probe is filtered there.
458+
private static IEnumerable<byte[]> MaterializeSnapshotCandidates(
459+
HeapTable table, BatchContext batch, long snapshotXid, IReadOnlyList<(int Page, int Slot)> bucketCandidates)
460+
{
461+
var tx = batch.Connection.CurrentTransaction;
462+
var seen = new HashSet<(int, int)>();
463+
464+
foreach (var (page, slot) in bucketCandidates)
465+
{
466+
if (!seen.Add((page, slot)))
467+
continue;
468+
if (table.Heap.ReadSlotBytes(page, slot) is { } live
469+
&& Storage.VersionStore.ResolveVisibleVersion(table, (page, slot), live, snapshotXid, tx) is { } resolved)
470+
{
471+
yield return resolved;
472+
}
473+
}
474+
475+
foreach (var kv in table.RowVersions)
476+
{
477+
var page = kv.Key.PageIndex;
478+
var slot = kv.Key.SlotIndex;
479+
if (!seen.Add((page, slot)))
480+
continue;
481+
482+
var resolved = table.Heap.IsSlotTombstoned(page, slot)
483+
? Storage.VersionStore.ResolveTombstonedSlotForSnapshot(kv.Value, snapshotXid, tx)
484+
: table.Heap.ReadSlotBytes(page, slot) is { } live
485+
? Storage.VersionStore.ResolveVisibleVersion(table, (page, slot), live, snapshotXid, tx)
486+
: null;
487+
if (resolved is { } bytes)
488+
yield return bytes;
489+
}
490+
}
491+
444492
// A value side that is constant across the source's rows and free of side
445493
// effects, so evaluating it once for the seek and again in the residual WHERE
446494
// is harmless. Pure conversion wrappers (CAST / CONVERT / parens) are peeled

docs/claude/indexes.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,20 @@ A prefix is bounded at the first key column that either has no equality conjunct
2727

2828
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.
2929

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:
3131

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;
3332
- **tx-scoped row-lock plans** (`REPEATABLE READ` / `SERIALIZABLE` / `UPDLOCK` / `HOLDLOCK` …), where the scan deliberately locks every row it reads to end of transaction;
3433
- 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.
3534

35+
### Snapshot / RCSI seeks (version-aware materialization)
36+
37+
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+
3644
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.
3745

3846
## Storage

0 commit comments

Comments
 (0)