Skip to content

Commit f50b48e

Browse files
committed
MVCC observability — version-store garbage collection + three new DMVs (sys.dm_tran_version_store, sys.dm_tran_version_store_space_usage, sys.dm_tran_active_snapshot_database_transactions). Closes two of the three documented phase-3 limitations; only multi-update-within-one-tx history collapse remains. **Active-snapshot tracking**: new Database.ActiveSnapshotTxs (ConcurrentDictionary<SimulatedDbTransaction, byte>) registers each SI tx at the moment BatchContext.ResolveSnapshotXidForRead first allocates its tx.SnapshotXid (on first user-table read), and unregisters at Commit / Rollback / Dispose via a shared SimulatedDbTransaction.UnregisterActiveSnapshot helper. RCSI per-statement snapshots aren't registered here — their sub-statement lifetime means the GC's once-per-tx-finalize cadence wouldn't observe them as load-bearing anyway, and the DMV intentionally excludes them (matching real SQL Server). **Version-store GC**: new VersionStore.RunGarbageCollection(Database) walks every per-table RowVersions chain after every tx finalization. Drops trailing HV nodes whose Xmax <= oldest_active_snapshot_xid — when no SI tx is in flight the cutoff is Database.CurrentTransactionCommitId so every finalized HV becomes collectible. Chains that lose their only HV AND aren't IsDeletedLive AND have no in-flight WriterTx are removed from the dict entirely; chains with non-null WriterTx are skipped (pending HVs marked with VersionStore.PendingXmax must not be disturbed mid-tx). Oldest-active is computed by walking ActiveSnapshotTxs and finding the min SnapshotXid; new helper TrimHistory(head, cutoff) walks newest-first and clips at the first node with Xmax > cutoff. **DMVs**: new VersionStoreDmvs.cs provides three enumerators registered as sys.dm_tran_version_store / sys.dm_tran_version_store_space_usage / sys.dm_tran_active_snapshot_database_transactions in BuiltInResources.cs's catalog-view dict, each with probe-confirmed column shapes from SQL Server 2025 (2026-05-14 — after the user granted VIEW SERVER STATE). sys.dm_tran_version_store yields one row per finalized HistoricalVersion across every chain — transaction_sequence_num = HV.Xmax (the commit Xid that retired the version), version_sequence_num synthesized per-tx counter from result-order grouping, record_image_first_part the raw byte[] payload, second-part NULL since the simulator stores payloads as a single allocation, database_id / rowset_id (= table.ObjectId) / status (0) / min_length_in_bytes / record_length_first_part_in_bytes matching probed types + ordinals exactly. sys.dm_tran_version_store_space_usage aggregates one row per database — reserved_space_kb = ceil(total_bytes / 1024), reserved_page_count = ceil(total_bytes / 8192) — always yielding a row (matches probe: empty stores show as zeroes, not row-empty). sys.dm_tran_active_snapshot_database_transactions projects Database.ActiveSnapshotTxs with transaction_sequence_num = tx.SnapshotXid, session_id = tx.connection.Spid, is_snapshot = true, commit_sequence_num / first_snapshot_sequence_num NULL (tx still in flight), max_version_chain_traversed / average_version_chain_traversed / elapsed_time_seconds = 0 (not instrumented). **Tests**: 11 new MvccObservabilityTests.cs cover: no-versioning → empty version_store; UPDATE-under-SI populates 3 HVs while reader holds snapshot; reader commits → GC drops all HVs; DELETE-under-SI appears in the store; transaction_sequence_num is a commit Xid > 0; space_usage always yields one row; reserved_space_kb scales with store contents; active_snapshot_db_tx lists the holding SI session and drains on commit; session_id matches @@spid; RCSI per-statement snapshot doesn't appear; GC respects an older still-active reader's HVs. Total: 3956 main (+11 new) + 227 internal + 328 EFCore + 58 analyzers, all green Debug + Release. CLAUDE.md "Phase-3 remaining limitations" rewrites — version-store GC + the three DMVs added to the shipped paragraph (with implementation pointers including VersionStore.RunGarbageCollection, Database.ActiveSnapshotTxs, and VersionStoreDmvs); only multi-update-within-one-tx history collapse remains in the limitations list. docs/claude/locking.md gains new "MVCC observability" and "Version-store garbage collection" sections documenting the three DMVs' column rules + the GC trigger / chain-removal logic; the "Known phase-3 limitations" section shrinks to just the multi-update item. Probe-confirmed exact column names + types of all three DMVs against SQL Server 2025 post-GRANT.
1 parent b7e4d37 commit f50b48e

9 files changed

