Skip to content

Commit 92caf50

Browse files
committed
Initial work on a query plan cache and the architectural upgrades to enable it. Covers single-SELECT batches.
1 parent 09653c5 commit 92caf50

9 files changed

Lines changed: 752 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
159159
- **`ALTER TABLE` ADD/DROP/ALTER COLUMN + CONSTRAINT (incl. trust toggling)**[`alter-table.md`](docs/claude/alter-table.md).
160160
- **`CREATE INDEX` (UNIQUE / CLUSTERED / INCLUDE / WHERE filter), `sys.indexes`**[`indexes.md`](docs/claude/indexes.md).
161161
- **Table hints (`WITH (NOLOCK …)`) + statement `OPTION (…)` hints**[`query-hints.md`](docs/claude/query-hints.md).
162+
- **Per-`Simulation` plan cache** (single-SELECT `Selection` reuse keyed by text + db + parameter-type signature, `SchemaVersion`-stamped invalidation, inline-in-the-SELECT-arm promotion because the iterator's post-yield code is unreachable on a non-draining `ExecuteReader`; `VariableReference` Run-time slot lookup is the load-bearing co-fix) → [`plan-cache.md`](docs/claude/plan-cache.md).
162163
- **Locking, MVCC, SNAPSHOT/RCSI, deadlock/timeout, lock-related DMVs**[`locking.md`](docs/claude/locking.md).
163164
- **`hierarchyid` data type** (incl. deferred byte-identical CAST research notes) → [`hierarchyid.md`](docs/claude/hierarchyid.md).
164165
- **`GRANT` / `REVOKE` / `DENY`, principal DDL, fixed-principal seed, principal scalars (`USER_ID` / `SUSER_ID` / `DATABASE_PRINCIPAL_ID` / `USER_NAME` / `SUSER_NAME` / `SUSER_SNAME` / `CURRENT_USER` / `SESSION_USER` / `SYSTEM_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER` / `IS_ROLEMEMBER` / `IS_SRVROLEMEMBER`)**[`permissions.md`](docs/claude/permissions.md).
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Guards the per-<see cref="Simulation"/> plan cache for single-SELECT
7+
/// command batches (the EF-query shape). Every parse-and-replay path here
8+
/// is observable through the internal <see cref="Simulation.PlanCacheHits"/>
9+
/// / <see cref="Simulation.PlanCacheMisses"/> / <see cref="Simulation.PlanCacheCount"/>
10+
/// counters, which gate on cache-key matching plus schema-version validation;
11+
/// the cache is bypassed entirely for batches that aren't a single top-level
12+
/// SELECT (multi-statement, mixed DDL+DML, reference a #temp / ##gtemp /
13+
/// table-variable, or whose parameters' DbType can't be inferred — TVP
14+
/// structured binding).
15+
/// </summary>
16+
[TestClass]
17+
public sealed class PlanCacheTests
18+
{
19+
private static (Simulation Sim, SimulatedDbConnection Connection) OpenWithTable()
20+
{
21+
var sim = new Simulation();
22+
var connection = sim.CreateDbConnection();
23+
connection.Open();
24+
using var setup = connection.CreateCommand();
25+
setup.CommandText = """
26+
create table t (id int not null primary key, val int not null);
27+
insert t values (1, 10), (2, 20), (3, 30);
28+
""";
29+
_ = setup.ExecuteNonQuery();
30+
return (sim, connection);
31+
}
32+
33+
private static int RunCount(SimulatedDbConnection connection, string sql)
34+
{
35+
using var command = connection.CreateCommand();
36+
command.CommandText = sql;
37+
using var reader = command.ExecuteReader();
38+
var rows = 0;
39+
while (reader.Read())
40+
rows++;
41+
return rows;
42+
}
43+
44+
[TestMethod]
45+
public void RepeatedQuery_SecondCallHits()
46+
{
47+
var (sim, connection) = OpenWithTable();
48+
using (connection)
49+
{
50+
var hitsBefore = sim.PlanCacheHits;
51+
var missesBefore = sim.PlanCacheMisses;
52+
AreEqual(3, RunCount(connection, "select val from t"));
53+
// First call: cache miss, entry added.
54+
AreEqual(missesBefore + 1, sim.PlanCacheMisses);
55+
AreEqual(hitsBefore, sim.PlanCacheHits);
56+
AreEqual(3, RunCount(connection, "select val from t"));
57+
// Second call: cache hit, no parse / no entry add.
58+
AreEqual(hitsBefore + 1, sim.PlanCacheHits);
59+
AreEqual(missesBefore + 1, sim.PlanCacheMisses);
60+
}
61+
}
62+
63+
[TestMethod]
64+
public void DistinctCommandText_DistinctEntries()
65+
{
66+
// Different WHERE clauses produce different CommandTexts → different
67+
// cache entries. Neither hits the other; both get cached for replay.
68+
var (sim, connection) = OpenWithTable();
69+
using (connection)
70+
{
71+
var entriesBefore = sim.PlanCacheCount;
72+
AreEqual(1, RunCount(connection, "select val from t where id = 1"));
73+
AreEqual(1, RunCount(connection, "select val from t where id = 2"));
74+
// Both queries cached separately.
75+
AreEqual(entriesBefore + 2, sim.PlanCacheCount);
76+
// Replays now hit, not miss.
77+
var hitsBefore = sim.PlanCacheHits;
78+
AreEqual(1, RunCount(connection, "select val from t where id = 1"));
79+
AreEqual(1, RunCount(connection, "select val from t where id = 2"));
80+
AreEqual(hitsBefore + 2, sim.PlanCacheHits);
81+
}
82+
}
83+
84+
[TestMethod]
85+
public void DdlInvalidatesCache()
86+
{
87+
// The schema-version stamp on each entry is compared at lookup against
88+
// the live SchemaVersion. CREATE INDEX bumps the version, so the next
89+
// call against the same CommandText hits a stale entry → re-parse,
90+
// overwrite the entry under the new version.
91+
var (sim, connection) = OpenWithTable();
92+
using (connection)
93+
{
94+
AreEqual(3, RunCount(connection, "select val from t"));
95+
var hitsAfterFirst = sim.PlanCacheHits;
96+
using (var ddl = connection.CreateCommand())
97+
{
98+
ddl.CommandText = "create index ix_t_val on t (val)";
99+
_ = ddl.ExecuteNonQuery();
100+
}
101+
AreEqual(3, RunCount(connection, "select val from t"));
102+
// Stale entry → miss, even though the CommandText is identical.
103+
AreEqual(hitsAfterFirst, sim.PlanCacheHits);
104+
// Third call hits the freshly-cached entry.
105+
AreEqual(3, RunCount(connection, "select val from t"));
106+
AreEqual(hitsAfterFirst + 1, sim.PlanCacheHits);
107+
}
108+
}
109+
110+
[TestMethod]
111+
public void TempTableReference_NotCached()
112+
{
113+
// A #temp table binding captures a session-local HeapTable instance;
114+
// a cross-session replay would project the wrong table. The dispatch
115+
// sets HasSessionScopedReference on TryResolveTable's temp-table
116+
// path, and the promotion check declines.
117+
var (sim, connection) = OpenWithTable();
118+
using (connection)
119+
{
120+
using (var setup = connection.CreateCommand())
121+
{
122+
setup.CommandText = "select * into #scratch from t";
123+
_ = setup.ExecuteNonQuery();
124+
}
125+
var entriesBefore = sim.PlanCacheCount;
126+
AreEqual(3, RunCount(connection, "select val from #scratch"));
127+
// No cache entry added for the temp-table-referencing query.
128+
AreEqual(entriesBefore, sim.PlanCacheCount);
129+
// Second call also misses (nothing was added the first time).
130+
var missesBefore = sim.PlanCacheMisses;
131+
AreEqual(3, RunCount(connection, "select val from #scratch"));
132+
AreEqual(missesBefore + 1, sim.PlanCacheMisses);
133+
}
134+
}
135+
136+
[TestMethod]
137+
public void TableVariableReference_NotCached()
138+
{
139+
// A @t table-variable binding is per-BATCH, so any cached plan would
140+
// be unusable in another batch (the variable doesn't even exist).
141+
// The dispatch flags the resolution at the @t path; the promotion
142+
// check sees the flag and declines.
143+
var (sim, connection) = OpenWithTable();
144+
using (connection)
145+
{
146+
var entriesBefore = sim.PlanCacheCount;
147+
using var command = connection.CreateCommand();
148+
command.CommandText = """
149+
declare @v table (id int);
150+
insert @v values (1);
151+
select id from @v;
152+
""";
153+
_ = RunReaderCount(command);
154+
AreEqual(entriesBefore, sim.PlanCacheCount);
155+
}
156+
157+
static int RunReaderCount(SimulatedDbCommand command)
158+
{
159+
using var reader = command.ExecuteReader();
160+
var rows = 0;
161+
while (reader.Read())
162+
rows++;
163+
return rows;
164+
}
165+
}
166+
167+
[TestMethod]
168+
public void MultiStatementBatch_NotCached()
169+
{
170+
// Two top-level SELECT statements in one CommandText: the second
171+
// statement's dispatch clears the candidate (the cache only models
172+
// single-statement batches).
173+
var (sim, connection) = OpenWithTable();
174+
using (connection)
175+
{
176+
var entriesBefore = sim.PlanCacheCount;
177+
using var command = connection.CreateCommand();
178+
command.CommandText = "select val from t; select id from t;";
179+
using (var reader = command.ExecuteReader())
180+
{
181+
while (reader.Read())
182+
{
183+
// drain first result set
184+
}
185+
_ = reader.NextResult();
186+
while (reader.Read())
187+
{
188+
// drain second
189+
}
190+
}
191+
AreEqual(entriesBefore, sim.PlanCacheCount);
192+
}
193+
}
194+
195+
[TestMethod]
196+
public void DifferentParameterTypes_DistinctEntries()
197+
{
198+
// Same SQL text with different declared parameter DbTypes parses to
199+
// different inferred result types (and possibly different overloads),
200+
// so each must get its own cache entry — the parameter signature is
201+
// part of the key.
202+
var (sim, connection) = OpenWithTable();
203+
using (connection)
204+
{
205+
var entriesBefore = sim.PlanCacheCount;
206+
207+
using (var asInt = connection.CreateCommand())
208+
{
209+
asInt.CommandText = "select val from t where id = @p";
210+
var p = asInt.CreateParameter();
211+
p.ParameterName = "@p";
212+
p.DbType = System.Data.DbType.Int32;
213+
p.Value = 1;
214+
_ = asInt.Parameters.Add(p);
215+
_ = asInt.ExecuteScalar();
216+
}
217+
using (var asBigInt = connection.CreateCommand())
218+
{
219+
asBigInt.CommandText = "select val from t where id = @p";
220+
var p = asBigInt.CreateParameter();
221+
p.ParameterName = "@p";
222+
p.DbType = System.Data.DbType.Int64;
223+
p.Value = 1L;
224+
_ = asBigInt.Parameters.Add(p);
225+
_ = asBigInt.ExecuteScalar();
226+
}
227+
228+
// Two cache entries: same text, different param-type signatures.
229+
AreEqual(entriesBefore + 2, sim.PlanCacheCount);
230+
}
231+
}
232+
233+
[TestMethod]
234+
public void IdenticalParameterValues_DifferentValues_StillHits()
235+
{
236+
// The cache key includes parameter NAME and TYPE, not VALUE — calls
237+
// with the same SQL text and same param shape but different values
238+
// share a plan. This is the dominant EF path: prepared SQL with
239+
// parameter placeholders fired with many different runtime values.
240+
var (sim, connection) = OpenWithTable();
241+
using (connection)
242+
{
243+
int Run(int idValue)
244+
{
245+
using var command = connection.CreateCommand();
246+
command.CommandText = "select val from t where id = @p";
247+
var p = command.CreateParameter();
248+
p.ParameterName = "@p";
249+
p.DbType = System.Data.DbType.Int32;
250+
p.Value = idValue;
251+
_ = command.Parameters.Add(p);
252+
return (int)command.ExecuteScalar()!;
253+
}
254+
AreEqual(10, Run(1));
255+
var hitsAfterFirst = sim.PlanCacheHits;
256+
AreEqual(20, Run(2));
257+
AreEqual(30, Run(3));
258+
// Two hits, one per replay.
259+
AreEqual(hitsAfterFirst + 2, sim.PlanCacheHits);
260+
}
261+
}
262+
263+
[TestMethod]
264+
public void ResultsMatchAcrossHitAndMiss()
265+
{
266+
// Correctness sanity: the cached replay must produce the same row set
267+
// a fresh parse would. Run twice, compare results — both pre- and
268+
// post-cache the answer is the same set.
269+
var (sim, connection) = OpenWithTable();
270+
using (connection)
271+
{
272+
var firstPass = ReadRows(connection, "select id, val from t order by id desc");
273+
var secondPass = ReadRows(connection, "select id, val from t order by id desc");
274+
HasCount(firstPass.Count, secondPass);
275+
for (var i = 0; i < firstPass.Count; i++)
276+
{
277+
AreEqual(firstPass[i].Id, secondPass[i].Id);
278+
AreEqual(firstPass[i].Val, secondPass[i].Val);
279+
}
280+
IsGreaterThanOrEqualTo(1L, sim.PlanCacheHits);
281+
}
282+
283+
static List<(int Id, int Val)> ReadRows(SimulatedDbConnection connection, string sql)
284+
{
285+
using var command = connection.CreateCommand();
286+
command.CommandText = sql;
287+
using var reader = command.ExecuteReader();
288+
var rows = new List<(int, int)>();
289+
while (reader.Read())
290+
rows.Add((reader.GetInt32(0), reader.GetInt32(1)));
291+
return rows;
292+
}
293+
}
294+
295+
[TestMethod]
296+
public void NonSelectBatch_NotCached()
297+
{
298+
// An INSERT / UPDATE / DELETE batch (no top-level SELECT) doesn't
299+
// populate a cache candidate — the dispatch SELECT arm is the only
300+
// arm that sets PlanCacheCandidate. Verifies the cache stays empty
301+
// even for a quite-common DML shape.
302+
var sim = new Simulation();
303+
var connection = sim.CreateDbConnection();
304+
connection.Open();
305+
using (connection)
306+
{
307+
using (var ddl = connection.CreateCommand())
308+
{
309+
ddl.CommandText = "create table t (id int not null)";
310+
_ = ddl.ExecuteNonQuery();
311+
}
312+
var entriesBefore = sim.PlanCacheCount;
313+
using var dml = connection.CreateCommand();
314+
dml.CommandText = "insert t values (1)";
315+
_ = dml.ExecuteNonQuery();
316+
AreEqual(entriesBefore, sim.PlanCacheCount);
317+
}
318+
}
319+
}

0 commit comments

Comments
 (0)