Skip to content

Commit ad2dd14

Browse files
committed
Closed remaining heap/LOB reclamation gaps: versioned autocommit, forwarded-row DELETE, and rolled-back INSERT.
1 parent e388046 commit ad2dd14

6 files changed

Lines changed: 165 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Three entry points share one per-connection undo log: implicit (statement-level
119119
- **Statement-level atomicity**: a single mutation throwing mid-execution rolls back its partial writes. Multi-row INSERT failing on row 3 leaves zero rows.
120120
- **Explicit txs**: `BEGIN TRAN` increments `TranCount`; only outermost `COMMIT` actually commits; `ROLLBACK` zeroes `TranCount` and walks the entire log. `SAVE TRAN <name>` + `ROLLBACK TRAN <name>` is the EF SaveChanges path inside an explicit tx. Parallel `BeginTransaction``InvalidOperationException`. `COMMIT`/`ROLLBACK` with no active tx → Msg 3902/3903.
121121
- `@@TRANCOUNT` reads connection depth as int.
122-
- **Identity counters and the database-scoped rowversion counter bypass the log** — both keep advancing through rollback. (A rolled-back INSERT's off-row LOB chain is reclaimed — see the LOB-reclamation quirk — but the dead heap-page row-payload bytes are not.)
122+
- **Identity counters and the database-scoped rowversion counter bypass the log** — both keep advancing through rollback. (A rolled-back INSERT's off-row LOB chain and its heap-page row-payload bytes are both reclaimed — the undo marks the slot reclaimable, since rollback is terminal and an uncommitted insert is invisible to every snapshot.)
123123
- **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.
124124
- Locking + MVCC: full 8-mode matrix, row-X writers + row-mode readers per hints/iso, RR/SER/UPDLOCK/XLOCK/TABLOCK/HOLDLOCK/REPEATABLEREAD/NOLOCK/READPAST hints, escalation at 5000 row-locks, Msg 1205 deadlock detection, Msg 1222 timeouts, SNAPSHOT + RCSI with version chains + GC + DMVs. See [`docs/claude/locking.md`](docs/claude/locking.md).
125125

@@ -213,7 +213,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
213213
- `decimal` / `numeric`: backed by .NET `decimal`. Values requiring more than 28 significant digits aren't modeled (declarations up through `decimal(38, *)` accepted so storage byte-width matches).
214214
- `float` text formatting: .NET `G15`/`G7` rather than SQL Server's `1e+015`-style scientific.
215215
- Auto-generated constraint names: PK / UNIQUE shape `PK__<table8>__<16hex>` / `UQ__<table8>__<16hex>` (16-hex 64-bit FNV-1a); CK / FK / DF shape `CK__<table8>__[<col8>__]<8hex>` (8-hex 32-bit FNV-1a). Both are deterministic across runs, distinct from SQL Server's object-id-derived hex (so won't byte-match).
216-
- **Reclaimed heap space is reused, but the page lists shrink only from the tail**: superseded row bytes and off-row LOB chains are freed and reused like SQL Server (see `HeapPage.Compact` / `Heap.FreeLobChain`), so memory tracks the *peak concurrent* working set. The lists don't shrink on their own — a fully-dead interior page is reused in place but never removed from `Heap.Pages`, and a reclaimed slot keeps a 2-byte zero-extent directory entry, because mid-list removal / slot renumbering would break the stable `(page, slot)` addresses cursors, version Rids, and forward pointers depend on. `DBCC SHRINKDATABASE` / `DBCC SHRINKFILE` lower the high-water mark on demand by trimming the *trailing* run of fully-dead data pages and freed LOB pages (`Heap.TrimTrailingDeadPages` / `TrimTrailingFreeLobPages`, after a version-store GC pass); interior dead pages and any version- or lock-pinned tail page stay put. SHRINKDATABASE emits no result set (matches the real server's nothing-to-report case); SHRINKFILE returns the documented per-file row (`DbId, FileId, CurrentSize, MinimumSize, UsedPages, EstimatedPages`) with sizes synthesized from heap page totals, since no physical file model exists. Two residual leaks remain: deleting a *forwarded* row reclaims only the pointer slot, not the relocated target's bytes; and a LOB chain superseded by a versioning-on **autocommit** statement isn't reclaimed (version-store GC fires only at explicit-tx commit).
216+
- **Reclaimed heap space is reused, but the page lists shrink only from the tail**: superseded row bytes and off-row LOB chains are freed and reused like SQL Server (see `HeapPage.Compact` / `Heap.FreeLobChain`), so memory tracks the *peak concurrent* working set. The lists don't shrink on their own — a fully-dead interior page is reused in place but never removed from `Heap.Pages`, and a reclaimed slot keeps a 2-byte zero-extent directory entry, because mid-list removal / slot renumbering would break the stable `(page, slot)` addresses cursors, version Rids, and forward pointers depend on. `DBCC SHRINKDATABASE` / `DBCC SHRINKFILE` lower the high-water mark on demand by trimming the *trailing* run of fully-dead data pages and freed LOB pages (`Heap.TrimTrailingDeadPages` / `TrimTrailingFreeLobPages`, after a version-store GC pass); interior dead pages and any version- or lock-pinned tail page stay put. SHRINKDATABASE emits no result set (matches the real server's nothing-to-report case); SHRINKFILE returns the documented per-file row (`DbId, FileId, CurrentSize, MinimumSize, UsedPages, EstimatedPages`) with sizes synthesized from heap page totals, since no physical file model exists. Deleting a *forwarded* row reclaims both the pointer slot and the relocated target (`Heap.DeleteAt`'s forwarding branch); a versioning-on **autocommit** UPDATE/DELETE reclaims its superseded chains via a version-store GC pass at statement end when no snapshot is open (an open snapshot legitimately holds them until it closes).
217217
- **`GetBytes`/`GetChars` materialize, don't stream**: each call decodes the full column value via `RowDecoder` and slices into the caller's buffer. Behavior matches per-call observation; the streaming-memory guarantee doesn't.
218218
- **`SELECT INTO` string `+` reads as nullable**: real SQL Server projects `cs + 'x'` (both NOT NULL) as NOT NULL; the simulator can't statically distinguish string-concat from integer-add at projection-schema time (the dispatch happens runtime on operand types), so all `Add` results read as nullable. Conservative; no test reliance on string-`+`-non-null.
219219
- **`SELECT INTO` from a CTE drops identity + nullability**: CTE bindings synthesize their wrapper `HeapColumn` entries with `nullable: true` and no identity, so the analyzer treats CTE sources as derived plans. Real SQL Server preserves both through simple single-source CTEs. Fix requires propagating column metadata through CTE bindings.

SqlServerSimulator.Tests.Internal/Storage/LobReclamationTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,26 @@ public void VersionedUpdate_ReclaimsViaGarbageCollection()
136136
IsLessThanOrEqualTo(8, HeapFor(conn, "t").LobPages.Count, "Version-store GC should trim each committed tx's history entry and free its chain when no snapshot needs it.");
137137
}
138138

