Skip to content

Commit 730b2e3

Browse files
committed
Locking phase 1b — row-level Shared / Update / Exclusive data locks with the full SQL Server 8-mode matrix (Sch-S / Sch-M / IS / IX / SIX / S / U / X), SET TRANSACTION ISOLATION LEVEL session-state with semantic effect, row-lock escalation at 5000 per-tx per-table, and the UPDLOCK / XLOCK / READPAST / TABLOCK / TABLOCKX hint surface. LockMode extended to 8 entries; LockManager.IsCompatible rewritten as the standard SQL Server matrix (IS × {IS, IX, SIX, S, U} compatible / IS × X conflict; IX × IX compatible / IX × {S, U, SIX} conflict; SIX × IS only; S × {S, U, IS} compatible / S × {U, X, IX, SIX} … wait the real matrix has S × U compatible). New HeapTable.RowLocks (ConcurrentDictionary<(int pageIndex, int slotIndex), LockResource>) holds lazily-interned per-row resources keyed by RID; new HeapTable.TableDataLock is the table-level data lock (IS/IX/SIX/S/U/X), distinct from the inherited SchemaObject.SchemaLock which still carries only Sch-S / Sch-M. New DataLockPlan struct (returned by the refactored BatchContext.AcquireDataLockIfApplicable) encodes the per-row strategy for the caller to execute during iteration / mutation: row mode (S / U / X / null-probe-only / null-skip-noLock), tx-scoping, READPAST flag. Heap.Insert API change from void to (int PageIndex, int SlotIndex) return so DML sites can attach row-X to the freshly-inserted row's RID; 11 call sites updated (4 DML write sites capture and acquire row-X tx-scoped; 7 internal / history-table / constraint-cascade / system-table-init / TVP-clone / SELECT-INTO sites discard with _ =). BatchContext.AcquireRowLockTxScoped(table, page, slot, mode) is the central row-level acquire that bumps SimulatedDbTransaction.RowLockCountsByTable and triggers escalation when the per-table count crosses RowLockEscalationThreshold (5000, matching real SQL Server's documented default) — EscalateToTableX acquires the table-X first (may throw on timeout / cycle, leaves partial state consistent), then releases every row-lock entry on the escalated table from tx.HeldLocks, then marks the table in tx.EscalatedTables so future per-row acquires short-circuit. BatchContext.TouchRowForRead(table, page, slot, plan) is the reader's per-row helper: NoLockReader = no probe; non-null RowMode = acquire that mode tx-scoped (for HOLDLOCK / RR / UPDLOCK / XLOCK); else probe-only (RC default — LockManager.HasIncompatibleHolderOtherThan + transient acquire-release row-S on conflict matching real SQL Server's "wait for committed row" pattern). BatchContext.WrapWithRowConflictChecks wraps Heap.EnumerateRowsWithAddress into the FromSource.Rows enumerable so each yielded row flows through the touch helper; READPAST returns false from TouchRowForRead so the wrapper skips the row instead of yielding it. Selection.TableHintInfo gains UpdLock / XLock / ReadPast / TabLock / TabLockX / Serializable / Repeatable flags (split from phase-1a's combined HoldLock — HOLDLOCK / SERIALIZABLE = Serializable, REPEATABLEREAD = Repeatable since they have different semantics: SER prevents phantoms via table-S, RR doesn't). SimulatedDbConnection.SessionIsolationLevel (default ReadCommitted) gets wired through SET TRANSACTION ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SNAPSHOT | SERIALIZABLE } in Simulation.Set.cs — RU promotes every read to dirty-read (NOLOCK-equivalent), RC default, RR retains row-S tx-scoped per read row, SER takes table-S tx-scoped at scan start for phantom prevention at table granularity (the simulator's approximation of SQL Server's key-range locks since no index range structure is modeled), SNAPSHOT parses-and-discards (MVCC is phase 3). The hint-driven AcquireDataLockIfApplicable dispatch table: TABLOCKX → table-X (skip row); TABLOCK reader → table-S (tx-scoped under HOLDLOCK / RR / SER / session RR / SER); TABLOCK writer → table-X; writer default → table-IX + per-row X tx-scoped; reader XLOCK → table-IX + per-row X tx-scoped; reader UPDLOCK → table-IX + per-row U tx-scoped; reader SER hint / session SER → table-S tx-scoped (no per-row); reader RC default → table-IS + per-row probe-only; reader RR hint / session RR → table-IS + per-row S tx-scoped; table variables / local temp tables / system tables bypass everything. **Practical effect**: the phase-1a "stronger than RC" quirk is gone — a SELECT no longer blocks on any X-locked row in the table, only on the specific row being touched. Two writers on disjoint rows of the same table now proceed in parallel through table-IX + disjoint row-X. The phase-1a HoldLockHint_KeepsSUntilCommit test (which expected the HOLDLOCK hint to block a phantom-creating INSERT) now passes for the right reason: HOLDLOCK takes table-S tx-scoped instead of row-S, matching real SQL Server's documented "HOLDLOCK = SERIALIZABLE" equivalence. 6 new internal LockResourceTests (U-mode compatibility, IS-orthogonality, IX-conflict, SIX-only-with-IS, escalation past threshold) plus 9 new user-facing LockingTests (row-level RC disjoint-row independence, READPAST skip-blocked-row, UPDLOCK U-U conflict, XLOCK reader-blocking, TABLOCKX blocking, SERIALIZABLE phantom-prevention, REPEATABLE READ row-S blocks UPDATE but allows INSERT, READ UNCOMMITTED dirty read, two-writer disjoint-row parallel proceed). Phase-1a TransactionScopedX_ReleasedAtCommit / _AtRollback internal tests updated to check TableDataLock (phase 1b's table-data lock) instead of SchemaLock (which only carries Sch-S / Sch-M now). docs/claude/locking.md rewritten as the unified phases-0+1a+1b doc with the 8-mode matrix, the granularity-dispatch tables, the row-lock storage / escalation / acquisition-site documentation, and a phase-1b gaps section calling out key-range locks (table-S approximation), page-level (PAGLOCK no-op), hint-conflict detection (Msg 1047 / 1065 / 1069), sys.dm_tran_locks (phase 2), @@LOCK_TIMEOUT scalar (phase 2), SNAPSHOT / RCSI (phase 3), alias-form UPDATE / DELETE row-X (deferred), ALTER PROCEDURE / ALTER TRIGGER / ALTER SEQUENCE Sch-M wiring (deferred), row-lock cleanup on DELETE (intentional leak matching heap slot-leak pattern), history-table / cascade-FK / OUTPUT-INTO row-X (deferred). CLAUDE.md transactions bullet rewritten to describe phase-1b row-level RC + isolation-level semantic effect; Feature-reference entry expanded with all the new lock APIs; "Not modeled" rewritten — the "stronger than RC" quirk is gone, phase 1b ships substantially more than phase 1a; SimulatedDbConnection session-state listing adds SessionIsolationLevel.
1 parent 37f5bfe commit 730b2e3

