Skip to content

Commit e2b1ca1

Browse files
committed
Locking phase 3 follow-up — close the SI-visible DELETE gap. Phase 3 shipped SNAPSHOT + RCSI but the documented limitation (SI readers can't see committed-deleted rows their snapshot pre-dates; SI writers can't raise Msg 3960 on RC-deleted target rows) needed a tombstoned-slot iteration pass. Both ship now. **Reader-side**: BatchContext.WrapWithRowConflictChecks gets a second pass under snapshot — after the live-heap iteration, walks the per-table RowVersions dict for entries whose live slot is tombstoned (HeapTable.Heap.IsSlotTombstoned) and yields the historical payload via the new VersionStore.ResolveTombstonedSlotForSnapshot(chain, snapshotXid, readerTx). The new helper handles two cases: in-flight foreign delete (chain.WriterTx is the deleting tx, my snapshot needs the pre-write payload from chain history) and committed delete (chain.IsDeletedLive with LiveXmin > my snapshot, history walked the same way). New Heap.IsSlotTombstoned / HeapPage.IsSlotTombstoned expose the per-slot tombstone bit so the live-heap-iteration filter doesn't double-yield. **Writer-side**: new Simulation.CheckSnapshotConflictOnTombstonedRows(context, table, where, sourceView) private partial-class helper called at the top of UPDATE's and DELETE's ExecuteUpdateAgainstTable / ExecuteDeleteAgainstTable paths (after their respective WHERE-collection loops, before the mutation loop). Under SNAPSHOT iso with an allocated tx.SnapshotXid, walks the version chain dict for tombstoned slots; decodes each snapshot-visible historical payload via DecodeFullRow + EvaluateComputedColumns; builds the same ResolveOriginal closure shape as the regular iteration (full view-column-mapping support); evaluates WHERE; raises Msg 3960 verbatim + auto-rollback if WHERE matches a tombstoned-but-visible row. The DELETE call site shares the helper through the partial-class scope (one definition in Simulation.Update.cs, called from Simulation.Delete.cs). Probe-confirmed against SQL Server 2025 (2026-05-14): SI reader sees v=200 for an RC-deleted row when snapshot pre-dates the delete; SI UPDATE / DELETE on the same row raise Msg 3960 with @@TRANCOUNT = 0 post-throw (auto-rollback). 3 new SnapshotIsolationTests: SI reader sees deleted row's pre-delete payload + correct count post-DELETE; SI UPDATE on RC-deleted row → Msg 3960 + auto-rollback; SI DELETE on RC-deleted row → Msg 3960. Total snapshot test count now 17 (was 14). docs/claude/locking.md "Known phase-3 limitations" section replaces the two SI-DELETE bullets with a new "Tombstoned-slot snapshot pass" section documenting the wiring; CLAUDE.md Not modeled opener tightens to "Key-range locks, ALTER SCHEMA TRANSFER Sch-M, row-lock cleanup on DELETE" (the SI-visible-DELETE bullet is gone) and the phase-3 paragraph adds the tombstoned-slot pass description. Remaining phase-3 limitations are now just three small ones: multi-update-within-one-tx history collapse, version-store garbage collection, and the sys.dm_tran_version_store DMV — none affecting standard application workloads.
1 parent 97f5a62 commit e2b1ca1

9 files changed

Lines changed: 224 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
159159

160160
## Not modeled
161161

