Skip to content

Commit e388046

Browse files
committed
Basic implementation of DBCC SHRINKDATABASE and SHRINKFILE to trim trailing unused data pages.
1 parent 6600890 commit e388046

9 files changed

Lines changed: 506 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 backing lists never shrink below their high-water mark**: 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, not the current live size — a fully-dead 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. 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. 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).
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.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator.Storage;
4+
5+
/// <summary>
6+
/// Guards <c>DBCC SHRINKDATABASE</c> / <c>DBCC SHRINKFILE</c> trailing-trim: the
7+
/// reclamation work bounds <see cref="Heap.Pages"/> / <see cref="Heap.LobPages"/>
8+
/// by the peak working set but never lets the lists fall below their high-water
9+
/// mark on their own (stable <c>(page, slot)</c> addresses forbid mid-list
10+
/// removal). A shrink drops the fully-dead / freed pages off the *tail* of those
11+
/// lists, lowering the high-water mark — but only the trailing run, so interior
12+
/// dead pages and any version- or lock-pinned tail page stay put.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class ShrinkTests
16+
{
17+
private static Heap HeapFor(SimulatedDbConnection conn, string table) =>
18+
conn.CurrentDatabase.Schemas[Database.DefaultSchemaName].HeapTables[table].Heap;
19+
20+
private static void Exec(SimulatedDbConnection conn, string sql)
21+
{
22+
using var cmd = conn.CreateCommand();
23+
cmd.CommandText = sql;
24+
_ = cmd.ExecuteNonQuery();
25+
}
26+
27+
// Fill many pages, delete every row (each autocommit DELETE commits dead
28+
// bytes), then shrink. With the whole heap dead, the trailing run is the
29+
// entire list, so the page list collapses.
30+
[TestMethod]
31+
public void ShrinkDatabase_DropsFullyDeadTrailingDataPages()
32+
{
33+
var conn = new Simulation().CreateDbConnection();
34+
conn.Open();
35+
Exec(conn, "create table t (id int not null primary key, v varchar(7000) not null)");
36+
for (var id = 1; id <= 30; id++)
37+
Exec(conn, $"insert t values ({id}, replicate('a', 7000))");
38+
39+
var grown = HeapFor(conn, "t").Pages.Count;
40+
IsGreaterThan(5, grown, "setup should have allocated many page-sized rows");
41+
42+
Exec(conn, "delete from t");
43+
Exec(conn, "dbcc shrinkdatabase (simulated)");
44+
45+
IsLessThanOrEqualTo(1, HeapFor(conn, "t").Pages.Count, "an all-dead heap should shrink its page list to the trailing-live remainder (here, empty).");
46+
}
47+
48+
// Free a whole heap's worth of LOB chains, then shrink: the trailing free
49+
// run is every LOB page, so the list collapses to near zero.
50+
[TestMethod]
51+
public void ShrinkDatabase_DropsFreedTrailingLobPages()
52+
{
53+
var conn = new Simulation().CreateDbConnection();
54+
conn.Open();
55+
Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)");
56+
for (var id = 1; id <= 20; id++)
57+
Exec(conn, $"insert t values ({id}, replicate(N'a', 200))");
58+
59+
IsGreaterThan(0, HeapFor(conn, "t").LobPages.Count, "MAX values should have pushed off-row LOB pages");
60+
61+
Exec(conn, "delete from t");
62+
Exec(conn, "dbcc shrinkdatabase (simulated)");
63+
64+
IsLessThanOrEqualTo(1, HeapFor(conn, "t").LobPages.Count, "freeing every chain then shrinking should drop the trailing free LOB pages.");
65+
}
66+
67+
// Delete the *leading* rows but keep a live row on the last page: the tail
68+
// page isn't dead, so the trailing run is empty and nothing is dropped —
69+
// even though interior pages are reclaimable.
70+
[TestMethod]
71+
public void ShrinkDatabase_KeepsInteriorDeadPagesWhenTailIsLive()
72+
{
73+
var conn = new Simulation().CreateDbConnection();
74+
conn.Open();
75+
Exec(conn, "create table t (id int not null primary key, v varchar(7000) not null)");
76+
for (var id = 1; id <= 10; id++)
77+
Exec(conn, $"insert t values ({id}, replicate('a', 7000))");
78+
79+
var grown = HeapFor(conn, "t").Pages.Count;
80+
81+
// Kill everything except the row on the last page.
82+
Exec(conn, "delete from t where id < 10");
83+
Exec(conn, "dbcc shrinkdatabase (simulated)");
84+
85+
HasCount(grown, HeapFor(conn, "t").Pages, "a live row on the tail page blocks the trailing trim; interior dead pages can't be removed without renumbering.");
86+
}
87+
88+
// A surviving row must read back intact after a shrink rearranges the lists
89+
// around it.
90+
[TestMethod]
91+
public void ShrinkDatabase_PreservesLiveValues()
92+
{
93+
var conn = new Simulation().CreateDbConnection();
94+
conn.Open();
95+
Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)");
96+
Exec(conn, "insert t values (1, N'keep-me')");
97+
for (var id = 2; id <= 20; id++)
98+
Exec(conn, $"insert t values ({id}, replicate(N'a', 200))");
99+
Exec(conn, "delete from t where id >= 2");
100+
101+
Exec(conn, "dbcc shrinkdatabase (simulated)");
102+
103+
using var cmd = conn.CreateCommand();
104+
cmd.CommandText = "select v from t where id = 1";
105+
AreEqual("keep-me", (string)cmd.ExecuteScalar()!);
106+
}
107+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Public-surface tests for <c>DBCC SHRINKDATABASE</c> / <c>DBCC SHRINKFILE</c>.
7+
/// The commands reclaim memory by trimming dead trailing pages; these assert the
8+
/// observable contract — SHRINKDATABASE returns no result set, SHRINKFILE returns
9+
/// the probe-confirmed per-file report row, the statements leave data intact, and
10+
/// an unknown database name raises Msg 2520. The high-water-mark drop itself is
11+
/// covered by <c>ShrinkTests</c> in the internal suite (it needs <c>Heap</c> access).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class ShrinkDatabaseTests
15+
{
16+
[TestMethod]
17+
public void ShrinkFile_ReturnsDocumentedReportRow()
18+
{
19+
using var reader = new Simulation().ExecuteReader("dbcc shrinkfile (1, 0)");
20+
21+
AreEqual(6, reader.FieldCount);
22+
AreEqual("DbId", reader.GetName(0));
23+
AreEqual("FileId", reader.GetName(1));
24+
AreEqual("CurrentSize", reader.GetName(2));
25+
AreEqual("MinimumSize", reader.GetName(3));
26+
AreEqual("UsedPages", reader.GetName(4));
27+
AreEqual("EstimatedPages", reader.GetName(5));
28+
AreEqual(typeof(short), reader.GetFieldType(0));
29+
AreEqual(typeof(int), reader.GetFieldType(1));
30+
31+
IsTrue(reader.Read());
32+
AreEqual((short)1, reader.GetInt16(0));
33+
AreEqual(1, reader.GetInt32(1));
34+
IsFalse(reader.Read());
35+
}
36+
37+
[TestMethod]
38+
public void ShrinkDatabase_ReturnsNoResultSet()
39+
{
40+
using var reader = new Simulation().ExecuteReader("dbcc shrinkdatabase (simulated)");
41+
AreEqual(0, reader.FieldCount);
42+
IsFalse(reader.HasRows);
43+
}
44+
45+
[TestMethod]
46+
public void ShrinkDatabase_RunsAndPreservesData()
47+
{
48+
var sim = new Simulation();
49+
_ = sim.ExecuteNonQuery("""
50+
create table t (id int not null primary key, v varchar(7000) not null);
51+
insert t values (1, replicate('a', 7000)), (2, replicate('b', 7000));
52+
delete from t where id = 2;
53+
dbcc shrinkdatabase (simulated)
54+
""");
55+
AreEqual(1, sim.ExecuteScalar("select count(*) from t"));
56+
AreEqual("aaaa", (string)sim.ExecuteScalar("select left(v, 4) from t where id = 1")!);
57+
// Reuse after a shrink still works.
58+
_ = sim.ExecuteNonQuery("insert t values (3, replicate('c', 7000))");
59+
AreEqual(2, sim.ExecuteScalar("select count(*) from t"));
60+
}
61+
62+
[TestMethod]
63+
public void ShrinkDatabase_WithNoInfoMsgs_Parses()
64+
=> AreEqual(1, new Simulation().ExecuteScalar("""
65+
create table t (id int);
66+
insert t values (1);
67+
dbcc shrinkdatabase (simulated) with no_infomsgs;
68+
select count(*) from t
69+
"""));
70+
71+
[TestMethod]
72+
public void ShrinkFile_WithTruncateOnlyOption_Parses()
73+
{
74+
// SHRINKFILE emits its report row even mid-batch, so drive it through
75+
// ExecuteNonQuery (which ignores result sets) and assert data intact.
76+
var sim = new Simulation();
77+
_ = sim.ExecuteNonQuery("""
78+
create table t (id int);
79+
insert t values (1);
80+
dbcc shrinkfile (1, 0, truncateonly)
81+
""");
82+
AreEqual(1, sim.ExecuteScalar("select count(*) from t"));
83+
}
84+
85+
[TestMethod]
86+
public void ShrinkDatabase_UnknownName_Msg2520()
87+
{
88+
var ex = new Simulation().AssertSqlError("dbcc shrinkdatabase (no_such_db)", 2520);
89+
Contains("Could not find database 'no_such_db'", ex.Message);
90+
}
91+
}

SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ internal static SimulatedSqlException InvalidCompatibilityLevel() =>
3737
internal static SimulatedSqlException DatabaseDoesNotExist(string databaseName) =>
3838
new($"Database '{databaseName}' does not exist. Make sure that the name is entered correctly.", 911, 16, 1);
3939

40+
/// <summary>
41+
/// Mimics SQL Server error 2520: <c>DBCC SHRINKDATABASE(&lt;name&gt;)</c>
42+
/// names a database not present in this <see cref="Simulation"/>. Distinct
43+
/// from the <c>USE</c> path's Msg 911 — DBCC reports its own wording.
44+
/// Probe-confirmed against SQL Server 2025: Class 16 State 12, the
45+
/// "querying the sys.databases catalog view" suffix.
46+
/// </summary>
47+
internal static SimulatedSqlException CouldNotFindDatabase(string databaseName) =>
48+
new($"Could not find database '{databaseName}'. The database either does not exist, or was dropped before a statement tried to use it. Verify if the database exists by querying the sys.databases catalog view.", 2520, 16, 12);
49+
4050
/// <summary>
4151
/// Mimics SQL Server error 2760: a statement referenced a schema that
4252
/// doesn't exist (or whose principal the caller can't access). Probe-

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ enum ContextualKeyword
8585
Scoped,
8686
Sequence,
8787
Sets,
88+
ShrinkDatabase,
89+
ShrinkFile,
8890
Spatial,
8991
Target,
9092
SetError,

0 commit comments

Comments
 (0)