26 files changed

Lines changed: 1340 additions & 271 deletions

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

SqlServerSimulator.Tests.Internal/LockResourceTests.cs

Lines changed: 135 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,18 +204,21 @@ public void CycleDetection_DetectorPicksRequester()
204204
[TestMethod]
205205
public void TransactionScopedX_ReleasedAtCommit()
206206
{
207-
// Once the simulator runs a tx-scoped X acquire (via
208-
// BatchContext.AcquireTransactionLock), Commit releases every
209-
// entry in the tx's HeldLocks list.
207+
// Once the simulator runs a tx-scoped IX-then-row-X acquire (via
208+
// BatchContext.AcquireTransactionLock + AcquireRowLockTxScoped),
209+
// Commit releases every entry in the tx's HeldLocks list — both
210+
// the table-IX and every row-X.
210211
var sim = new Simulation();
211212
ExecuteNonQuery(sim, "create table t (id int)");
212213
using var conn = sim.CreateDbConnection();
213214
conn.Open();
214-
var schemaLock = ((SimulatedDbConnection)conn).CurrentDatabase.Schemas["dbo"].HeapTables["t"].SchemaLock;
215+
var table = ((SimulatedDbConnection)conn).CurrentDatabase.Schemas["dbo"].HeapTables["t"];
215216
ExecuteNonQuery(conn, "begin tran; insert t values (1)");
216-
IsNotEmpty(schemaLock.Holders);
217+
IsNotEmpty(table.TableDataLock.Holders);
217218
ExecuteNonQuery(conn, "commit tran");
218-
IsEmpty(schemaLock.Holders);
219+
IsEmpty(table.TableDataLock.Holders);
220+
foreach (var (_, resource) in table.RowLocks)
221+
IsEmpty(resource.Holders);
219222
}
220223