139+
// Under RCSI with no concurrent snapshot reader, even plain autocommit
140+
// UPDATEs (no explicit transaction) must reclaim their superseded chains:
141+
// version-store GC runs at the end of a versioned autocommit statement when
142+
// no snapshot is open, so churn stays bounded without an explicit COMMIT to
143+
// trigger collection.
144+
[TestMethod]
145+
public void VersionedAutocommitUpdate_ReclaimsWithoutExplicitTransaction()
146+
{
147+
var conn = new Simulation().CreateDbConnection();
148+
conn.Open();
149+
Exec(conn, "alter database simulated set read_committed_snapshot on");
150+
Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)");
151+
Exec(conn, "insert t values (1, replicate(N'a', 100))");
152+
153+
for (var i = 0; i < 100; i++)
154+
Exec(conn, "update t set v = replicate(N'b', 100) where id = 1");
155+
156+
IsLessThanOrEqualTo(8, HeapFor(conn, "t").LobPages.Count, "A versioned autocommit UPDATE with no open snapshot should GC its superseded chain immediately, not wait for an explicit-tx commit.");
157+
}
158+
139159
// Reclamation must not corrupt a surviving value: the live row reads back
140160
// correctly after extensive churn around it.
141161
[TestMethod]

SqlServerSimulator.Tests.Internal/Storage/PageReclamationTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,64 @@ public void Compaction_PreservesManyInterleavedSurvivors()
115115
}
116116
}
117117

