Skip to content

Commit c98b8f8

Browse files
committed
Seek-narrow single-table UPDATE / DELETE target scans through the shared per-Heap seek cache (Selection.SeekMutationTarget, reusing the query path's equality/range candidate cores) instead of full-scanning.
1 parent 51e98be commit c98b8f8

7 files changed

Lines changed: 275 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contrac
107107

108108
### Constraints
109109
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate. Inline column-level CHECK predicates may only reference their owning column — peer references raise **Msg 8141** at CREATE TABLE (probe-confirmed verbatim wording). The walker is structural via `Expression.VisitColumnReferences` + `BooleanExpression.VisitOperandExpressions`; coverage is currently limited to common container subclasses (`Reference`, `Parenthesized`, `TwoSidedExpression`, `Cast`, `Length`) — peer refs buried in less-common containers (`DATEPART`, `SUBSTRING`, nested `CASE`, etc.) silently escape the CREATE-TABLE check and surface at INSERT instead. Table-level CHECK has no peer restriction.
110-
- `PRIMARY KEY` / `UNIQUE` / secondary `CREATE INDEX`: linear scan (O(N) per insert); no B-tree. Reads get **incrementally-maintained** per-`Heap` seek acceleration (no per-mutation warm-up), identical across keys and non-clustered indexes (built from heap-row bytes keyed by the index's ordinals): equality / IN on the longest leading prefix, range (`>`/`<`/`BETWEEN`) on a leading key column, ORDER BY elimination — single or multi-column leading-prefix sort (all NOT NULL, all one direction), including an equality-prefix continued by the order columns (`WHERE a = @x ORDER BY b`) — and keyset (seek-method) pagination (`WHERE a > @x OR (a = @x AND b > @y) ORDER BY a, b` seeks past the cursor). See [`indexes.md`](docs/claude/indexes.md) for the journal mechanics, decline rules, and the residual-WHERE correctness invariant.
110+
- `PRIMARY KEY` / `UNIQUE` / secondary `CREATE INDEX`: linear scan (O(N) per insert); no B-tree. Reads get **incrementally-maintained** per-`Heap` seek acceleration (no per-mutation warm-up), identical across keys and non-clustered indexes (built from heap-row bytes keyed by the index's ordinals): equality / IN on the longest leading prefix, range (`>`/`<`/`BETWEEN`) on a leading key column, ORDER BY elimination — single or multi-column leading-prefix sort (all NOT NULL, all one direction), including an equality-prefix continued by the order columns (`WHERE a = @x ORDER BY b`) — and keyset (seek-method) pagination (`WHERE a > @x OR (a = @x AND b > @y) ORDER BY a, b` seeks past the cursor). The same equality / range narrowing accelerates **single-table `UPDATE` / `DELETE` target scans** (`Selection.SeekMutationTarget`; the mutation loop's full-predicate re-check is the residual filter, and it X-locks only the rows it commits so the lock footprint is unchanged) — `MERGE` still full-scans (its `ON` correlates target to source, so it needs loop inversion). See [`indexes.md`](docs/claude/indexes.md) for the journal mechanics, decline rules, and the residual-WHERE correctness invariant.
111111
- `FOREIGN KEY`: inline / table-level / named forms; all four referential actions on `ON DELETE` / `ON UPDATE`; enforced at INSERT / UPDATE / DELETE / MERGE; full `sys.foreign_keys` / `sys.foreign_key_columns`. Enforcement **seeks the shared `HeapSeekCache`** (the same per-`Heap` index the query path uses): child-insert parent-existence probes the parent's PK/UNIQUE; parent-delete/update cascades seek the child's FK columns — both verifying candidates against live bytes (no residual WHERE), with a full-scan fallback only for non-stored key columns. Referential-action, cascade-cycle, PK/UNIQUE-target, and NULL-skip rules + Msg numbers in [`foreign-keys.md`](docs/claude/foreign-keys.md).
112112

113113
### Transactions

SqlServerSimulator.Tests.Internal/IndexSeekTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1613,4 +1613,105 @@ insert ch values (10, 1), (11, 1), (12, 2), (13, 3)
16131613
Exec(c, "delete from p where id = 1");
16141614
AreEqual("12,13", Seq(ReadRows(c, "select id from ch order by id")));
16151615
}
1616+
1617+
// ---- UPDATE / DELETE target scans ride the same per-Heap seek cache
1618+
// (Selection.SeekMutationTarget). A single-table UPDATE / DELETE does no
1619+
// query-path seek of its own, so any CacheBuild / CacheReplay during one is
1620+
// the mutation target seek; a full scan touches the cache not at all. The
1621+
// mutation loop re-runs the full WHERE per row, so the seek only narrows. ----
1622+
1623+
private static List<string> ExecTraced(SimulatedDbConnection c, string sql)
1624+
{
1625+
IndexSeekDiagnostics.Sink = [];
1626+
try
1627+
{
1628+
Exec(c, sql);
1629+
return IndexSeekDiagnostics.Sink;
1630+
}
1631+
finally
1632+
{
1633+
IndexSeekDiagnostics.Sink = null;
1634+
}
1635+
}
1636+
1637+
private static SimulatedDbConnection FreshT()
1638+
{
1639+
var c = new Simulation().CreateDbConnection();
1640+
c.Open();
1641+
Exec(c, TableT);
1642+
return c;
1643+
}
1644+
1645+
[TestMethod]
1646+
public void UpdateByPrimaryKey_SeeksTarget()
1647+
{
1648+
var c = FreshT();
1649+
Contains("CacheBuild", ExecTraced(c, "update t set val = 999 where id = 2"));
1650+
AreEqual(999, Convert.ToInt32(ReadVal(c, "select val from t where id = 2")));
1651+
AreEqual("5,500", Seq(ReadRows(c, "select val from t where id <> 2 order by id")));
1652+
}
1653+
1654+
[TestMethod]
1655+
public void DeleteByPrimaryKey_SeeksTarget()
1656+
{
1657+
var c = FreshT();
1658+
Contains("CacheBuild", ExecTraced(c, "delete from t where id = 2"));
1659+
AreEqual("1,3", Seq(ReadRows(c, "select id from t order by id")));
1660+
}
1661+
1662+
[TestMethod]
1663+
public void DeleteByRange_RangeSeeksTarget()
1664+
{
1665+
var c = FreshT();
1666+
Contains("CacheBuild", ExecTraced(c, "delete from t where id >= 2"));
1667+
AreEqual("1", Seq(ReadRows(c, "select id from t order by id")));
1668+
}
1669+
1670+
[TestMethod]
1671+
public void DeleteByInList_SeeksTarget()
1672+
{
1673+
var c = FreshT();
1674+
Contains("CacheBuild", ExecTraced(c, "delete from t where id in (1, 3)"));
1675+
AreEqual("2", Seq(ReadRows(c, "select id from t order by id")));
1676+
}
1677+
1678+
[TestMethod]
1679+
public void UpdateOnUnindexedColumn_FullScans()
1680+
{
1681+
var c = FreshT();
1682+
// val carries no key / index, so nothing seekable — the mutation keeps
1683+
// its full scan and never touches the seek cache.
1684+
DoesNotContain("CacheBuild", ExecTraced(c, "update t set val = 0 where val = 50"));
1685+
AreEqual(0, Convert.ToInt32(ReadVal(c, "select val from t where id = 2")));
1686+
}
1687+
1688+
[TestMethod]
1689+
public void UpdateSeek_ResidualWhereStillExcludes()
1690+
{
1691+
var c = FreshT();
1692+
// id = 2 seeks the single row, but the residual val > 1000 conjunct
1693+
// (re-checked in the mutation loop) excludes it: zero rows updated.
1694+
Exec(c, "update t set val = 7 where id = 2 and val > 1000");
1695+
AreEqual(50, Convert.ToInt32(ReadVal(c, "select val from t where id = 2")));
1696+
}
1697+
1698+
[TestMethod]
1699+
public void UpdateSeek_ReplaysDelta_NoWarmup()
1700+
{
1701+
var c = FreshT();
1702+
Exec(c, "update t set val = val where id = 1"); // warms the seek cache
1703+
Exec(c, "insert t values (4, 40)"); // moves the mutation generation
1704+
var trace = ExecTraced(c, "update t set val = 111 where id = 4");
1705+
Contains("CacheReplay", trace);
1706+
DoesNotContain("CacheBuild", trace);
1707+
AreEqual(111, Convert.ToInt32(ReadVal(c, "select val from t where id = 4")));
1708+
}
1709+
1710+
[TestMethod]
1711+
public void DeleteSeek_NoMatch_LeavesTableIntact()
1712+
{
1713+
var c = FreshT();
1714+
Exec(c, "delete from t where id = 99");
1715+
AreEqual("1,2,3", Seq(ReadRows(c, "select id from t order by id")));
1716+
}
16161717
}