221224
[TestMethod]
@@ -225,9 +228,11 @@ public void TransactionScopedX_ReleasedAtRollback()
225228
ExecuteNonQuery(sim, "create table t (id int)");
226229
using var conn = sim.CreateDbConnection();
227230
conn.Open();
228-
var schemaLock = ((SimulatedDbConnection)conn).CurrentDatabase.Schemas["dbo"].HeapTables["t"].SchemaLock;
231+
var table = ((SimulatedDbConnection)conn).CurrentDatabase.Schemas["dbo"].HeapTables["t"];
229232
ExecuteNonQuery(conn, "begin tran; insert t values (1); rollback tran");
230-
IsEmpty(schemaLock.Holders);
233+
IsEmpty(table.TableDataLock.Holders);
234+
foreach (var (_, resource) in table.RowLocks)
235+
IsEmpty(resource.Holders);
231236
}
232237

233238
private static void ExecuteNonQuery(Simulation sim, string sql)
@@ -285,4 +290,126 @@ public void SetLockTimeout_Zero_UpdatesConnectionState()
285290
_ = cmd.ExecuteNonQuery();
286291
AreEqual(0, ((SimulatedDbConnection)conn).LockTimeoutMillis);
287292
}
293+
294+
[TestMethod]
295+
public void U_CompatibleWith_S_IS()
296+
{
297+
// U × S: compatible (read-with-intent-to-upgrade coexists with
298+
// plain readers). U × IS: compatible (the IS holder might be
299+
// a different child of the same parent).
300+
var sim = new Simulation();
301+
var resource = new LockResource();
302+
var a = (SimulatedDbConnection)sim.CreateDbConnection();
303+
var b = (SimulatedDbConnection)sim.CreateDbConnection();
304+
sim.LockManager.Acquire(resource, LockMode.Shared, a, 0);
305+
sim.LockManager.Acquire(resource, LockMode.Update, b, 0);
306+
sim.LockManager.Release(resource, LockMode.Update, b);
307+
sim.LockManager.Release(resource, LockMode.Shared, a);
308+
sim.LockManager.Acquire(resource, LockMode.IntentShared, a, 0);
309+
sim.LockManager.Acquire(resource, LockMode.Update, b, 0);
310+
sim.LockManager.Release(resource, LockMode.Update, b);
311+
sim.LockManager.Release(resource, LockMode.IntentShared, a);
312+
}
313+
314+
[TestMethod]
315+
public void U_ConflictsWith_U_X_IX_SIX()
316+
{
317+
var sim = new Simulation();
318+
var a = (SimulatedDbConnection)sim.CreateDbConnection();
319+
var b = (SimulatedDbConnection)sim.CreateDbConnection();
320+
a.CurrentExecutingThreadId = -1;
321+
foreach (var conflict in new[] { LockMode.Update, LockMode.Exclusive, LockMode.IntentExclusive, LockMode.SharedIntentExclusive })
322+
{
323+
var resource = new LockResource();
324+
sim.LockManager.Acquire(resource, LockMode.Update, a, 0);
325+
_ = Throws<DbException>(() => sim.LockManager.Acquire(resource, conflict, b, 0));
326+
sim.LockManager.Release(resource, LockMode.Update, a);
327+
}
328+
}
329+
330+
[TestMethod]
331+
public void IS_CompatibleWithEverythingExceptX()
332+
{
333+
var sim = new Simulation();
334+
var a = (SimulatedDbConnection)sim.CreateDbConnection();
335+
var b = (SimulatedDbConnection)sim.CreateDbConnection();
336+
a.CurrentExecutingThreadId = -1;
337+
foreach (var ok in new[] { LockMode.IntentShared, LockMode.IntentExclusive, LockMode.SharedIntentExclusive, LockMode.Shared, LockMode.Update })
338+
{
339+
var resource = new LockResource();
340+
sim.LockManager.Acquire(resource, LockMode.IntentShared, a, 0);
341+
sim.LockManager.Acquire(resource, ok, b, 0);
342+
sim.LockManager.Release(resource, ok, b);
343+
sim.LockManager.Release(resource, LockMode.IntentShared, a);
344+
}
345+
// IS × X: conflict.
346+
var rx = new LockResource();
347+
sim.LockManager.Acquire(rx, LockMode.IntentShared, a, 0);
348+
_ = Throws<DbException>(() => sim.LockManager.Acquire(rx, LockMode.Exclusive, b, 0));
349+
sim.LockManager.Release(rx, LockMode.IntentShared, a);
350+
}
351+
352+
[TestMethod]
353+
public void IX_ConflictsWith_S_U_SIX_X()
354+
{
355+
var sim = new Simulation();
356+
var a = (SimulatedDbConnection)sim.CreateDbConnection();
357+
var b = (SimulatedDbConnection)sim.CreateDbConnection();
358+
a.CurrentExecutingThreadId = -1;
359+
foreach (var conflict in new[] { LockMode.Shared, LockMode.Update, LockMode.SharedIntentExclusive, LockMode.Exclusive })
360+
{
361+
var resource = new LockResource();
362+
sim.LockManager.Acquire(resource, LockMode.IntentExclusive, a, 0);
363+
_ = Throws<DbException>(() => sim.LockManager.Acquire(resource, conflict, b, 0));
364+
sim.LockManager.Release(resource, LockMode.IntentExclusive, a);
365+
}
366+
}
367+
368+
[TestMethod]
369+
public void SIX_OnlyCompatibleWith_IS()
370+
{
371+
var sim = new Simulation();
372+
var a = (SimulatedDbConnection)sim.CreateDbConnection();
373+
var b = (SimulatedDbConnection)sim.CreateDbConnection();
374+
a.CurrentExecutingThreadId = -1;
375+
// SIX × IS: compatible.
376+
var r1 = new LockResource();
377+
sim.LockManager.Acquire(r1, LockMode.SharedIntentExclusive, a, 0);
378+
sim.LockManager.Acquire(r1, LockMode.IntentShared, b, 0);
379+
sim.LockManager.Release(r1, LockMode.IntentShared, b);
380+
sim.LockManager.Release(r1, LockMode.SharedIntentExclusive, a);
381+
// SIX × everything else: conflict.
382+
foreach (var conflict in new[] { LockMode.IntentExclusive, LockMode.SharedIntentExclusive, LockMode.Shared, LockMode.Update, LockMode.Exclusive })
383+
{
384+
var resource = new LockResource();
385+
sim.LockManager.Acquire(resource, LockMode.SharedIntentExclusive, a, 0);
386+
_ = Throws<DbException>(() => sim.LockManager.Acquire(resource, conflict, b, 0));
387+
sim.LockManager.Release(resource, LockMode.SharedIntentExclusive, a);
388+
}
389+
}
390+
391+
[TestMethod]
392+
public void RowLockEscalation_Above5000_PromotesToTableX()
393+
{
394+
// Insert >5000 rows in one transaction; the per-row X acquisitions
395+
// bump the per-tx per-table count past the threshold, triggering
396+
// promotion to a single table-X. After commit, the table-X is
397+
// released and the per-row dict entries are no longer tracked
398+
// against any tx.
399+
var sim = new Simulation();
400+
ExecuteNonQuery(sim, "create table t (id int)");
401+
using var conn = sim.CreateDbConnection();
402+
conn.Open();
403+
var table = ((SimulatedDbConnection)conn).CurrentDatabase.Schemas["dbo"].HeapTables["t"];
404+
ExecuteNonQuery(conn, "begin tran");
405+
for (var i = 0; i < SimulatedDbTransaction.RowLockEscalationThreshold + 2; i++)
406+
ExecuteNonQuery(conn, $"insert t values ({i})");
407+
// After escalation, the active transaction holds table-X on this
408+
// table; row-lock count is zeroed.
409+
var tx = ((SimulatedDbConnection)conn).CurrentTransaction;
410+
IsNotNull(tx);
411+
Contains(table, tx.EscalatedTables);
412+
ExecuteNonQuery(conn, "commit tran");
413+
IsEmpty(table.TableDataLock.Holders);
414+
}
288415
}