118+
// Deleting a *forwarded* row must reclaim the relocated target's bytes, not
119+
// just the pointer slot. Each cycle grows a row (forwarding it to a fresh
120+
// target) then deletes it; without target reclamation the target pages
121+
// accrete one per cycle.
122+
[TestMethod]
123+
public void ForwardedRowDeleteChurn_KeepsPagesBounded()
124+
{
125+
var conn = new Simulation().CreateDbConnection();
126+
conn.Open();
127+
Exec(conn, "create table t (id int not null primary key, v varchar(7000) not null)");
128+
129+
for (var i = 0; i < 100; i++)
130+
{
131+
Exec(conn, "insert t values (1, 'x')");
132+
Exec(conn, "update t set v = replicate('a', 7000) where id = 1");
133+
Exec(conn, "delete from t where id = 1");
134+
}
135+
136+
IsLessThanOrEqualTo(6, HeapFor(conn, "t").Pages.Count, "Deleting a forwarded row should reclaim the relocated target's page space, not orphan a page per cycle.");
137+
}
138+
139+
// Rolling back a forwarded-row DELETE must restore the row exactly once — the
140+
// pointer and target both un-tombstone and the target re-registers as a
141+
// forward target, so the row surfaces via the forwarder rather than also
142+
// appearing as a standalone live row.
143+
[TestMethod]
144+
public void RolledBackForwardedRowDelete_RestoresRowExactlyOnce()
145+
{
146+
var conn = new Simulation().CreateDbConnection();
147+
conn.Open();
148+
Exec(conn, "create table t (id int not null primary key, v varchar(7000) not null)");
149+
Exec(conn, "insert t values (1, 'x')");
150+
Exec(conn, "update t set v = replicate('a', 7000) where id = 1"); // grows → forwards
151+
Exec(conn, "begin tran; delete from t where id = 1; rollback");
152+
153+
using var cmd = conn.CreateCommand();
154+
cmd.CommandText = "select count(*) from t";
155+
AreEqual(1, cmd.ExecuteScalar());
156+
cmd.CommandText = "select len(v) from t where id = 1";
157+
AreEqual(7000, cmd.ExecuteScalar());
158+
}
159+
160+
// A rolled-back INSERT's page-payload bytes must be reclaimed: the row is
161+
// gone for good, so its slot is reclaimable and later inserts reuse the
162+
// space rather than appending a page per rolled-back cycle.
163+
[TestMethod]
164+
public void RolledBackInsertChurn_KeepsPagesBounded()
165+
{
166+
var conn = new Simulation().CreateDbConnection();
167+
conn.Open();
168+
Exec(conn, "create table t (id int not null primary key, v varchar(7000) not null)");
169+
170+
for (var i = 0; i < 100; i++)
171+
Exec(conn, "begin tran; insert t values (1, replicate('a', 7000)); rollback");
172+
173+
IsLessThanOrEqualTo(4, HeapFor(conn, "t").Pages.Count, "Rolled-back INSERT page bytes should be reclaimable and reused, not leak a page per cycle.");
174+
}
175+
118176
// A rolled-back DELETE must not be reclaimed: compaction has to preserve an
119177
// uncommitted-tombstone's bytes so the row comes back intact.
120178
[TestMethod]