162-
- **Key-range locks, `ALTER SCHEMA TRANSFER` Sch-M, row-lock cleanup on DELETE, SI-visible DELETE** — phase 4+ deferrals (phase 3 ships SNAPSHOT + RCSI for INSERT/UPDATE). **Phases 0 + 1a + 1b + 2 + 3 ship**: schema-stability locks (Sch-S / Sch-M) including ALTER PROCEDURE / TRIGGER / SEQUENCE; the full 8-mode data + intent matrix (Sch-S / Sch-M / IS / IX / SIX / S / U / X); row-level X on writers and probe-only / row-S / row-U / row-X on readers depending on hints + isolation level; row-X on history-table / cascade-FK / OUTPUT-INTO / SELECT-INTO destination writes; alias-form `UPDATE x SET … FROM t x JOIN …` table-IX acquired after FROM-target identification; SERIALIZABLE / HOLDLOCK hint upgrades read to table-S tx-scoped (phantom prevention at table granularity since real SQL Server's key-range locks require index range structure the simulator doesn't have); REPEATABLE READ / REPEATABLEREAD hint retains row-S tx-scoped; UPDLOCK retains row-U tx-scoped; XLOCK retains row-X tx-scoped on reads; READPAST skips conflicting rows; TABLOCK / TABLOCKX escalate to table-S / table-X; NOLOCK / READUNCOMMITTED dirty-read; lock escalation at 5000 per-tx per-table row-locks → table-X; cross-thread waiter-graph cycle detection (Msg 1205); same-thread immediate-deadlock short-circuit; `SET LOCK_TIMEOUT N` driving Msg 1222 on expiry; `SET TRANSACTION ISOLATION LEVEL` mutating session state with semantic effect; auto-rollback of the victim's tx on Msg 1205 (probe-confirmed `@@TRANCOUNT = 0` in CATCH); `@@LOCK_TIMEOUT` / `@@SPID` scalar functions; hint-conflict detection (Msg 1047 `NOLOCK + XLOCK/UPDLOCK/HOLDLOCK/…` verbatim, Msg 1065 `NOLOCK on DML target` verbatim, Msg 1069 `INDEX hint on DML target` verbatim); `sys.dm_tran_locks` + `sys.dm_os_waiting_tasks` DMVs projecting GRANT / WAIT rows over the live lock-manager state; **SNAPSHOT isolation + READ_COMMITTED_SNAPSHOT (phase 3)** via per-table `HeapTable.RowVersions` version chains + monotonic per-database `Database.AllocateTransactionCommitId` Xid allocator + `ALTER DATABASE … SET (ALLOW_SNAPSHOT_ISOLATION | READ_COMMITTED_SNAPSHOT) { ON | OFF }` (the WITH NO_WAIT / WITH ROLLBACK IMMEDIATE termination options that real SQL Server rejects with Msg 5083 on versioning-state changes aren't modeled; the simulator's flip takes effect immediately, skipping real SQL Server's brief-stabilization-wait and single-connection-required gates). SI-session-on-user-table with ASI=OFF raises `Msg 3952` verbatim at first user-table access (probe-confirmed: `SET TRANSACTION ISOLATION LEVEL SNAPSHOT` is silent, system-catalog reads bypass the gate). Snapshot Xid is allocated lazily at first user-table read (per-tx for SI, per-statement for RCSI; carried in `SimulatedDbTransaction.SnapshotXid` / `BatchContext.RcsiStatementSnapshotXid`). Writers under either flag capture pre-mutation payloads to the chain via `VersionStore.CaptureWrite`; INSERT creates a fresh chain entry, UPDATE attaches a pending `HistoricalVersion` carrying the pre-write payload eagerly (so concurrent SI readers see it before the writer commits), DELETE marks the chain as deleted-live at commit. Tx-scoped `SimulatedDbTransaction.PendingVersionEntries` finalizes on COMMIT (one commit Xid for the whole batch, stamps `HistoricalVersion.Xmax` and `RowVersionChain.LiveXmin`) and discards on ROLLBACK (the heap row is restored by the undo log; the chain mark is cleared). SI update-conflict raises `Msg 3960` verbatim from `VersionStore.CheckSnapshotUpdateConflict` at the top of `CommitUpdate` / `CommitDelete` when the live row at the target Rid has `LiveXmin > snapshotXid` or a foreign `WriterTx`; auto-rolls back the SI tx before throwing (probe-confirmed `@@TRANCOUNT = 0`). RCSI default-RC readers consult the chain through `BatchContext.ResolveSnapshotXidForRead` → `VersionStore.ResolveVisibleVersion`. Phase-3 limitations: DELETE-visible-to-SI-snapshot (heap iteration skips tombstoned slots — SI readers can't see committed-deleted rows their snapshot would still see); SI-writer-on-deleted-row Msg 3960 (same root cause); multi-update-within-one-tx history collapse; version-store garbage collection; `sys.dm_tran_version_store` DMV. See [`docs/claude/locking.md`](docs/claude/locking.md). `BEGIN DISTRIBUTED TRANSACTION` and `BEGIN TRANSACTION ... WITH MARK` raise `NotSupportedException` at dispatch. **Other `SET <option> …` statements are parsed-and-discarded** via a closed accept-list in `Simulation.Set.cs` — `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. SNAPSHOT isolation level parses-and-discards (behaves as RC). 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`, `SET LOCK_TIMEOUT N`, and `SET TRANSACTION ISOLATION LEVEL {…}` have semantic effect; everything else in the accept-list parses-and-discards.
162+
- **Key-range locks, `ALTER SCHEMA TRANSFER` Sch-M, row-lock cleanup on DELETE** — phase 4+ deferrals (phases 0–3 ship comprehensive lock + MVCC coverage). **Phases 0 + 1a + 1b + 2 + 3 ship**: schema-stability locks (Sch-S / Sch-M) including ALTER PROCEDURE / TRIGGER / SEQUENCE; the full 8-mode data + intent matrix (Sch-S / Sch-M / IS / IX / SIX / S / U / X); row-level X on writers and probe-only / row-S / row-U / row-X on readers depending on hints + isolation level; row-X on history-table / cascade-FK / OUTPUT-INTO / SELECT-INTO destination writes; alias-form `UPDATE x SET … FROM t x JOIN …` table-IX acquired after FROM-target identification; SERIALIZABLE / HOLDLOCK hint upgrades read to table-S tx-scoped (phantom prevention at table granularity since real SQL Server's key-range locks require index range structure the simulator doesn't have); REPEATABLE READ / REPEATABLEREAD hint retains row-S tx-scoped; UPDLOCK retains row-U tx-scoped; XLOCK retains row-X tx-scoped on reads; READPAST skips conflicting rows; TABLOCK / TABLOCKX escalate to table-S / table-X; NOLOCK / READUNCOMMITTED dirty-read; lock escalation at 5000 per-tx per-table row-locks → table-X; cross-thread waiter-graph cycle detection (Msg 1205); same-thread immediate-deadlock short-circuit; `SET LOCK_TIMEOUT N` driving Msg 1222 on expiry; `SET TRANSACTION ISOLATION LEVEL` mutating session state with semantic effect; auto-rollback of the victim's tx on Msg 1205 (probe-confirmed `@@TRANCOUNT = 0` in CATCH); `@@LOCK_TIMEOUT` / `@@SPID` scalar functions; hint-conflict detection (Msg 1047 `NOLOCK + XLOCK/UPDLOCK/HOLDLOCK/…` verbatim, Msg 1065 `NOLOCK on DML target` verbatim, Msg 1069 `INDEX hint on DML target` verbatim); `sys.dm_tran_locks` + `sys.dm_os_waiting_tasks` DMVs projecting GRANT / WAIT rows over the live lock-manager state; **SNAPSHOT isolation + READ_COMMITTED_SNAPSHOT (phase 3)** via per-table `HeapTable.RowVersions` version chains + monotonic per-database `Database.AllocateTransactionCommitId` Xid allocator + `ALTER DATABASE … SET (ALLOW_SNAPSHOT_ISOLATION | READ_COMMITTED_SNAPSHOT) { ON | OFF }` (the WITH NO_WAIT / WITH ROLLBACK IMMEDIATE termination options that real SQL Server rejects with Msg 5083 on versioning-state changes aren't modeled; the simulator's flip takes effect immediately, skipping real SQL Server's brief-stabilization-wait and single-connection-required gates). SI-session-on-user-table with ASI=OFF raises `Msg 3952` verbatim at first user-table access (probe-confirmed: `SET TRANSACTION ISOLATION LEVEL SNAPSHOT` is silent, system-catalog reads bypass the gate). Snapshot Xid is allocated lazily at first user-table read (per-tx for SI, per-statement for RCSI; carried in `SimulatedDbTransaction.SnapshotXid` / `BatchContext.RcsiStatementSnapshotXid`). Writers under either flag capture pre-mutation payloads to the chain via `VersionStore.CaptureWrite`; INSERT creates a fresh chain entry, UPDATE attaches a pending `HistoricalVersion` carrying the pre-write payload eagerly (so concurrent SI readers see it before the writer commits), DELETE marks the chain as deleted-live at commit. Tx-scoped `SimulatedDbTransaction.PendingVersionEntries` finalizes on COMMIT (one commit Xid for the whole batch, stamps `HistoricalVersion.Xmax` and `RowVersionChain.LiveXmin`) and discards on ROLLBACK (the heap row is restored by the undo log; the chain mark is cleared). SI update-conflict raises `Msg 3960` verbatim from `VersionStore.CheckSnapshotUpdateConflict` at the top of `CommitUpdate` / `CommitDelete` when the live row at the target Rid has `LiveXmin > snapshotXid` or a foreign `WriterTx`; auto-rolls back the SI tx before throwing (probe-confirmed `@@TRANCOUNT = 0`). RCSI default-RC readers consult the chain through `BatchContext.ResolveSnapshotXidForRead` → `VersionStore.ResolveVisibleVersion`. Tombstoned-slot snapshot pass: SI / RCSI iteration walks tombstoned slots in a second pass after the live-heap pass via `Heap.IsSlotTombstoned` + `VersionStore.ResolveTombstonedSlotForSnapshot` so SI readers see deleted rows whose pre-delete payload is still visible at their snapshot; the writer-side variant (`Simulation.CheckSnapshotConflictOnTombstonedRows`) evaluates UPDATE / DELETE WHERE against snapshot-visible historical payloads and raises Msg 3960 + auto-rolls back if any match (closing the SI-vs-RC-deleted-row conflict path). Phase-3 remaining limitations: multi-update-within-one-tx history collapse; version-store garbage collection; `sys.dm_tran_version_store` DMV. See [`docs/claude/locking.md`](docs/claude/locking.md). `BEGIN DISTRIBUTED TRANSACTION` and `BEGIN TRANSACTION ... WITH MARK` raise `NotSupportedException` at dispatch. **Other `SET <option> …` statements are parsed-and-discarded** via a closed accept-list in `Simulation.Set.cs` — `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. `SET TRANSACTION ISOLATION LEVEL SNAPSHOT` carries full semantic effect (per-tx snapshot, Msg 3952 if ASI=OFF — see phase 3 above). 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`, `SET LOCK_TIMEOUT N`, and `SET TRANSACTION ISOLATION LEVEL {…}` 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`).

SqlServerSimulator.Tests/SnapshotIsolationTests.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,77 @@ insert t values (1, 10)
241241
_ = siConn.CreateCommand("commit").ExecuteNonQuery();
242242
}
243243

244+
[TestMethod]
245+
public void SnapshotReader_SeesDeletedRow_AfterConcurrentCommittedDelete()
246+
{
247+
var sim = new Simulation();
248+
_ = sim.ExecuteNonQuery("""
249+
alter database current set allow_snapshot_isolation on;
250+
create table t (id int not null primary key, v int);
251+
insert t values (1, 100), (2, 200), (3, 300)
252+
""");
253+
254+
using var siConn = sim.CreateOpenConnection();
255+
// First read takes the snapshot at all 3 rows visible.
256+
_ = siConn.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
257+
258+
using (var rc = sim.CreateOpenConnection())
259+
_ = rc.CreateCommand("delete from t where id = 2").ExecuteNonQuery();
260+
261+
// SI snapshot still sees the deleted row's pre-delete payload.
262+
var v = siConn.CreateCommand("select v from t where id = 2").ExecuteScalar();
263+
AreEqual(200, v);
264+
var count = siConn.CreateCommand("select count(*) from t").ExecuteScalar();
265+
AreEqual(3, count);
266+
267+
_ = siConn.CreateCommand("commit").ExecuteNonQuery();
268+
}
269+
270+
[TestMethod]
271+
public void Msg3960_SnapshotUpdate_OnRcDeletedRow_ThrowsWithAutoRollback()
272+
{
273+
var sim = new Simulation();
274+
_ = sim.ExecuteNonQuery("""
275+
alter database current set allow_snapshot_isolation on;
276+
create table t (id int not null primary key, v int);
277+
insert t values (1, 100)
278+
""");
279+
280+
using var siConn = sim.CreateOpenConnection();
281+
_ = siConn.CreateCommand("set transaction isolation level snapshot; begin tran; select v from t where id = 1").ExecuteScalar();
282+
283+
using (var rc = sim.CreateOpenConnection())
284+
_ = rc.CreateCommand("delete from t where id = 1").ExecuteNonQuery();
285+
286+
// SI snapshot still sees id=1; UPDATE on it must raise Msg 3960
287+
// (not silently succeed with 0 affected rows).
288+
var ex = Throws<System.Data.Common.DbException>(() =>
289+
siConn.CreateCommand("update t set v = 999 where id = 1").ExecuteNonQuery());
290+
AreEqual("3960", ex.Data["HelpLink.EvtID"]);
291+
AreEqual(0, siConn.CreateCommand("select @@trancount").ExecuteScalar());
292+
}
293+
294+
[TestMethod]
295+
public void Msg3960_SnapshotDelete_OnRcDeletedRow_Throws()
296+
{
297+
var sim = new Simulation();
298+
_ = sim.ExecuteNonQuery("""
299+
alter database current set allow_snapshot_isolation on;
300+
create table t (id int not null primary key, v int);
301+
insert t values (1, 100)
302+
""");
303+
304+
using var siConn = sim.CreateOpenConnection();
305+
_ = siConn.CreateCommand("set transaction isolation level snapshot; begin tran; select v from t where id = 1").ExecuteScalar();
306+
307+
using (var rc = sim.CreateOpenConnection())
308+
_ = rc.CreateCommand("delete from t where id = 1").ExecuteNonQuery();
309+
310+
var ex = Throws<System.Data.Common.DbException>(() =>
311+
siConn.CreateCommand("delete from t where id = 1").ExecuteNonQuery());
312+
AreEqual("3960", ex.Data["HelpLink.EvtID"]);
313+
}
314+
244315
[TestMethod]
245316
public void SnapshotReader_AfterCommit_SeesNewBaseline()
246317
{

SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,25 @@ public static IEnumerable<byte[]> WrapWithRowConflictChecks(HeapTable table, Bat
654654
if (batch.TouchRowForRead(table, pageIndex, slotIndex, plan))
655655
yield return bytes;
656656
}
657+
// Second pass: under snapshot, surface tombstoned slots whose chain
658+
// carries a still-visible historical version. The live-heap pass
659+
// skips these (heap iteration skips tombstoned slots), but the
660+
// SI / RCSI snapshot may pre-date the delete, in which case the
661+
// pre-delete payload is the visible version. Walks the per-table
662+
// version dict directly; live slots are filtered out by the
663+
// tombstone check so we don't double-yield.
664+
if (snapshotXid is { } sx2)
665+
{
666+
foreach (var kv in table.RowVersions)
667+
{
668+
if (!table.Heap.IsSlotTombstoned(kv.Key.PageIndex, kv.Key.SlotIndex))
669+
continue;
670+
var resolved = Storage.VersionStore.ResolveTombstonedSlotForSnapshot(kv.Value, sx2, batch.Connection.CurrentTransaction);
671+
if (resolved is null)
672+
continue;
673+
yield return resolved;
674+
}
675+
}
657676
}
658677

659678
/// <summary>

SqlServerSimulator/Simulation/Simulation.Delete.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@ SqlValue Resolve(MultiPartName name)
163163
deleted.Add((pageIndex, slotIndex, (output is null && !needsFullForTriggers && !needsFullForHistory && !needsFullForFk) ? null : fullValues));
164164
}
165165

166+
// SI writer pre-flight: scan the version chain for snapshot-visible
167+
// tombstoned rows. A pre-delete payload matching WHERE means
168+
// another tx already removed a row our snapshot still sees —
169+
// Msg 3960 with auto-rollback (probe-confirmed against SQL Server
170+
// 2025; mirrors the SI UPDATE-on-RC-deleted case). Helper lives
171+
// in Simulation.Update.cs and the partial-class scope shares it.
172+
CheckSnapshotConflictOnTombstonedRows(context, table, where, sourceView);
173+
166174
return CommitDelete(context, table, deleted, output, sourceView);
167175
}
168176

0 commit comments

Comments
 (0)