Lines changed: 586 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Phase-3 MVCC observability surface: <c>sys.dm_tran_version_store</c>,
7+
/// <c>sys.dm_tran_version_store_space_usage</c>, and
8+
/// <c>sys.dm_tran_active_snapshot_database_transactions</c>, plus the
9+
/// version-store garbage-collector that runs at every Commit / Rollback /
10+
/// Dispose of an explicit transaction. Each DMV's column shape was
11+
/// probe-confirmed against SQL Server 2025 (2026-05-14) after
12+
/// <c>GRANT VIEW SERVER STATE</c>; the simulator surfaces matching column
13+
/// names + ordering + types so existing diagnostic queries port unchanged.
14+
/// </summary>
15+
[TestClass]
16+
public sealed class MvccObservabilityTests
17+
{
18+
private static Simulation NewWithVersioning()
19+
{
20+
var sim = new Simulation();
21+
_ = sim.ExecuteNonQuery("alter database simulated set allow_snapshot_isolation on");
22+
_ = sim.ExecuteNonQuery("alter database simulated set read_committed_snapshot on");
23+
return sim;
24+
}
25+
26+
[TestMethod]
27+
public void VersionStore_NoVersioning_ReturnsEmpty()
28+
=> AreEqual(0, new Simulation().ExecuteScalar("""
29+
create table t (id int primary key, v int);
30+
insert t values (1, 100);
31+
update t set v = 200 where id = 1;
32+
select count(*) from sys.dm_tran_version_store
33+
"""));
34+
35+
[TestMethod]
36+
public void VersionStore_PopulatedAfterUpdateUnderSi_WhileReaderHoldsSnapshot()
37+
{
38+
var sim = NewWithVersioning();
39+
_ = sim.ExecuteNonQuery("""
40+
create table t (id int primary key, v int);
41+
insert t values (1, 100), (2, 200), (3, 300)
42+
""");
43+
using var reader = sim.CreateOpenConnection();
44+
_ = reader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
45+
46+
using var writer = sim.CreateOpenConnection();
47+
_ = writer.CreateCommand("update t set v = v + 1").ExecuteNonQuery();
48+
49+
AreEqual(3, sim.ExecuteScalar("select count(*) from sys.dm_tran_version_store"));
50+
_ = reader.CreateCommand("commit").ExecuteNonQuery();
51+
}
52+
53+
[TestMethod]
54+
public void VersionStore_AfterAllReadersCommit_GcEmptiesStore()
55+
{
56+
var sim = NewWithVersioning();
57+
_ = sim.ExecuteNonQuery("""
58+
create table t (id int primary key, v int);
59+
insert t values (1, 100), (2, 200), (3, 300)
60+
""");
61+
using var reader = sim.CreateOpenConnection();
62+
_ = reader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
63+
64+
using (var writer = sim.CreateOpenConnection())
65+
_ = writer.CreateCommand("update t set v = v + 1").ExecuteNonQuery();
66+
67+
AreEqual(3, sim.ExecuteScalar("select count(*) from sys.dm_tran_version_store"));
68+
_ = reader.CreateCommand("commit").ExecuteNonQuery();
69+
// After the reader's SI tx commits there are no active snapshots
70+
// anchoring HVs <= the writer's commit Xid; the GC at commit time
71+
// collapses the store.
72+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.dm_tran_version_store"));
73+
}
74+
75+
[TestMethod]
76+
public void VersionStore_DeleteUnderSi_AppearsInVersionStore()
77+
{
78+
var sim = NewWithVersioning();
79+
_ = sim.ExecuteNonQuery("""
80+
create table t (id int primary key, v int);
81+
insert t values (1, 100), (2, 200)
82+
""");
83+
using var reader = sim.CreateOpenConnection();
84+
_ = reader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
85+
86+
using var writer = sim.CreateOpenConnection();
87+
_ = writer.CreateCommand("delete from t where id = 1").ExecuteNonQuery();
88+
89+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.dm_tran_version_store"));
90+
_ = reader.CreateCommand("commit").ExecuteNonQuery();
91+
}
92+
93+
[TestMethod]
94+
public void VersionStore_TransactionSequenceNumIsCommitXid()
95+
{
96+
var sim = NewWithVersioning();
97+
_ = sim.ExecuteNonQuery("""
98+
create table t (id int primary key, v int);
99+
insert t values (1, 100)
100+
""");
101+
using var reader = sim.CreateOpenConnection();
102+
_ = reader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
103+
104+
using (var writer = sim.CreateOpenConnection())
105+
_ = writer.CreateCommand("update t set v = 200").ExecuteNonQuery();
106+
107+
// The HV's transaction_sequence_num is the writer's commit Xid.
108+
// We can't predict the absolute value but it should be > 0 (the
109+
// implicit Xid 0 marks pre-versioning state).
110+
var seq = (long)sim.ExecuteScalar("select transaction_sequence_num from sys.dm_tran_version_store")!;
111+
IsGreaterThan(0L, seq);
112+
_ = reader.CreateCommand("commit").ExecuteNonQuery();
113+
}
114+
115+
[TestMethod]
116+
public void SpaceUsage_AlwaysYieldsOneRow_PerDatabase()
117+
{
118+
var sim = NewWithVersioning();
119+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.dm_tran_version_store_space_usage"));
120+
}
121+
122+
[TestMethod]
123+
public void SpaceUsage_ReservedKbScalesWithStoreSize()
124+
{
125+
var sim = NewWithVersioning();
126+
_ = sim.ExecuteNonQuery("""
127+
create table t (id int primary key, v int);
128+
insert t values (1, 100), (2, 200), (3, 300)
129+
""");
130+
using var reader = sim.CreateOpenConnection();
131+
_ = reader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
132+
using (var writer = sim.CreateOpenConnection())
133+
_ = writer.CreateCommand("update t set v = v + 1").ExecuteNonQuery();
134+
135+
var kb = (long)sim.ExecuteScalar("select reserved_space_kb from sys.dm_tran_version_store_space_usage")!;
136+
IsGreaterThan(0L, kb);
137+
_ = reader.CreateCommand("commit").ExecuteNonQuery();
138+
}
139+
140+
[TestMethod]
141+
public void ActiveSnapshotDbTx_ListsHoldingSiSession()
142+
{
143+
var sim = NewWithVersioning();
144+
_ = sim.ExecuteNonQuery("create table t (id int primary key); insert t values (1)");
145+
using var reader = sim.CreateOpenConnection();
146+
_ = reader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
147+
148+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.dm_tran_active_snapshot_database_transactions where is_snapshot = 1"));
149+
_ = reader.CreateCommand("commit").ExecuteNonQuery();
150+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.dm_tran_active_snapshot_database_transactions"));
151+
}
152+
153+
[TestMethod]
154+
public void ActiveSnapshotDbTx_SessionIdMatchesSpid()
155+
{
156+
var sim = NewWithVersioning();
157+
_ = sim.ExecuteNonQuery("create table t (id int primary key); insert t values (1)");
158+
using var reader = sim.CreateOpenConnection();
159+
var spid = (short)reader.CreateCommand("select @@spid").ExecuteScalar()!;
160+
_ = reader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
161+
162+
AreEqual(spid, (int)sim.ExecuteScalar($"select session_id from sys.dm_tran_active_snapshot_database_transactions where session_id = {spid}")!);
163+
_ = reader.CreateCommand("commit").ExecuteNonQuery();
164+
}
165+
166+
[TestMethod]
167+
public void ActiveSnapshotDbTx_RcsiStatementSnapshot_NotTracked()
168+
{
169+
var sim = NewWithVersioning();
170+
_ = sim.ExecuteNonQuery("create table t (id int primary key); insert t values (1)");
171+
using var reader = sim.CreateOpenConnection();
172+
// Default RC + RCSI; the per-statement snapshot allocated by the
173+
// SELECT is ephemeral and shouldn't appear in the DMV after the
174+
// statement returns.
175+
AreEqual(1, reader.CreateCommand("select count(*) from t").ExecuteScalar());
176+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.dm_tran_active_snapshot_database_transactions"));
177+
}
178+
179+
[TestMethod]
180+
public void Gc_DoesNotDropHvsStillNeededByOlderSnapshot()
181+
{
182+
var sim = NewWithVersioning();
183+
_ = sim.ExecuteNonQuery("""
184+
create table t (id int primary key, v int);
185+
insert t values (1, 100)
186+
""");
187+
using var olderReader = sim.CreateOpenConnection();
188+
_ = olderReader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
189+
190+
// First writer mutation
191+
using (var writer = sim.CreateOpenConnection())
192+
_ = writer.CreateCommand("update t set v = 200").ExecuteNonQuery();
193+
194+
// A newer SI tx commits without ever needing the HV
195+
using (var newerReader = sim.CreateOpenConnection())
196+
{
197+
_ = newerReader.CreateCommand("set transaction isolation level snapshot; begin tran; select count(*) from t").ExecuteScalar();
198+
_ = newerReader.CreateCommand("commit").ExecuteNonQuery();
199+
}
200+
201+
// GC at the newer reader's commit must not drop the HV — older
202+
// reader still needs it.
203+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.dm_tran_version_store"));
204+
_ = olderReader.CreateCommand("commit").ExecuteNonQuery();
205+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.dm_tran_version_store"));
206+
}
207+
}