SqlServerSimulator/Parser/Selection.Execution.IndexSeek.cs

Lines changed: 144 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,32 @@ private static bool TrySeekByRange(
155155
bool allowCorrelatedColumnValue,
156156
out IEnumerable<byte[]> seekRows)
157157
{
158-
seekRows = [];
158+
if (!TryComputeRangeCandidates(source, table, batch, outerResolver, conjuncts, allowCorrelatedColumnValue, out var candidates))
159+
{
160+
seekRows = [];
161+
return false;
162+
}
163+
164+
seekRows = snapshotXid is { } sx
165+
? MaterializeSnapshotCandidates(table, batch, sx, candidates)
166+
: MaterializeWithLockChecks(table, batch, plan, candidates);
167+
return true;
168+
}
169+
170+
// Address-only core of the single-column leading range seek, shared by the
171+
// query path (TrySeekByRange) and the mutation path. Returns true with the
172+
// in-range (page, slot) candidates (possibly empty — a NULL bound seeks to
173+
// nothing), or false when no range bound lands on a leading key column.
174+
private static bool TryComputeRangeCandidates(
175+
FromSource source,
176+
HeapTable table,
177+
BatchContext batch,
178+
Func<MultiPartName, SqlValue>? outerResolver,
179+
List<BooleanExpression> conjuncts,
180+
bool allowCorrelatedColumnValue,
181+
out List<(int Page, int Slot)> candidates)
182+
{
183+
candidates = [];
159184

160185
var bounds = new Dictionary<int, RangeBoundExprs>();
161186
foreach (var conjunct in conjuncts)
@@ -221,13 +246,9 @@ private static bool TrySeekByRange(
221246
upperValue = upperValue.CoerceTo(common);
222247

223248
var cache = HeapSeekCache.For(table.Heap);
224-
var candidates = cache.RangeScan(
249+
candidates = cache.RangeScan(
225250
table.Heap, source.StoredSchema, source.LobStore, ordinal, common,
226251
hasLower, lowerValue, bound.LowerInclusive, hasUpper, upperValue, bound.UpperInclusive);
227-
228-
seekRows = snapshotXid is { } sx
229-
? MaterializeSnapshotCandidates(table, batch, sx, candidates)
230-
: MaterializeWithLockChecks(table, batch, plan, candidates);
231252
return true;
232253
}
233254

@@ -1053,7 +1074,35 @@ private static bool TrySeekByLongestPrefix(
10531074
out IEnumerable<byte[]> seekRows,
10541075
out int width)
10551076
{
1056-
seekRows = [];
1077+
if (!TryComputeEqualityCandidates(source, table, batch, outerResolver, equalities, out var candidates, out width))
1078+
{
1079+
seekRows = [];
1080+
return false;
1081+
}
1082+
1083+
seekRows = snapshotXid is { } sx
1084+
? MaterializeSnapshotCandidates(table, batch, sx, candidates)
1085+
: MaterializeWithLockChecks(table, batch, plan, candidates);
1086+
return true;
1087+
}
1088+
1089+
// Computes the seek-narrowed (page, slot) candidate addresses for the longest
1090+
// usable equality prefix across this table's keys / indexes — the address-only
1091+
// core shared by the query path (TrySeekByLongestPrefix wraps it in the lock /
1092+
// snapshot read materializer) and the mutation path (which materializes them as
1093+
// live rewrite targets). Probe components evaluate at most once per column; see
1094+
// TrySeekByLongestPrefix's doc for the prefix / cartesian rules. Returns false
1095+
// (prefix length 0) when no column carries a usable probe.
1096+
private static bool TryComputeEqualityCandidates(
1097+
FromSource source,
1098+
HeapTable table,
1099+
BatchContext batch,
1100+
Func<MultiPartName, SqlValue>? outerResolver,
1101+
Dictionary<int, Expression[]> equalities,
1102+
out List<(int Page, int Slot)> candidates,
1103+
out int width)
1104+
{
1105+
candidates = [];
10571106
width = 0;
10581107

10591108
var resolved = new Dictionary<int, (SqlType Common, SqlValue[] Probes)?>();
@@ -1105,17 +1154,13 @@ private static bool TrySeekByLongestPrefix(
11051154
}
11061155

11071156
var cache = HeapSeekCache.For(table.Heap);
1108-
var candidates = new List<(int Page, int Slot)>();
11091157
foreach (var tuple in CartesianProduct(probesPerColumn))
11101158
{
11111159
var bucket = cache.Seek(table.Heap, source.StoredSchema, source.LobStore, prefix, commons, new SqlValueKey(tuple));
11121160
if (bucket.Count != 0)
11131161
candidates.AddRange(bucket);
11141162
}
11151163

1116-
seekRows = snapshotXid is { } sx
1117-
? MaterializeSnapshotCandidates(table, batch, sx, candidates)
1118-
: MaterializeWithLockChecks(table, batch, plan, candidates);
11191164
width = bestLen;
11201165
return true;
11211166

@@ -1265,6 +1310,94 @@ private static IEnumerable<byte[]> MaterializeWithLockChecks(
12651310
}
12661311
}
12671312

1313+
/// <summary>
1314+
/// Seek-narrowed live <c>(page, slot, bytes)</c> rows for a single-table
1315+
/// UPDATE / DELETE target whose WHERE carries an indexable equality (literal /
1316+
/// variable / arithmetic value — never a correlated column, since a single-
1317+
/// table mutation has no outer row), IN-list / OR family, composite leading
1318+
/// prefix, or single leading-column range. Returns <c>null</c> when nothing
1319+
/// seekable is present, so the caller keeps its full
1320+
/// <see cref="Heap.EnumerateRowsWithAddress"/> scan. The result is an exact
1321+
/// match set for the seekable conjuncts only; the mutation loop re-runs the
1322+
/// full predicate per row (its residual filter), so a partly-seekable WHERE
1323+
/// stays correct and a stale cache entry is discarded there — the same
1324+
/// residual-filter contract as the query path. No lock / snapshot wrapper: the
1325+
/// mutation reads live addresses (exactly what the scan it replaces does) and
1326+
/// X-locks only the rows it commits, so narrowing the candidate set never
1327+
/// changes the lock footprint.
1328+
/// </summary>
1329+
internal static IEnumerable<(int Page, int Slot, byte[] Bytes)>? SeekMutationTarget(
1330+
HeapTable table, BooleanExpression where, BatchContext batch)
1331+
{
1332+
var source = BuildBaseTableSeekSource(table);
1333+
1334+
var conjuncts = new List<BooleanExpression>();
1335+
where.CollectConjuncts(conjuncts);
1336+
1337+
var equalities = new Dictionary<int, Expression[]>();
1338+
foreach (var conjunct in conjuncts)
1339+
{
1340+
if (conjunct.TryGetEqualityOperands(out var left, out var right))
1341+
{
1342+
_ = TryRecordColumnEquality(source, left, right, equalities, allowCorrelatedColumnValue: false)
1343+
|| TryRecordColumnEquality(source, right, left, equalities, allowCorrelatedColumnValue: false);
1344+
continue;
1345+
}
1346+
if (conjunct.TryGetEqualityFamily(out var family))
1347+
_ = TryRecordEqualityFamily(source, family, equalities, allowCorrelatedColumnValue: false);
1348+
}
1349+
1350+
if (equalities.Count != 0
1351+
&& TryComputeEqualityCandidates(source, table, batch, outerResolver: null, equalities, out var eqCandidates, out _))
1352+
{
1353+
return MaterializeMutationCandidates(table, eqCandidates);
1354+
}
1355+
1356+
if (TryComputeRangeCandidates(source, table, batch, outerResolver: null, conjuncts, allowCorrelatedColumnValue: false, out var rangeCandidates))
1357+
return MaterializeMutationCandidates(table, rangeCandidates);
1358+
1359+
// No seekable equality or range conjunct: caller keeps its full scan.
1360+
return null;
1361+
}
1362+
1363+
// Minimal single-source view of a base table for the mutation seek: real
1364+
// column names + storage metadata so TryIdentifyIndexableColumn resolves the
1365+
// WHERE's column references, qualified by the table's own name (the only
1366+
// qualifier a single-table UPDATE / DELETE WHERE can use). The Rows stream is
1367+
// unused — the seek reads addresses straight from the heap's cache.
1368+
private static FromSource BuildBaseTableSeekSource(HeapTable table)
1369+
{
1370+
var columnNames = new string[table.Columns.Length];
1371+
for (var i = 0; i < columnNames.Length; i++)
1372+
columnNames[i] = table.Columns[i].Name;
1373+
return new FromSource(
1374+
qualifier: table.Name,
1375+
columnNames: columnNames,
1376+
columns: table.Columns,
1377+
storedSchema: table.StoredColumns,
1378+
storageOrdinals: table.StorageOrdinals,
1379+
lobStore: table.Heap,
1380+
rows: []);
1381+
}
1382+
1383+
// The mutation analogue of MaterializeWithLockChecks: dedup + tombstone-skip
1384+
// over the seeked candidates, yielding each live row's address and bytes. No
1385+
// per-row lock touch (the mutation path X-locks only what it commits) and no
1386+
// live-key verify (the mutation loop's full-predicate re-check is the residual
1387+
// filter, exactly as the query path leans on its residual WHERE).
1388+
private static IEnumerable<(int Page, int Slot, byte[] Bytes)> MaterializeMutationCandidates(
1389+
HeapTable table, IReadOnlyList<(int Page, int Slot)> candidates)
1390+
{
1391+
var seen = new HashSet<(int, int)>();
1392+
foreach (var (page, slot) in candidates)
1393+
{
1394+
if (!seen.Add((page, slot)) || table.Heap.IsSlotTombstoned(page, slot))
1395+
continue;
1396+
if (table.Heap.ReadSlotBytes(page, slot) is { } bytes)
1397+
yield return (page, slot, bytes);
1398+
}
1399+
}
1400+
12681401
// Snapshot / RCSI candidate materialization. Mirrors the snapshot branch of
12691402
// BatchContext.WrapWithRowConflictChecks, but over the seeked candidate set
12701403
// rather than the whole heap. Two sources, deduplicated by slot:

SqlServerSimulator/Simulation/Simulation.Delete.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,15 @@ private static SimulatedStatementOutcome ExecuteDeleteAgainstTable(
121121
var needsFullForTriggers = hasDeleteTriggers || insteadOfActive;
122122
var needsFullForHistory = table.SystemVersioning is not null;
123123
var needsFullForFk = table.IncomingForeignKeys.Count > 0;
124-
foreach (var (pageIndex, slotIndex, rowBytes) in table.Heap.EnumerateRowsWithAddress())
124+
125+
// Seek the target when WHERE carries an indexable equality / range
126+
// (positioned DELETE leaves where null, so it keeps the full scan — the
127+
// cursor already fixed one row). The loop re-runs WHERE below, so the
128+
// seek only narrows the rows considered.
129+
var rowSource = where is not null
130+
? Selection.SeekMutationTarget(table, where, context.Batch) ?? table.Heap.EnumerateRowsWithAddress()
131+
: table.Heap.EnumerateRowsWithAddress();
132+
foreach (var (pageIndex, slotIndex, rowBytes) in rowSource)
125133
{
126134
// Positioned DELETE (WHERE CURRENT OF): only the cursor's row.
127135
if (positionedCursor is not null && !CursorRowMatches(positionedCursor, (pageIndex, slotIndex)))

SqlServerSimulator/Simulation/Simulation.Update.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,13 @@ private static SimulatedStatementOutcome ExecuteUpdateAgainstTable(
206206
var storedColumns = table.StoredColumns;
207207
var lobStore = table.Heap;
208208

209-
foreach (var (pageIndex, slotIndex, rowBytes) in table.Heap.EnumerateRowsWithAddress())
209+
// Seek the target when WHERE carries an indexable equality / range
210+
// (positioned UPDATE leaves where null, so it keeps the full scan). The
211+
// loop re-runs WHERE below, so the seek only narrows the rows considered.
212+
var rowSource = where is not null
213+
? Selection.SeekMutationTarget(table, where, context.Batch) ?? table.Heap.EnumerateRowsWithAddress()
214+
: table.Heap.EnumerateRowsWithAddress();
215+
foreach (var (pageIndex, slotIndex, rowBytes) in rowSource)
210216
{
211217
// Positioned UPDATE (WHERE CURRENT OF): target only the row the
212218
// cursor is sitting on, identified by its stable heap address.

0 commit comments

Comments
 (0)