SqlServerSimulator/Simulation/Simulation.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,13 +1155,26 @@ private static SimulatedStatementOutcome RunMutation(ParserContext context, Func
11551155
var outcome = body(context);
11561156
if (statementVersionEntries is { } autoCommitEntries)
11571157
{
1158+
// FinalizePendingEntries clears the list, so capture whether
1159+
// this statement versioned anything before the call.
1160+
var versionedThisStatement = autoCommitEntries.Count > 0;
11581161
Storage.VersionStore.FinalizePendingEntries(autoCommitEntries, context.CurrentDatabase);
11591162
// Auto-commit statement: its writes are now permanent, so
11601163
// commit the throwaway log — reclaiming chains superseded by
11611164
// this statement's UPDATE/DELETEs (unversioned path). Under an
11621165
// explicit tx (statementVersionEntries is null) the entries
11631166
// stay on the tx's log until COMMIT instead.
11641167
log.Commit();
1168+
// When this statement versioned its superseded rows, those
1169+
// images are pinned only by the HistoricalVersions just
1170+
// created. With no snapshot open nothing needs them, so collect
1171+
// now rather than leaving them until the next explicit-tx
1172+
// commit (the version-store analog of the unversioned
1173+
// log.Commit() above). An active snapshot legitimately needs the
1174+
// versions, so defer — and skip the scan — until it closes.
1175+
var autoCommitDatabase = context.CurrentDatabase;
1176+
if (versionedThisStatement && autoCommitDatabase.ActiveSnapshotTxs.IsEmpty)
1177+
Storage.VersionStore.RunGarbageCollection(autoCommitDatabase);
11651178
}
11661179
// Table-variable writes are non-transactional and final on
11671180
// statement success regardless of any enclosing tx, so their

SqlServerSimulator/Storage/Heap.cs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,20 +302,41 @@ public IEnumerable<byte[]> EnumerateRows()
302302

303303
/// <summary>
304304
/// Marks the row at <paramref name="pageIndex"/> / <paramref name="slotIndex"/>
305-
/// as deleted. The slot is tombstoned at the page level; row payload bytes
306-
/// are not reclaimed (the heap-page-byte leak quirk in CLAUDE.md). The
307-
/// row's off-row LOB chains, however, are reclaimed: when
305+
/// as deleted. The slot is tombstoned at the page level; on commit its undo
306+
/// entry marks the slot reclaimable so <see cref="HeapPage.Compact"/> can
307+
/// pack the bytes away. The row's off-row LOB chains are reclaimed too: when
308308
/// <paramref name="reclaimSuperseded"/> is set (no <c>HistoricalVersion</c>
309309
/// will pin them — see <see cref="VersionStore.WillCaptureVersions"/>) the
310310
/// recorded undo entry frees them on commit via
311311
/// <see cref="FreeLobChain"/>; otherwise version-store GC frees them once
312312
/// no snapshot needs the deleted row.
313313
/// </summary>
314+
/// <remarks>
315+
/// When the visible slot is a forwarding pointer, the row's payload (and any
316+
/// off-row chains) lives at the relocated target, not the pointer slot — so
317+
/// both are deleted, and the target is unregistered from
318+
/// <see cref="ForwardTargets"/>. The target's Delete entry carries the
319+
/// <paramref name="reclaimSuperseded"/> gate (it owns the row + chains); the
320+
/// pointer's entry only reclaims its directory slot — its bytes are a
321+
/// forward pointer, not a row, so they must never be decoded for LOB heads.
322+
/// Rollback resurrects both slots and re-registers the target.
323+
/// </remarks>
314324
public void DeleteAt(int pageIndex, int slotIndex, UndoLog? undoLog = null, bool reclaimSuperseded = false)
315325
{
316326
this.MutationGeneration++;
327+
var page = this.Pages[pageIndex];
328+
if (page.IsSlotForwarded(slotIndex))
329+
{
330+
var target = page.ReadForwardTarget(slotIndex);
331+
undoLog?.RecordDelete(this, target.PageIndex, target.SlotIndex, reclaimSuperseded);
332+
this.Pages[target.PageIndex].DeleteSlot(target.SlotIndex);
333+
undoLog?.RecordForwardedPointerDelete(this, pageIndex, slotIndex, target);
334+
page.DeleteSlot(slotIndex);
335+
_ = this.ForwardTargets.Remove(target);
336+
return;
337+
}
317338
undoLog?.RecordDelete(this, pageIndex, slotIndex, reclaimSuperseded);
318-
this.Pages[pageIndex].DeleteSlot(slotIndex);
339+
page.DeleteSlot(slotIndex);
319340
}
320341

321342
/// <summary>
@@ -421,6 +442,15 @@ internal void SwapForwardTargetForUndo((int Page, int Slot) oldTarget, (int Page
421442
_ = this.ForwardTargets.Add(oldTarget);
422443
}
423444

445+
/// <summary>
446+
/// Undo callback for <see cref="UndoLog.RecordForwardedPointerDelete"/> —
447+
/// re-registers a target whose forwarding row was deleted, so the
448+
/// resurrected pointer surfaces the row once (via the forwarder) rather than
449+
/// the target also appearing as a standalone live row.
450+
/// </summary>
451+
internal void ReinstateForwardTargetForUndo((int Page, int Slot) target) =>
452+
_ = this.ForwardTargets.Add(target);
453+
424454
/// <summary>
425455
/// Returns a fresh copy of the row bytes at the given Rid, dereferencing
426456
/// one level of forwarding so callers see the live row's payload even

SqlServerSimulator/Storage/UndoLog.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ public void RecordForwardInstall(Heap heap, int pageIndex, int slotIndex, byte[]
7575
public void RecordForwardRetarget(Heap heap, int pageIndex, int slotIndex, (int Page, int Slot) oldTarget, (int Page, int Slot) newTarget) =>
7676
this.entries.Add(new SlotRewrite(heap, SlotRewriteKind.ForwardRetarget, pageIndex, slotIndex, [], oldTarget, newTarget, freeOnCommit: false));
7777

78+
public void RecordForwardedPointerDelete(Heap heap, int pageIndex, int slotIndex, (int Page, int Slot) target) =>
79+
this.entries.Add(new ForwardedPointerDelete(heap, pageIndex, slotIndex, target));
80+
7881
public void RecordTempTableCreation(ConcurrentDictionary<string, HeapTable> owner, string name) =>
7982
this.entries.Add(new TempTableCreation(owner, name));
8083

@@ -188,8 +191,16 @@ public override void Undo()
188191
// The row this INSERT created is being unwound; its off-row
189192
// chains were allocated by the rolled-back statement and no
190193
// surviving row, undo entry, or version references them.
194+
// Rollback is terminal — the row can't come back and an
195+
// uncommitted insert is invisible to every snapshot — so the
196+
// slot is reclaimable just like a committed DELETE's: mark it
197+
// so Compact packs the row-payload bytes away and later
198+
// inserts reuse the space, instead of leaking a slot's worth
199+
// of bytes per rolled-back insert.
191200
FreeChainsAtSlot(this.Heap, this.PageIndex, this.SlotIndex);
192201
page.DeleteSlot(this.SlotIndex);
202+
page.MarkSlotReclaimable(this.SlotIndex);
203+
this.Heap.MarkPageReclaimable(this.PageIndex);
193204
break;
194205
case UndoKind.Delete:
195206
// Un-tombstone: the row (and its chains) become live again,
@@ -275,6 +286,33 @@ public override void Commit()
275286
}
276287
}
277288

289+
/// <summary>
290+
/// Undo entry for the forwarding-pointer half of a forwarded-row DELETE
291+
/// (the relocated target is handled by a paired <see cref="UndoKind.Delete"/>
292+
/// <see cref="SlotChange"/>). The pointer slot holds a forward pointer, not a
293+
/// row, so commit reclaims only its directory slot — never decoding it for
294+
/// LOB chains — and undo resurrects the pointer and re-registers the target.
295+
/// </summary>
296+
private sealed class ForwardedPointerDelete(Heap heap, int pageIndex, int slotIndex, (int Page, int Slot) target) : UndoEntry
297+
{
298+
public readonly Heap Heap = heap;
299+
public readonly int PageIndex = pageIndex;
300+
public readonly int SlotIndex = slotIndex;
301+
public readonly (int Page, int Slot) Target = target;
302+
303+
public override void Undo()
304+
{
305+
this.Heap.Pages[this.PageIndex].UndeleteSlot(this.SlotIndex);
306+
this.Heap.ReinstateForwardTargetForUndo(this.Target);
307+
}
308+
309+
public override void Commit()
310+
{
311+
this.Heap.Pages[this.PageIndex].MarkSlotReclaimable(this.SlotIndex);
312+
this.Heap.MarkPageReclaimable(this.PageIndex);
313+
}
314+
}
315+
278316
private sealed class TempTableCreation(ConcurrentDictionary<string, HeapTable> owner, string name) : UndoEntry
279317
{
280318
public readonly ConcurrentDictionary<string, HeapTable> Owner = owner;

0 commit comments

Comments
 (0)