Skip to content

Commit 2c3a8af

Browse files
committed
Locking phase 0 — schema-stability locks (Sch-S / Sch-M) on every schema-bound object, plus the per-connection plumbing (SPID / @@LOCK_TIMEOUT / currently-executing-thread) the data-lock phases will build on. New LockResource primitive (one per SchemaObject via the inherited SchemaLock field) provides reader/writer locking with re-entrant per-owner counting, Monitor.Wait-based cross-thread blocking, and same-thread-deadlock detection: when a conflicting holder's CurrentExecutingThreadId matches the caller's managed thread, the wait short-circuits to Msg 1205 since the holder can't release until the caller does. Cross-thread waiter-graph cycle detection isn't part of phase 0 — Sch-S / Sch-M's per-statement scope means cycles can't form here; they emerge with data X locks held across transactions (phase 1a). Errors/SimulatedSqlException.ConcurrencyErrors.cs ships verbatim-from-probe Msg 1222 (Lock request time out period exceeded.) at Class 16 State 56 (schema-lock path) and Msg 1205 (Transaction (Process ID <N>) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.) at Class 13 State 45 with the victim's SPID embedded. SimulatedDbConnection gains Spid (allocated via Simulation.AllocateSpid(), first user = 51 per SQL Server convention), LockTimeoutMillis (default -1 / wait forever — probe-confirmed against SQL Server 2025), and CurrentExecutingThreadId (set/cleared in DispatchOneStatement's try/finally with save+restore for nested proc / trigger / UDF / MS-TVF body dispatches). BatchContext.StatementSchemaLocks is the per-statement list of (LockResource, LockMode) tuples; AcquireStatementLock appends, ReleaseStatementSchemaLocks walks in reverse and the dispatcher fires it in a finally so locks unwind on success / error / TRY-caught alike. Sch-S acquired by every successful BatchContext.TryResolve* path (table / view / function / procedure / table-type / sequence); temp tables / table variables / trigger INSERTED / DELETED pseudo-tables bypass (per-session or per-batch, no cross-connection contention). Sch-M acquired by every DDL site: DropOne{Table,View,Function,Procedure,Type,Sequence,Trigger} between TryGetValue and TryRemove, TRUNCATE TABLE after the lookup, and TryParseAlterTable once at dispatcher entry so every sub-parser (ADD / DROP / ALTER COLUMN / ADD CONSTRAINT / DROP CONSTRAINT / CHECK / NOCHECK / SET SYSTEM_VERSIONING) runs under exclusive schema modification (the sub-parsers' own TryResolveTable adds re-entrant Sch-S holds, harmless under same-owner Sch-M reentry). SET LOCK_TIMEOUT N promoted from parse-and-discard → drives connection.LockTimeoutMillis; the negative-literal form (SET LOCK_TIMEOUT -1) still falls through Msg 102 since the tokenizer splits - from 1 and the simulator's SET path uses a single-token check rather than Expression.Parse — the default is already -1 so this rarely matters in practice. 13 new internal LockResourceTests cover the primitive directly (acquire / release / re-entrance, Sch-S × Sch-S compatible, Sch-M-blocks-everything, timeout zero / positive / cross-thread grant-after-release, same-thread immediate Msg 1205, SPID allocation, SET LOCK_TIMEOUT propagation) plus 5 user-facing LockingTests (single-connection SET acceptance for positive / zero, two-connection sequential DDL/DML, two-thread concurrent inserts into separate tables, two-thread DROP race on the same table — exactly-one Msg 3701). New docs/claude/locking.md covers acquisition sites, same-thread-deadlock model, and the phase-0 gaps list (data locks / hint behavior / isolation levels / MVCC are phases 1a–3; ALTER PROCEDURE / TRIGGER / SEQUENCE / SCHEMA TRANSFER Sch-M wiring deferred; Heap.Insert thread-safety relies on phase 1a's S/X serialization; @@LOCK_TIMEOUT scalar not yet wired). CLAUDE.md "Not modeled" rewritten to scope the locking gap to phases 1a+, session-state listing on SimulatedDbConnection expanded with the three new fields, Feature reference gains the locking.md trigger.
1 parent 68a0535 commit 2c3a8af

14 files changed

Lines changed: 1052 additions & 29 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Five scopes, one home each. **Add new state to whichever class matches its true
6262
- **`Simulation`** = server / instance. Process-shared system tables (`SystemHeapTables`), `NEWSEQUENTIALID` anchor + counter, the `Databases` dictionary. Public surface (`Simulation` ctor + `CreateDbConnection()`) stays on this class.
6363
- **`Database`** (internal) = one database hosted by the server instance. `Schemas` (named-schema dict, pre-seeded with `dbo`), `CompatibilityLevel`, `VerboseTruncationWarnings`, `rowVersionCounter` (per-DB `@@DBTS`). Every `Simulation` ships with one entry named `Simulation.DefaultDatabaseName` (`"simulated"`); a future `USE <db>` adds entries to the dictionary.
6464
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`), `Views` (per-schema view dict), `Procedures` (per-schema stored-procedure dict), `TableTypes` (per-schema user-defined-table-type dict — `CREATE TYPE … AS TABLE` populates; separate namespace from the object dicts, see `docs/claude/table-valued-parameters.md`), `Sequences` (per-schema sequence-object dict — `CREATE SEQUENCE` populates, shares the object namespace with tables/views/funcs/procs, see `docs/claude/sequences.md`), `Triggers` (per-schema DML-trigger dict — `CREATE TRIGGER` populates; shares the object namespace, see `docs/claude/triggers.md`). FOREIGN KEY constraints live on `HeapTable.OutgoingForeignKeys` (child side) and `HeapTable.IncomingForeignKeys` (parent side) — see `docs/claude/foreign-keys.md`. Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`, `FROM audit.view1`, `EXEC audit.proc1`, `DECLARE @t audit.MyType`, `NEXT VALUE FOR audit.seq`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
65-
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `LastErrorNumber` (`@@ERROR`), `NestingLevel` (UDF / proc / trigger / view recursion depth, capped at 32), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()`, `TempTables` (per-session `#foo` dictionary, cleared on `Dispose`).
65+
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `LastErrorNumber` (`@@ERROR`), `NestingLevel` (UDF / proc / trigger / view recursion depth, capped at 32), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()`, `TempTables` (per-session `#foo` dictionary, cleared on `Dispose`), `Spid` (allocated once at construction via `Simulation.AllocateSpid()`, starts at 51), `LockTimeoutMillis` (`@@LOCK_TIMEOUT` — default -1, updated by `SET LOCK_TIMEOUT N`), `CurrentExecutingThreadId` (managed-thread-id while a statement is dispatching, drives same-thread-deadlock detection on `LockResource.Acquire`).
6666
- **`BatchContext`** (internal, in `Parser/`) = one command execution. Owns the `ParserContext` (parse-time-only scratch — `Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`, `InDefaultClause`, `AllowsWindowExpressions`) and holds batch-lifetime runtime state: `Variables`, `TableVariables` (per-batch `DECLARE @t TABLE`–backed `HeapTable` dict, shared namespace with scalar `Variables` for the Msg-134 uniqueness check), `CurrentUndoLog` (per-tx/per-statement, regular tables), `CurrentTableVarUndoLog` (per-statement-only, `@t` writes — kept disjoint from the tx-scoped log so `ROLLBACK TRAN` skips `@t` while statement-atomic rollback covers it), `UdfFrame` (non-null when this batch is a scalar-UDF body being dispatched — gates value-form `RETURN` and lands the return value for the caller), `ProcFrame` (non-null when this batch is a stored-procedure body — same gate for value-form `RETURN`, plus a return-code slot the caller reads), plus the per-statement frame `CurrentStatement`. Exposes `TryResolveTable(MultiPartName)` — the routing rule that dispatches `#foo` leaves to `Connection.TempTables` regardless of qualifier and `@t` leaves to `TableVariables` (1-part only — `dbo.@t` returns false; the caller raises Msg 102 at parse via `acceptTableVariable` gating); everything else routes through the named schema (or `dbo` for an unqualified reference), with `SystemHeapTables` reachable only as a flat 1-part fallback. `TryResolveFunction(MultiPartName)` resolves 2-/3-part dotted names against the named schema's `Functions` dict; 1-part names return false (real SQL Server rejects bare UDF calls with Msg 195). `TryResolveProcedure(MultiPartName)` resolves through the same schema-lookup path but accepts 1-part names (probe-confirmed: `EXEC p1` finds `dbo.p1`). `TryResolveTableType(MultiPartName)` resolves user-defined table types against the named schema's `TableTypes` dict and falls back to `dbo` for 1-part names (DECLARE @t MyType / TVP-parameter-type lookup). `TryResolveSchema(MultiPartName)` exposes the dict-bearing schema for CREATE / DROP / TRUNCATE / SELECT INTO. `ParseObjectName(ParserContext, bool acceptTableVariable = false)` parses the 1–4-segment dotted form, leaves cursor on the last name segment (standard parser contract), and compresses empty middle segments (so `tempdb..#foo` returns a 2-part name); the `acceptTableVariable` opt-in routes `@t` to a 1-part-with-`@`-prefix leaf for DML / FROM-source sites, and rejects `@t` everywhere else (so ALTER TABLE / DROP TABLE / TRUNCATE / CREATE / SELECT INTO surface Msg 102 matching probe). Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch`. Scalar-UDF and procedure invocation each allocate a child `BatchContext` via the corresponding body constructor: parameters pre-seed `Variables`, the matching frame is set, the body source text (captured at CREATE FUNCTION / CREATE PROCEDURE time) is re-tokenized through a synthesized `SimulatedDbCommand`, and the same dispatch loop runs the body. UDF bodies discard yielded result sets at the call site (Msg 444 territory); procedure bodies forward them through to the outer caller's iterator.
6767
- **`StatementContext`** (internal, in `Parser/`) = the dispatch loop's per-statement frame. Allocated once per batch and overwritten in place at the top of each iteration; holds `UtcNow` (the per-statement-freeze the time scalars read). Stored-proc EXEC / TRY-CATCH frames slot in here when added.
6868

@@ -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: uncommitted writes immediately visible to all readers (single-Simulation, single-thread-at-a-time).
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.
118118

119119
### MERGE / OUTPUT
120120
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
@@ -154,11 +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).
157158
- **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).
158159

159160
## Not modeled
160161

161-
- Locks / MVCC / isolation levels (single-Simulation, single-thread-at-a-time). `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 (LOCK_TIMEOUT / 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). Only `SET @v = expr`, `SET IDENTITY_INSERT`, `SET NOCOUNT ON|OFF`, and `SET IMPLICIT_TRANSACTIONS ON|OFF` have semantic effect; the closed accept-list now subsumes the last two for parse purposes, and the first two retain their dedicated semantic handlers.
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.
162163
- `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.
163164
- Comma-separated FROM (legacy ANSI-89 join syntax).
164165
- `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)