|
| 1 | +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; |
| 2 | + |
| 3 | +namespace SqlServerSimulator.Storage; |
| 4 | + |
| 5 | +/// <summary> |
| 6 | +/// Guards LOB-chain reclamation (Stage 1 of the heap space-reclamation work): |
| 7 | +/// an UPDATE / DELETE that supersedes an off-row <c>nvarchar(MAX)</c> value |
| 8 | +/// must return the old chain's pages to the heap's free-list rather than |
| 9 | +/// orphaning them in <see cref="Heap.LobPages"/>. Without reclamation |
| 10 | +/// a logical dataset of constant size grows <see cref="Heap.LobPages"/> |
| 11 | +/// without bound under mutation churn — the leak that made long-running |
| 12 | +/// simulations a memory problem. These tests assert the page count stays a |
| 13 | +/// small multiple of the live working set, not a function of the churn count. |
| 14 | +/// </summary> |
| 15 | +[TestClass] |
| 16 | +public sealed class LobReclamationTests |
| 17 | +{ |
| 18 | + private static Heap HeapFor(SimulatedDbConnection conn, string table) => |
| 19 | + conn.CurrentDatabase.Schemas[Database.DefaultSchemaName].HeapTables[table].Heap; |
| 20 | + |
| 21 | + private static void Exec(SimulatedDbConnection conn, string sql) |
| 22 | + { |
| 23 | + using var cmd = conn.CreateCommand(); |
| 24 | + cmd.CommandText = sql; |
| 25 | + _ = cmd.ExecuteNonQuery(); |
| 26 | + } |
| 27 | + |
| 28 | + // One logical row, repeatedly rewritten with a fresh off-row value. The |
| 29 | + // working set is a single chain (one page for the ~200-byte payload); the |
| 30 | + // free-list must keep total page count bounded regardless of update count. |
| 31 | + [TestMethod] |
| 32 | + public void RepeatedUpdate_OfMaxValue_KeepsLobPagesBounded() |
| 33 | + { |
| 34 | + var conn = new Simulation().CreateDbConnection(); |
| 35 | + conn.Open(); |
| 36 | + Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)"); |
| 37 | + Exec(conn, "insert t values (1, replicate(N'a', 100))"); |
| 38 | + |
| 39 | + for (var i = 0; i < 100; i++) |
| 40 | + Exec(conn, $"update t set v = replicate(N'b', 100) where id = 1"); |
| 41 | + |
| 42 | + // Live set is one chain (one page). Allow generous slack for the |
| 43 | + // in-flight old+new overlap during a single statement. |
| 44 | + IsLessThanOrEqualTo(4, HeapFor(conn, "t").LobPages.Count, "LobPages should stay near the one-chain working set; the free-list bounds repeated-UPDATE churn."); |
| 45 | + } |
| 46 | + |
| 47 | + // Delete then re-insert the same logical row many times. Each DELETE |
| 48 | + // supersedes a chain; the free-list must reclaim it for the next INSERT. |
| 49 | + [TestMethod] |
| 50 | + public void DeleteInsertChurn_KeepsLobPagesBounded() |
| 51 | + { |
| 52 | + var conn = new Simulation().CreateDbConnection(); |
| 53 | + conn.Open(); |
| 54 | + Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)"); |
| 55 | + |
| 56 | + for (var i = 0; i < 100; i++) |
| 57 | + { |
| 58 | + Exec(conn, "insert t values (1, replicate(N'a', 100))"); |
| 59 | + Exec(conn, "delete from t where id = 1"); |
| 60 | + } |
| 61 | + |
| 62 | + IsLessThanOrEqualTo(4, HeapFor(conn, "t").LobPages.Count, "DELETE should free the chain for the next INSERT to reuse."); |
| 63 | + } |
| 64 | + |
| 65 | + // A rolled-back INSERT's chain must not leak (closes the legacy |
| 66 | + // "orphaned LOB chains for rolled-back inserts also leak" quirk). |
| 67 | + [TestMethod] |
| 68 | + public void RolledBackInsert_FreesItsChain() |
| 69 | + { |
| 70 | + var conn = new Simulation().CreateDbConnection(); |
| 71 | + conn.Open(); |
| 72 | + Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)"); |
| 73 | + |
| 74 | + for (var i = 0; i < 100; i++) |
| 75 | + { |
| 76 | + Exec(conn, "begin tran; insert t values (1, replicate(N'a', 100)); rollback"); |
| 77 | + } |
| 78 | + |
| 79 | + IsLessThanOrEqualTo(4, HeapFor(conn, "t").LobPages.Count, "Rolled-back INSERT chains should be reclaimed on rollback."); |
| 80 | + } |
| 81 | + |
| 82 | + // A rolled-back UPDATE's freshly-allocated new chain must be reclaimed on |
| 83 | + // rollback, while the restored old value's chain stays live. |
| 84 | + [TestMethod] |
| 85 | + public void RolledBackUpdate_FreesNewChain() |
| 86 | + { |
| 87 | + var conn = new Simulation().CreateDbConnection(); |
| 88 | + conn.Open(); |
| 89 | + Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)"); |
| 90 | + Exec(conn, "insert t values (1, replicate(N'a', 100))"); |
| 91 | + |
| 92 | + for (var i = 0; i < 100; i++) |
| 93 | + Exec(conn, "begin tran; update t set v = replicate(N'b', 100) where id = 1; rollback"); |
| 94 | + |
| 95 | + IsLessThanOrEqualTo(4, HeapFor(conn, "t").LobPages.Count, "A rolled-back UPDATE should reclaim its new chain and keep only the restored original."); |
| 96 | + } |
| 97 | + |
| 98 | + // Inside one explicit transaction the superseded chains can't be reclaimed |
| 99 | + // mid-flight (a rollback might still need them), so LobPages grows to the |
| 100 | + // tx's peak. The win is reuse: after the tx commits those chains are free, |
| 101 | + // so a second equally-long transaction draws from the free-list instead of |
| 102 | + // growing the page list further. |
| 103 | + [TestMethod] |
| 104 | + public void ReclaimedChains_AreReusedAcrossTransactions() |
| 105 | + { |
| 106 | + var conn = new Simulation().CreateDbConnection(); |
| 107 | + conn.Open(); |
| 108 | + Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)"); |
| 109 | + Exec(conn, "insert t values (1, replicate(N'a', 100))"); |
| 110 | + |
| 111 | + const string churnTx = "begin tran; declare @i int = 0; while @i < 100 begin update t set v = replicate(N'b', 100) where id = 1; set @i += 1; end; commit"; |
| 112 | + Exec(conn, churnTx); |
| 113 | + var afterFirst = HeapFor(conn, "t").LobPages.Count; |
| 114 | + Exec(conn, churnTx); |
| 115 | + var afterSecond = HeapFor(conn, "t").LobPages.Count; |
| 116 | + |
| 117 | + IsLessThanOrEqualTo(afterFirst, afterSecond, $"Second churn tx (peak {afterSecond}) should reuse the first's freed pages (peak {afterFirst}), not grow the list."); |
| 118 | + } |
| 119 | + |
| 120 | + // Under READ_COMMITTED_SNAPSHOT a superseded chain is pinned by its history |
| 121 | + // entry until version-store GC (which runs at each tx commit) trims it; with |
| 122 | + // no concurrent snapshot reader that happens immediately, so churn stays |
| 123 | + // bounded across committed transactions. |
| 124 | + [TestMethod] |
| 125 | + public void VersionedUpdate_ReclaimsViaGarbageCollection() |
| 126 | + { |
| 127 | + var conn = new Simulation().CreateDbConnection(); |
| 128 | + conn.Open(); |
| 129 | + Exec(conn, "alter database simulated set read_committed_snapshot on"); |
| 130 | + Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)"); |
| 131 | + Exec(conn, "insert t values (1, replicate(N'a', 100))"); |
| 132 | + |
| 133 | + for (var i = 0; i < 100; i++) |
| 134 | + Exec(conn, "begin tran; update t set v = replicate(N'b', 100) where id = 1; commit"); |
| 135 | + |
| 136 | + 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."); |
| 137 | + } |
| 138 | + |
| 139 | + // Reclamation must not corrupt a surviving value: the live row reads back |
| 140 | + // correctly after extensive churn around it. |
| 141 | + [TestMethod] |
| 142 | + public void ReclamationPreservesLiveValue() |
| 143 | + { |
| 144 | + var conn = new Simulation().CreateDbConnection(); |
| 145 | + conn.Open(); |
| 146 | + Exec(conn, "create table t (id int not null primary key, v nvarchar(max) not null)"); |
| 147 | + Exec(conn, "insert t values (1, N'keep-me')"); |
| 148 | + Exec(conn, "insert t values (2, replicate(N'x', 200))"); |
| 149 | + |
| 150 | + for (var i = 0; i < 50; i++) |
| 151 | + Exec(conn, $"update t set v = replicate(N'y', 200) where id = 2"); |
| 152 | + |
| 153 | + using var cmd = conn.CreateCommand(); |
| 154 | + cmd.CommandText = "select v from t where id = 1"; |
| 155 | + AreEqual("keep-me", (string)cmd.ExecuteScalar()!); |
| 156 | + } |
| 157 | +} |
0 commit comments