wal-deferral.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * WAL checkpoint deferral during bulk indexing (#1231).
  3. *
  4. * The default 1000-page wal_autocheckpoint re-writes hot pages into the main
  5. * DB over and over during a bulk index (~95% of all disk I/O on slow
  6. * storage). indexAll defers auto-checkpointing for the whole run, a
  7. * WalCheckpointValve bounds WAL growth via off-thread PASSIVE checkpoints,
  8. * and the interval is restored afterwards. These tests pin the DB helpers,
  9. * the valve's trigger/dedupe/backpressure logic, and the end-to-end indexAll
  10. * behavior (identical graph with and without deferral; interval restored).
  11. */
  12. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  13. import * as fs from 'fs';
  14. import * as os from 'os';
  15. import * as path from 'path';
  16. import { DatabaseConnection } from '../src/db';
  17. import { WalCheckpointValve, resolveWalValveMb } from '../src/db/wal-valve';
  18. import CodeGraph from '../src/index';
  19. let tmpDir: string;
  20. beforeEach(() => {
  21. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wal-deferral-'));
  22. });
  23. afterEach(() => {
  24. fs.rmSync(tmpDir, { recursive: true, force: true });
  25. });
  26. function openDb(): DatabaseConnection {
  27. return DatabaseConnection.initialize(path.join(tmpDir, 'test.db'));
  28. }
  29. /** Grow the WAL: with autocheckpoint off, every commit appends and nothing folds back. */
  30. function writeRows(db: DatabaseConnection, rows: number): void {
  31. const raw = db.getDb();
  32. raw.exec('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, blob TEXT)');
  33. const stmt = raw.prepare('INSERT INTO t (blob) VALUES (?)');
  34. for (let i = 0; i < rows; i++) stmt.run('x'.repeat(4096));
  35. }
  36. describe('resolveWalValveMb', () => {
  37. it('honors a positive numeric override and falls back otherwise', () => {
  38. expect(resolveWalValveMb('64')).toBe(64);
  39. expect(resolveWalValveMb('64.9')).toBe(64);
  40. expect(resolveWalValveMb(undefined)).toBe(256);
  41. expect(resolveWalValveMb('')).toBe(256);
  42. expect(resolveWalValveMb('abc')).toBe(256);
  43. expect(resolveWalValveMb('0')).toBe(256);
  44. expect(resolveWalValveMb('-5')).toBe(256);
  45. });
  46. });
  47. describe('DatabaseConnection WAL helpers', () => {
  48. it('reads and writes the wal_autocheckpoint interval', () => {
  49. const db = openDb();
  50. expect(db.getWalAutocheckpoint()).toBe(1000); // SQLite default
  51. db.setWalAutocheckpoint(0);
  52. expect(db.getWalAutocheckpoint()).toBe(0);
  53. db.setWalAutocheckpoint(1000);
  54. expect(db.getWalAutocheckpoint()).toBe(1000);
  55. db.close();
  56. });
  57. it('reports WAL size that grows with deferred commits', () => {
  58. const db = openDb();
  59. db.setWalAutocheckpoint(0);
  60. const before = db.getWalSizeBytes();
  61. writeRows(db, 200);
  62. expect(db.getWalSizeBytes()).toBeGreaterThan(before);
  63. db.close();
  64. });
  65. it('checkpointWalPassive backfills the WAL from a worker connection and reports the result', async () => {
  66. const db = openDb();
  67. db.setWalAutocheckpoint(0);
  68. writeRows(db, 500);
  69. const dbFile = path.join(tmpDir, 'test.db');
  70. const mainSizeBefore = fs.statSync(dbFile).size;
  71. const res = await db.checkpointWalPassive();
  72. // Backfill moves the committed pages into the main DB file…
  73. expect(fs.statSync(dbFile).size).toBeGreaterThan(mainSizeBefore);
  74. // …and reports a full backfill (idle DB: every WAL frame checkpointed).
  75. expect(res).not.toBeNull();
  76. expect(res!.busy).toBe(0);
  77. expect(res!.log).toBeGreaterThan(0);
  78. expect(res!.checkpointed).toBe(res!.log);
  79. db.close();
  80. });
  81. });
  82. describe('WalCheckpointValve', () => {
  83. it('check() fires an off-thread checkpoint once growth passes the soft threshold', async () => {
  84. const db = openDb();
  85. db.setWalAutocheckpoint(0);
  86. writeRows(db, 500); // WAL well past a ~10-byte threshold
  87. const valve = new WalCheckpointValve(db, 0.00001); // ~10 bytes soft
  88. const dbFile = path.join(tmpDir, 'test.db');
  89. const mainSizeBefore = fs.statSync(dbFile).size;
  90. valve.check();
  91. await valve.drain();
  92. expect(fs.statSync(dbFile).size).toBeGreaterThan(mainSizeBefore);
  93. db.close();
  94. });
  95. it('advances its baseline on a full backfill — a wrapped WAL does not retrigger it', async () => {
  96. const db = openDb();
  97. db.setWalAutocheckpoint(0);
  98. writeRows(db, 500);
  99. const valve = new WalCheckpointValve(db, 0.00001);
  100. valve.check();
  101. await valve.drain(); // full backfill on an idle DB → baseline = current file size
  102. // The WAL file keeps its high-water size, but growth is now 0: neither
  103. // the timer path nor backpressure may fire again (the pre-fix bug fired
  104. // on raw size forever and serialized every store behind a checkpoint).
  105. expect(valve.backpressure()).toBeNull();
  106. valve.check();
  107. await valve.drain(); // no-op drain: nothing in flight
  108. // New commits recycle wrapped frames — file size is flat, still no trigger.
  109. writeRows(db, 5);
  110. expect(valve.backpressure()).toBeNull();
  111. db.close();
  112. });
  113. it('does not fire below the soft threshold', async () => {
  114. const db = openDb();
  115. db.setWalAutocheckpoint(0);
  116. writeRows(db, 5);
  117. const valve = new WalCheckpointValve(db, 1024); // 1GB soft — never reached
  118. const dbFile = path.join(tmpDir, 'test.db');
  119. const mainSizeBefore = fs.statSync(dbFile).size;
  120. valve.check();
  121. await valve.drain();
  122. expect(fs.statSync(dbFile).size).toBe(mainSizeBefore);
  123. db.close();
  124. });
  125. it('backpressure() is null under the hard cap and a promise above it', async () => {
  126. const db = openDb();
  127. db.setWalAutocheckpoint(0);
  128. writeRows(db, 500);
  129. const relaxed = new WalCheckpointValve(db, 1024);
  130. expect(relaxed.backpressure()).toBeNull();
  131. const strict = new WalCheckpointValve(db, 0.0000001); // hard cap ~0.4 bytes
  132. const bp = strict.backpressure();
  133. expect(bp).toBeInstanceOf(Promise);
  134. await bp;
  135. await strict.drain();
  136. db.close();
  137. });
  138. it('foldNow() backfills everything at a phase boundary and resets growth', async () => {
  139. const db = openDb();
  140. db.setWalAutocheckpoint(0);
  141. writeRows(db, 500);
  142. const valve = new WalCheckpointValve(db, 1024); // thresholds never reached on their own
  143. const dbFile = path.join(tmpDir, 'test.db');
  144. const mainSizeBefore = fs.statSync(dbFile).size;
  145. await valve.foldNow();
  146. expect(fs.statSync(dbFile).size).toBeGreaterThan(mainSizeBefore); // pages backfilled
  147. expect(valve.backpressure()).toBeNull(); // baseline advanced — growth is zero
  148. await valve.foldNow(); // second fold is a no-op (growth 0), must not spin
  149. db.close();
  150. });
  151. it('dedupes concurrent fires into one in-flight checkpoint', () => {
  152. const db = openDb();
  153. db.setWalAutocheckpoint(0);
  154. writeRows(db, 500);
  155. const valve = new WalCheckpointValve(db, 0.00001);
  156. valve.check();
  157. const first = valve.backpressure();
  158. const second = valve.backpressure();
  159. expect(second).toBe(first); // same in-flight promise, not a second worker
  160. db.close();
  161. return first ?? undefined;
  162. });
  163. });
  164. describe('indexAll WAL deferral end-to-end', () => {
  165. function writeFixtureProject(): void {
  166. fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
  167. for (let i = 0; i < 8; i++) {
  168. fs.writeFileSync(
  169. path.join(tmpDir, 'src', `mod${i}.ts`),
  170. `export function fn${i}(x: number): number { return helper${i}(x) + ${i}; }\n` +
  171. `function helper${i}(x: number): number { return x * ${i}; }\n`
  172. );
  173. }
  174. }
  175. it('produces the same graph with and without deferral, and restores the interval', async () => {
  176. writeFixtureProject();
  177. const cg1 = CodeGraph.initSync(tmpDir);
  178. const r1 = await cg1.indexAll();
  179. expect(r1.success).toBe(true);
  180. // Deferral is scoped to the run: the connection is back on the default.
  181. const conn1 = (cg1 as unknown as { db: DatabaseConnection }).db;
  182. expect(conn1.getWalAutocheckpoint()).toBe(1000);
  183. const counts1 = { nodes: r1.nodesCreated, edges: r1.edgesCreated };
  184. await cg1.close();
  185. fs.rmSync(path.join(tmpDir, '.codegraph'), { recursive: true, force: true });
  186. process.env.CODEGRAPH_NO_WAL_DEFER = '1';
  187. try {
  188. const cg2 = CodeGraph.initSync(tmpDir);
  189. const r2 = await cg2.indexAll();
  190. expect(r2.success).toBe(true);
  191. expect({ nodes: r2.nodesCreated, edges: r2.edgesCreated }).toEqual(counts1);
  192. await cg2.close();
  193. } finally {
  194. delete process.env.CODEGRAPH_NO_WAL_DEFER;
  195. }
  196. });
  197. });