SqlServerSimulator/BuiltInResources.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,53 @@ private static Dictionary<string, CatalogView> BuildCatalogViews()
739739
};
740740
var dmOsWaitingTasksView = new CatalogView("dm_os_waiting_tasks", dmOsWaitingTasksColumns, LockDmvs.EnumerateDmOsWaitingTasks);
741741

742+
// sys.dm_tran_version_store: one row per finalized HistoricalVersion
743+
// across every per-table chain. Pending HVs (Xmax = PendingXmax)
744+
// are excluded. Real SQL Server's exact column order is preserved
745+
// so existing diagnostic queries port unchanged.
746+
var dmTranVersionStoreColumns = new HeapColumn[]
747+
{
748+
new("transaction_sequence_num", SqlType.BigInt, null, false),
749+
new("version_sequence_num", SqlType.BigInt, null, false),
750+
new("database_id", SqlType.SmallInt, null, false),
751+
new("rowset_id", SqlType.BigInt, null, false),
752+
new("status", SqlType.TinyInt, null, false),
753+
new("min_length_in_bytes", SqlType.SmallInt, null, false),
754+
new("record_length_first_part_in_bytes", SqlType.SmallInt, null, false),
755+
new("record_image_first_part", VarbinarySqlType.MaxForm, null, true),
756+
new("record_length_second_part_in_bytes", SqlType.SmallInt, null, true),
757+
new("record_image_second_part", VarbinarySqlType.MaxForm, null, true),
758+
};
759+
var dmTranVersionStoreView = new CatalogView("dm_tran_version_store", dmTranVersionStoreColumns, VersionStoreDmvs.EnumerateDmTranVersionStore);
760+
761+
// sys.dm_tran_version_store_space_usage: aggregate sizing per
762+
// database. The simulator approximates pages as ceil(bytes / 8192)
763+
// since HV payloads aren't backed by real pages.
764+
var dmTranVersionStoreSpaceUsageColumns = new HeapColumn[]
765+
{
766+
new("database_id", SqlType.Int32, null, false),
767+
new("reserved_page_count", SqlType.BigInt, null, false),
768+
new("reserved_space_kb", SqlType.BigInt, null, false),
769+
};
770+
var dmTranVersionStoreSpaceUsageView = new CatalogView("dm_tran_version_store_space_usage", dmTranVersionStoreSpaceUsageColumns, VersionStoreDmvs.EnumerateDmTranVersionStoreSpaceUsage);
771+
772+
// sys.dm_tran_active_snapshot_database_transactions: one row per
773+
// active SI tx with an allocated snapshot Xid. RCSI per-statement
774+
// snapshots are not tracked here (matching real SQL Server).
775+
var dmTranActiveSnapshotDbTxColumns = new HeapColumn[]
776+
{
777+
new("transaction_id", SqlType.BigInt, null, false),
778+
new("transaction_sequence_num", SqlType.BigInt, null, false),
779+
new("commit_sequence_num", SqlType.BigInt, null, true),
780+
new("session_id", SqlType.Int32, null, false),
781+
new("is_snapshot", SqlType.Bit, null, false),
782+
new("first_snapshot_sequence_num", SqlType.BigInt, null, true),
783+
new("max_version_chain_traversed", SqlType.Int32, null, false),
784+
new("average_version_chain_traversed", SqlType.Float, null, false),
785+
new("elapsed_time_seconds", SqlType.BigInt, null, false),
786+
};
787+
var dmTranActiveSnapshotDbTxView = new CatalogView("dm_tran_active_snapshot_database_transactions", dmTranActiveSnapshotDbTxColumns, VersionStoreDmvs.EnumerateDmTranActiveSnapshotDatabaseTransactions);
788+
742789
return new Dictionary<string, CatalogView>(Collation.Default)
743790
{
744791
["sys.schemas"] = schemasView,
@@ -761,6 +808,9 @@ private static Dictionary<string, CatalogView> BuildCatalogViews()
761808
["sys.index_columns"] = indexColumnsView,
762809
["sys.dm_tran_locks"] = dmTranLocksView,
763810
["sys.dm_os_waiting_tasks"] = dmOsWaitingTasksView,
811+
["sys.dm_tran_version_store"] = dmTranVersionStoreView,
812+
["sys.dm_tran_version_store_space_usage"] = dmTranVersionStoreSpaceUsageView,
813+
["sys.dm_tran_active_snapshot_database_transactions"] = dmTranActiveSnapshotDbTxView,
764814
["INFORMATION_SCHEMA.TABLES"] = isTablesView,
765815
["INFORMATION_SCHEMA.COLUMNS"] = isColumnsView,
766816
["INFORMATION_SCHEMA.SCHEMATA"] = isSchemataView,

SqlServerSimulator/Database.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ public Database(string name)
142142
/// </summary>
143143
public bool ReadCommittedSnapshot;
144144

145+
/// <summary>
146+
/// Active SNAPSHOT-isolation transactions whose snapshot Xid is still
147+
/// load-bearing — every entry's <see cref="SimulatedDbTransaction.SnapshotXid"/>
148+
/// is non-null and the tx hasn't reached Commit / Rollback / Dispose yet.
149+
/// Populated by <see cref="Parser.BatchContext.ResolveSnapshotXidForRead"/>
150+
/// on first user-table read of an SI tx; drained by the corresponding
151+
/// finalization path. Read by the version-store GC to compute the
152+
/// oldest active snapshot Xid (HVs whose <c>Xmax &lt;= oldest_active</c>
153+
/// are safe to drop), and by <c>sys.dm_tran_active_snapshot_database_transactions</c>
154+
/// to enumerate per-session SI state.
155+
/// </summary>
156+
public readonly ConcurrentDictionary<SimulatedDbTransaction, byte> ActiveSnapshotTxs = new();
157+
145158
private int nextObjectId = 100;
146159

147160
/// <summary>

SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,11 @@ public static IEnumerable<byte[]> WrapWithRowConflictChecks(HeapTable table, Bat
697697
{
698698
if (connection.CurrentTransaction is { } tx)
699699
{
700-
tx.SnapshotXid ??= database.CurrentTransactionCommitId;
700+
if (tx.SnapshotXid is null)
701+
{
702+
tx.SnapshotXid = database.CurrentTransactionCommitId;
703+
database.ActiveSnapshotTxs[tx] = 0;
704+
}
701705
return tx.SnapshotXid;
702706
}
703707
// Auto-commit SI session — each read gets the latest commit

SqlServerSimulator/SimulatedDbTransaction.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,12 @@ public override void Commit()
146146
this.TranCount--;
147147
if (this.TranCount > 0)
148148
return;
149-
Storage.VersionStore.FinalizePendingEntries(this.PendingVersionEntries, this.connection.CurrentDatabase);
149+
var db = this.connection.CurrentDatabase;
150+
Storage.VersionStore.FinalizePendingEntries(this.PendingVersionEntries, db);
150151
this.UndoLog.Clear();
151152
ReleaseAllLocks();
153+
UnregisterActiveSnapshot(db);
154+
Storage.VersionStore.RunGarbageCollection(db);
152155
RestoreSessionIsolation();
153156
this.connection.CurrentTransaction = null;
154157
this.finished = true;
@@ -158,10 +161,13 @@ public override void Rollback()
158161
{
159162
if (this.finished)
160163
throw new InvalidOperationException("This SqlTransaction has completed; it is no longer usable.");
164+
var db = this.connection.CurrentDatabase;
161165
Storage.VersionStore.DiscardPendingEntries(this.PendingVersionEntries);
162166
this.UndoLog.Rollback();
163167
this.TranCount = 0;
164168
ReleaseAllLocks();
169+
UnregisterActiveSnapshot(db);
170+
Storage.VersionStore.RunGarbageCollection(db);
165171
RestoreSessionIsolation();
166172
this.connection.CurrentTransaction = null;
167173
this.finished = true;
@@ -177,16 +183,25 @@ protected override void Dispose(bool disposing)
177183
{
178184
if (disposing && !this.finished)
179185
{
186+
var db = this.connection.CurrentDatabase;
180187
Storage.VersionStore.DiscardPendingEntries(this.PendingVersionEntries);
181188
this.UndoLog.Rollback();
182189
ReleaseAllLocks();
190+
UnregisterActiveSnapshot(db);
191+
Storage.VersionStore.RunGarbageCollection(db);
183192
RestoreSessionIsolation();
184193
this.connection.CurrentTransaction = null;
185194
this.finished = true;
186195
}
187196
base.Dispose(disposing);
188197
}
189198

199+
private void UnregisterActiveSnapshot(Database db)
200+
{
201+
if (this.SnapshotXid is not null)
202+
_ = db.ActiveSnapshotTxs.TryRemove(this, out _);
203+
}
204+
190205
private void RestoreSessionIsolation()
191206
{
192207
if (this.OverrodeSessionIsolation)

0 commit comments

Comments
 (0)