SqlServerSimulator.Tests.Internal/Storage/HeapTests.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public void NewHeap_HasNoPagesUntilFirstInsert()
2323
public void Insert_SingleRow_AllocatesFirstPage()
2424
{
2525
var heap = new Heap();
26-
heap.Insert([1, 2, 3]);
26+
_ = heap.Insert([1, 2, 3]);
2727
HasCount(1, heap.Pages);
2828
AreEqual(1, heap.RowCount);
2929
}
@@ -33,8 +33,8 @@ public void Insert_RowsExceedingPageCapacity_AllocatesSecondPageAndLinks()
3333
{
3434
var heap = new Heap();
3535
var big = new byte[Heap.MaxRowSize];
36-
heap.Insert(big); // page 0 holds one max-sized row
37-
heap.Insert(big); // forces a new page
36+
_ = heap.Insert(big); // page 0 holds one max-sized row
37+
_ = heap.Insert(big); // forces a new page
3838

3939
HasCount(2, heap.Pages);
4040
AreEqual(1, heap.Pages[0].NextPageIndex);
@@ -57,7 +57,7 @@ public void EnumerateRows_WalksAllPagesInOrder()
5757
var row = new byte[512];
5858
row[0] = (byte)i;
5959
rowsInserted.Add(row);
60-
heap.Insert(row);
60+
_ = heap.Insert(row);
6161
}
6262

6363
IsGreaterThanOrEqualTo(2, heap.Pages.Count, "Expected the heap to span at least two pages.");
@@ -84,7 +84,7 @@ public void Insert_RowAtMaxRowSize_Succeeds()
8484
{
8585
var heap = new Heap();
8686
var row = new byte[Heap.MaxRowSize];
87-
heap.Insert(row);
87+
_ = heap.Insert(row);
8888
AreEqual(1, heap.RowCount);
8989
}
9090

@@ -94,9 +94,9 @@ public void RowCount_AggregatesAcrossPages()
9494
var heap = new Heap();
9595
var halfish = new byte[HeapPage.MaxRowPayload / 3];
9696

97-
heap.Insert(halfish); // fits in page 0
98-
heap.Insert(halfish); // fits in page 0
99-
heap.Insert(halfish); // page 0 may or may not fit a third copy; either way row count is correct
97+
_ = heap.Insert(halfish); // fits in page 0
98+
_ = heap.Insert(halfish); // fits in page 0
99+
_ = heap.Insert(halfish); // page 0 may or may not fit a third copy; either way row count is correct
100100

101101
AreEqual(3, heap.RowCount);
102102

0 commit comments

Comments
 (0)