Skip to content

Commit 37f5bfe

Browse files
committed
Locking phase 1a — table-level Shared / Exclusive data locks, transaction-scoped X retention, WITH (NOLOCK) / WITH (HOLDLOCK) hint semantics, cross-thread waiter-graph cycle detection (Msg 1205), and auto-rollback of the deadlock victim's transaction. LockResource refactored from "encapsulates its own gate + Acquire/Release logic" into a passive data carrier (just the Holders list); the logic moves into a new LockManager class held at Simulation.LockManager with a single per-Simulation gate. The single-gate model is the load-bearing simplification that makes cross-resource cycle detection straightforward — every connection's WaitingOnResource is readable consistently under the same lock, no inter-gate ordering issues. LockMode extended with Shared and Exclusive. Compatibility matrix in LockManager.IsCompatible makes the schema family (Sch-S / Sch-M) and the data family (S / X) orthogonal — Sch-S × S / X compatible; Sch-M conflicts with everything; within the data family only S × S is compatible. Same-owner re-entrance always succeeds (handles the ALTER pattern's Sch-S then Sch-M from the same connection plus DML-target double-resolution). BatchContext.AcquireTransactionLock is the new tx-scoped acquire — when CurrentTransaction is non-null the (resource, mode) tuple lands in SimulatedDbTransaction.HeldLocks; otherwise it falls back to the statement-scoped list. BatchContext.AcquireDataLockIfApplicable(table, hints, isWrite) is the central routing helper called from FROM-source resolution in Selection.cs, MERGE bare-table source in Simulation.Merge.cs, and INSERT / UPDATE / DELETE / MERGE target sites; bypasses for table variables / local temp tables / system tables (none cross-connection contention points). Reads acquire Shared (skipped on NOLOCK / READUNCOMMITTED; promoted to tx-scoped on HOLDLOCK / SERIALIZABLE / REPEATABLEREAD); writes always acquire Exclusive, tx-scoped when BEGIN TRAN is active and released at Commit / Rollback / dispose-implicit-rollback in SimulatedDbTransaction.ReleaseAllLocks. Savepoint partial-rollback (ROLLBACK TRAN <savepoint>) intentionally does NOT release locks, matching real SQL Server (probe-confirmed via the EF SaveChanges path on 2026-05-14). Selection.ParseOptionalTableHints now returns a TableHintInfo (NoLock / HoldLock flags) instead of being void; every callsite either captures the value for downstream lock-routing or discards via _ =. Cross-thread cycle detection: LockManager.WouldCreateCycle walks the wait-for graph from each conflicting holder (SimulatedDbConnection.WaitingOnResource, set under the gate before Monitor.Wait and cleared in finally), DFS with a visited set to break finite cycles in the holder set itself; on cycle the caller is the victim per the always-the-requester policy. The same-thread-deadlock short-circuit from phase 0 remains as a fast path for the "two connections on one thread, holder's command is suspended at the iterator boundary" scenario. Simulation.DispatchOneStatement's catch now intercepts Class-13 exceptions (Msg 1205) and rolls back connection.CurrentTransaction BEFORE the TRY-frame check — both the propagating and TRY-captured paths observe the auto-rollback, matching real SQL Server's @@TRANCOUNT = 0 in CATCH (probe step 3, 2026-05-14). The rollback releases every entry in HeldLocks so the survivor's blocked acquire unblocks naturally on the next pulse. **Practical effect**: phase-1a is *stronger* than real SQL Server's READ COMMITTED — a SELECT blocks on any X-locked row in the table, not just the row being read, because granularity is table-only; phase 1b's row-level dispatch fixes this. 3 new internal LockResourceTests (S × X conflict, transaction-scoped X release at commit / rollback) plus 5 new user-facing LockingTests: cross-thread tx-X blocks concurrent read until commit; LOCK_TIMEOUT 0 → fail-fast Msg 1222; NOLOCK reads through uncommitted X (dirty read); HOLDLOCK keeps S until commit (blocks concurrent write); classic 2-cycle cross-thread deadlock → Msg 1205 on one connection (requester), survivor's UPDATE completes after victim's tx auto-rolls-back. docs/claude/locking.md rewritten as a unified phases-0+1a doc with the new compatibility matrix, the lock-owner / lock-scope table, the cycle-detection algorithm, the hint surface table (modeled vs parse-and-discard), and a phase-1a gaps section: row / page / key locks defer to 1b along with the alias-form UPDATE/DELETE FROM-clause target X acquire; READPAST / UPDLOCK / XLOCK / TABLOCK* / PAGLOCK / ROWLOCK still parse-and-discard; sys.dm_tran_locks views + @@LOCK_TIMEOUT scalar are phase 2; SNAPSHOT / RCSI is phase 3; Heap.Insert cross-table cross-thread mutation isn't yet thread-safe (same-table writes serialize via X naturally, cross-table relies on phase 1b). CLAUDE.md "Not modeled" rewritten to scope to phases 1b+ and call out the table-only-granularity stronger-than-RC quirk; Feature-reference entry expanded with the data-lock APIs and the cycle-detection / auto-rollback surfaces; the "No isolation yet" line replaced by a phase-1a-accurate description of table-level RC.
1 parent 2c3a8af commit 37f5bfe

15 files changed

Lines changed: 985 additions & 423 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Three entry points share one per-connection undo log: implicit (statement-level
114114
- `@@TRANCOUNT` reads connection depth as int.
115115
- **Identity counters and the database-scoped rowversion counter bypass the log** — both keep advancing through rollback. Orphaned LOB chains for rolled-back inserts also leak.
116116
- **Temp-table CREATE/DROP participates in the log** via `TempTableCreation` / `TempTableRemoval` `UndoEntry` subtypes (rollback removes from / restores into the connection's `TempTables` dict). Regular CREATE/DROP TABLE is NOT logged — see the corresponding quirk.
117-
- No isolation yet: uncommitted writes immediately visible to all readers. Multi-connection use is now safe at the schema-stability layer (Sch-S / Sch-M locks via `LockResource` serialize DDL behind concurrent readers / writers — see [`docs/claude/locking.md`](docs/claude/locking.md)); data locks (row-level S / X / U for isolation) land in phase 1a.
117+
- Table-level READ COMMITTED isolation via Shared / Exclusive data locks: a concurrent reader of an X-locked table blocks until the writer commits; concurrent INSERT/UPDATE/DELETE serialize behind the writer's tx-scoped X. `WITH (NOLOCK)` / `READUNCOMMITTED` bypasses to dirty-read; `WITH (HOLDLOCK)` extends S to tx-scope. Phase 1a's table-only granularity is *stronger* than real RC (a read blocks on any X-locked row in the table, not just the row being read); phase 1b's row-level dispatch will fix this. Cross-thread deadlocks detect via wait-for-graph cycle walk (Msg 1205, victim's tx auto-rolled-back). See [`docs/claude/locking.md`](docs/claude/locking.md).
118118

119119
### MERGE / OUTPUT
120120
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
@@ -154,12 +154,12 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
154154
- **Touching `ALTER TABLE … ADD CONSTRAINT` (PK / UQ / FK / CHECK / DEFAULT), `ALTER TABLE … DROP CONSTRAINT [IF EXISTS]`, `ALTER TABLE … (CHECK | NOCHECK) CONSTRAINT (ALL | name [,…])` trust toggling, `ALTER TABLE … ADD [COLUMN]` / `DROP COLUMN` / `ALTER COLUMN`, `WITH CHECK` / `WITH NOCHECK`, the existing-data-validation scan, the constraint-name uniqueness check, the atomic multi-drop / multi-toggle pipeline, the `IsDisabled` / `IsNotTrusted` enforcement gates, the type-change conversion (`SqlValue.CoerceTo` surfacing Msg 220 / 245 / 241 / 8115 / 2628), the nullability-flip Msg 515 scan, the Msg 4928 COMPUTED / rowversion rejection, the Msg 5074 ALTER COLUMN blocker walker (PK/UQ/FK/computed-deps unconditional; index when SqlType-subclass changes), or `sys.check_constraints` / `sys.key_constraints` / `sys.default_constraints`**[`docs/claude/alter-table.md`](docs/claude/alter-table.md).
155155
- **Touching `CREATE [UNIQUE] [CLUSTERED | NONCLUSTERED] INDEX` (with `INCLUDE` / `WHERE` filter / `WITH (options)` clauses), `DROP INDEX [IF EXISTS] name ON table [, …]`, the `HeapTable.Indexes` list, UNIQUE-index enforcement (filter-aware Msg 2601 via `EnforceUniqueIndexes` / `EnforceUniqueIndexesForUpdate`), the PK-or-UQ-name DROP rejection (Msg 3723), or `sys.indexes` / `sys.index_columns`**[`docs/claude/indexes.md`](docs/claude/indexes.md).
156156
- **Touching table hints (`WITH (NOLOCK [, …])` on FROM sources / JOIN-RHS / UPDATE / DELETE targets, the legacy `(hint)` without-WITH form, `Msg 321` unknown-hint rejection), statement-level `OPTION (…)` hints (`RECOMPILE` / `MAXDOP` / `FAST` / `LOOP|HASH|MERGE JOIN` / `FORCE ORDER` / `OPTIMIZE FOR (UNKNOWN)` / `USE HINT(…)` / `MAXRECURSION` with its CTE-binding override), the `TableHintNames` / `OptionHintFirstWords` closed accept-lists, or `SkipBalancedParens` argument consumption**[`docs/claude/query-hints.md`](docs/claude/query-hints.md).
157-
- **Touching the `LockResource` primitive, Sch-S / Sch-M acquisition (per-statement Sch-S via `BatchContext.AcquireStatementLock` at every `TryResolve*` success, per-statement Sch-M at DDL sites), `SimulatedDbConnection.Spid` / `LockTimeoutMillis` / `CurrentExecutingThreadId`, the same-thread-deadlock check (Msg 1205) and lock-timeout path (Msg 1222), or `SET LOCK_TIMEOUT`'s semantic wiring**[`docs/claude/locking.md`](docs/claude/locking.md).
157+
- **Touching the `LockResource` / `LockManager` pair, Sch-S / Sch-M / Shared / Exclusive acquisition (per-statement Sch-S via `BatchContext.AcquireStatementLock` at every `TryResolve*` success, per-statement Sch-M at DDL sites, S / X / HOLDLOCK-S routing via `BatchContext.AcquireDataLockIfApplicable` at FROM-source and DML-target sites, X held to COMMIT / ROLLBACK via `SimulatedDbTransaction.HeldLocks`), `SimulatedDbConnection.Spid` / `LockTimeoutMillis` / `CurrentExecutingThreadId` / `WaitingOnResource`, the same-thread-deadlock check and cross-thread waiter-graph cycle detection (Msg 1205, auto-rollback of victim's tx), lock-timeout path (Msg 1222), `SET LOCK_TIMEOUT`'s semantic wiring, or the `NOLOCK` / `HOLDLOCK` hint behavior**[`docs/claude/locking.md`](docs/claude/locking.md).
158158
- **Adding a new top-level statement parser or changing the dispatch loop's statement-separator rules**[`docs/claude/grammar.md`](docs/claude/grammar.md) + [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
159159

160160
## Not modeled
161161

162-
- **Data locks (S / X / U / IS / IX / SIX), MVCC / SNAPSHOT, isolation levels, lock hint behavior** — phases 1a / 1b / 2 / 3 of the locking rollout. Phase 0 ships **schema-stability locks (Sch-S / Sch-M)** only — every read of a schema-bound object acquires Sch-S for the duration of its statement; every DDL acquires Sch-M; `SET LOCK_TIMEOUT` now has semantic effect (Msg 1222 on expiry); same-thread conflict raises Msg 1205 immediately. See [`docs/claude/locking.md`](docs/claude/locking.md). `BEGIN DISTRIBUTED TRANSACTION` and `BEGIN TRANSACTION ... WITH MARK` raise `NotSupportedException` at dispatch. **Most `SET <option> …` statements are parsed-and-discarded** via a closed accept-list in `Simulation.Set.cs` — including `SET TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SNAPSHOT|SERIALIZABLE}`, `SET XACT_ABORT ON|OFF`, the ANSI / session-state toggles (ANSI_NULLS / QUOTED_IDENTIFIER / ANSI_WARNINGS / ANSI_PADDING / CONCAT_NULL_YIELDS_NULL / ARITHABORT / NUMERIC_ROUNDABORT / …) including the multi-option comma form EF Core emits, value-taking options (TEXTSIZE / DATEFIRST / ROWCOUNT / DATEFORMAT / LANGUAGE / DEADLOCK_PRIORITY / CONTEXT_INFO), and the `SET STATISTICS {IO|TIME|XML|PROFILE} ON|OFF` sub-form. Unknown SET option followed by ON/OFF/value → Msg 195 verbatim (`'<name>' is not a recognized SET option.`); no-trailing-token form → generic Msg 102 (probe-confirmed split). `SET @v = expr`, `SET IDENTITY_INSERT`, `SET NOCOUNT ON|OFF`, `SET IMPLICIT_TRANSACTIONS ON|OFF`, and `SET LOCK_TIMEOUT N` (drives schema-lock wait) have semantic effect; everything else in the accept-list parses-and-discards.
162+
- **Row / page / key locks, U / IS / IX / SIX modes, lock escalation, MVCC / SNAPSHOT, isolation-level-as-semantic-effect** — phases 1b / 2 / 3 of the locking rollout. **Phases 0 + 1a ship**: schema-stability locks (Sch-S / Sch-M) on every schema-bound object; table-level Shared / Exclusive data locks (S held statement-end by reads, X held until COMMIT / ROLLBACK by writes when inside a transaction); cross-thread waiter-graph cycle detection (Msg 1205); same-thread immediate-deadlock short-circuit; `SET LOCK_TIMEOUT N` driving Msg 1222 on expiry; `WITH (NOLOCK)` / `READUNCOMMITTED` skipping S (dirty reads); `WITH (HOLDLOCK)` / `SERIALIZABLE` / `REPEATABLEREAD` upgrading S to transaction-scoped retention; auto-rollback of the victim's tx on Msg 1205 (probe-confirmed `@@TRANCOUNT = 0` in CATCH). See [`docs/claude/locking.md`](docs/claude/locking.md). Practical effect: phase-1a's table-only granularity is *stronger* than real SQL Server's READ COMMITTED — a SELECT blocks on any X-locked row in the table, not just the row being read; phase 1b's row-level dispatch fixes this. `BEGIN DISTRIBUTED TRANSACTION` and `BEGIN TRANSACTION ... WITH MARK` raise `NotSupportedException` at dispatch. **Most `SET <option> …` statements are parsed-and-discarded** via a closed accept-list in `Simulation.Set.cs` — including `SET TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SNAPSHOT|SERIALIZABLE}`, `SET XACT_ABORT ON|OFF`, the ANSI / session-state toggles (ANSI_NULLS / QUOTED_IDENTIFIER / ANSI_WARNINGS / ANSI_PADDING / CONCAT_NULL_YIELDS_NULL / ARITHABORT / NUMERIC_ROUNDABORT / …) including the multi-option comma form EF Core emits, value-taking options (TEXTSIZE / DATEFIRST / ROWCOUNT / DATEFORMAT / LANGUAGE / DEADLOCK_PRIORITY / CONTEXT_INFO), and the `SET STATISTICS {IO|TIME|XML|PROFILE} ON|OFF` sub-form. Unknown SET option followed by ON/OFF/value → Msg 195 verbatim (`'<name>' is not a recognized SET option.`); no-trailing-token form → generic Msg 102 (probe-confirmed split). `SET @v = expr`, `SET IDENTITY_INSERT`, `SET NOCOUNT ON|OFF`, `SET IMPLICIT_TRANSACTIONS ON|OFF`, and `SET LOCK_TIMEOUT N` (drives all lock-acquire waits) have semantic effect; everything else in the accept-list parses-and-discards.
163163
- `RIGHT JOIN` / `FULL OUTER JOIN` with a derived-table or lateral right side — base tables / views / system tables on the right ship (see JOINs / APPLY); a derived-table right (always-deferred in this simulator) raises `NotSupportedException` since real SQL Server's Msg 4104 rejection of correlated subqueries on the right of RIGHT/FULL can't be statically distinguished from non-correlated ones.
164164
- Comma-separated FROM (legacy ANSI-89 join syntax).
165165
- `RANGE BETWEEN <N> PRECEDING` / `<N> FOLLOWING` — real SQL Server gates the numeric-offset RANGE form behind a separately-licensed feature surface and the simulator matches that rejection (Msg 4194). `ROWS` numeric-offset frames ship; both modes support the canonical `UNBOUNDED` / `CURRENT ROW` bounds and the single-bound shorthand (`ROWS UNBOUNDED PRECEDING``ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`). The default frame when ORDER BY is present in OVER is `RANGE UNBOUNDED PRECEDING TO CURRENT ROW` (running-total semantic with peer-tie grouping); without ORDER BY, default frame is whole partition. LAST_VALUE ships with the same semantics as real SQL Server (its default frame returns the current row's value or the peer-tie last under RANGE — the intuitive "partition last" form needs explicit `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`).

0 commit comments

Comments
 (0)