1
0

wal-deferral.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  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 — no infinite retrigger (at most one truncate park)', async () => {
  96. // Pre-§7a.1 contract was "a wrapped WAL never retriggers"; the file-size
  97. // trigger deliberately weakens that to "retriggers AT MOST once more, to
  98. // truncate the file, then goes quiet" — the pre-fix bug this test pinned
  99. // (firing on raw size forever, serializing every store) stays dead: a
  100. // successful truncate zeroes the file, so the trigger cannot loop. At
  101. // this test's pathological 10-BYTE soft cap, byte-level residue can trip
  102. // the 4×-soft file cap once; product-scale caps are 256MB/1GB.
  103. const db = openDb();
  104. db.setWalAutocheckpoint(0);
  105. writeRows(db, 500);
  106. const valve = new WalCheckpointValve(db, 0.00001);
  107. valve.check();
  108. await valve.drain(); // full backfill (and possibly a timer truncate)
  109. const first = valve.backpressure();
  110. if (first) await first; // one truncate park allowed — file must be 0 after
  111. expect(db.getWalSizeBytes()).toBe(0);
  112. expect(valve.backpressure()).toBeNull(); // and now: quiet
  113. valve.check();
  114. await valve.drain();
  115. expect(valve.backpressure()).toBeNull();
  116. db.close();
  117. });
  118. it('does not fire below the soft threshold', async () => {
  119. const db = openDb();
  120. db.setWalAutocheckpoint(0);
  121. writeRows(db, 5);
  122. const valve = new WalCheckpointValve(db, 1024); // 1GB soft — never reached
  123. const dbFile = path.join(tmpDir, 'test.db');
  124. const mainSizeBefore = fs.statSync(dbFile).size;
  125. valve.check();
  126. await valve.drain();
  127. expect(fs.statSync(dbFile).size).toBe(mainSizeBefore);
  128. db.close();
  129. });
  130. it('backpressure() is null under the hard cap and a promise above it', async () => {
  131. const db = openDb();
  132. db.setWalAutocheckpoint(0);
  133. writeRows(db, 500);
  134. const relaxed = new WalCheckpointValve(db, 1024);
  135. expect(relaxed.backpressure()).toBeNull();
  136. const strict = new WalCheckpointValve(db, 0.0000001); // hard cap ~0.4 bytes
  137. const bp = strict.backpressure();
  138. expect(bp).toBeInstanceOf(Promise);
  139. await bp;
  140. await strict.drain();
  141. db.close();
  142. });
  143. it('foldNow() backfills everything at a phase boundary and resets growth', async () => {
  144. const db = openDb();
  145. db.setWalAutocheckpoint(0);
  146. writeRows(db, 500);
  147. const valve = new WalCheckpointValve(db, 1024); // thresholds never reached on their own
  148. const dbFile = path.join(tmpDir, 'test.db');
  149. const mainSizeBefore = fs.statSync(dbFile).size;
  150. await valve.foldNow();
  151. expect(fs.statSync(dbFile).size).toBeGreaterThan(mainSizeBefore); // pages backfilled
  152. expect(valve.backpressure()).toBeNull(); // baseline advanced — growth is zero
  153. await valve.foldNow(); // second fold is a no-op (growth 0), must not spin
  154. db.close();
  155. });
  156. it('dedupes concurrent fires into one in-flight checkpoint', () => {
  157. const db = openDb();
  158. db.setWalAutocheckpoint(0);
  159. writeRows(db, 500);
  160. const valve = new WalCheckpointValve(db, 0.00001);
  161. valve.check();
  162. const first = valve.backpressure();
  163. const second = valve.backpressure();
  164. expect(second).toBe(first); // same in-flight promise, not a second worker
  165. db.close();
  166. return first ?? undefined;
  167. });
  168. });
  169. function writeFixtureProject(): void {
  170. fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
  171. for (let i = 0; i < 8; i++) {
  172. fs.writeFileSync(
  173. path.join(tmpDir, 'src', `mod${i}.ts`),
  174. `export function fn${i}(x: number): number { return helper${i}(x) + ${i}; }\n` +
  175. `function helper${i}(x: number): number { return x * ${i}; }\n`
  176. );
  177. }
  178. }
  179. describe('indexAll WAL deferral end-to-end', () => {
  180. it('produces the same graph with and without deferral, and restores the interval', async () => {
  181. writeFixtureProject();
  182. const cg1 = CodeGraph.initSync(tmpDir);
  183. const r1 = await cg1.indexAll();
  184. expect(r1.success).toBe(true);
  185. // Deferral is scoped to the run: the connection is back on the default.
  186. const conn1 = (cg1 as unknown as { db: DatabaseConnection }).db;
  187. expect(conn1.getWalAutocheckpoint()).toBe(1000);
  188. const counts1 = { nodes: r1.nodesCreated, edges: r1.edgesCreated };
  189. await cg1.close();
  190. fs.rmSync(path.join(tmpDir, '.codegraph'), { recursive: true, force: true });
  191. process.env.CODEGRAPH_NO_WAL_DEFER = '1';
  192. try {
  193. const cg2 = CodeGraph.initSync(tmpDir);
  194. const r2 = await cg2.indexAll();
  195. expect(r2.success).toBe(true);
  196. expect({ nodes: r2.nodesCreated, edges: r2.edgesCreated }).toEqual(counts1);
  197. await cg2.close();
  198. } finally {
  199. delete process.env.CODEGRAPH_NO_WAL_DEFER;
  200. }
  201. });
  202. });
  203. describe('sync WAL deferral end-to-end (#1248)', () => {
  204. // The #1242 fix originally landed only on indexAll; sync stayed at the
  205. // default 1000-page autocheckpoint and reproduced the #1231 HDD thrash on
  206. // every incremental run (2 minutes for a 7-file sync). These pin that sync
  207. // defers during the run, restores after — success AND no-change paths —
  208. // and that a deferred sync produces the same graph as an undeferred one.
  209. it('defers the autocheckpoint interval DURING sync and restores it after', async () => {
  210. writeFixtureProject();
  211. const cg = CodeGraph.initSync(tmpDir);
  212. await cg.indexAll();
  213. const conn = (cg as unknown as { db: DatabaseConnection }).db;
  214. fs.writeFileSync(
  215. path.join(tmpDir, 'src', 'mod0.ts'),
  216. `export function fn0(x: number): number { return helper0(x) + 100; }\n` +
  217. `function helper0(x: number): number { return x * 100; }\n`
  218. );
  219. // Sample the interval mid-run from inside the progress callback — the
  220. // store loop is exactly where the #1248 thrash happened.
  221. const midRunIntervals: number[] = [];
  222. const result = await cg.sync({
  223. onProgress: () => {
  224. try { midRunIntervals.push(conn.getWalAutocheckpoint()); } catch { /* ignore */ }
  225. },
  226. });
  227. expect(result.filesModified).toBe(1);
  228. expect(midRunIntervals.length).toBeGreaterThan(0);
  229. expect(midRunIntervals.every((v) => v === 0)).toBe(true);
  230. // Scoped to the run: back on the default afterwards.
  231. expect(conn.getWalAutocheckpoint()).toBe(1000);
  232. await cg.close();
  233. });
  234. it('restores the interval on a no-change sync too', async () => {
  235. writeFixtureProject();
  236. const cg = CodeGraph.initSync(tmpDir);
  237. await cg.indexAll();
  238. const conn = (cg as unknown as { db: DatabaseConnection }).db;
  239. const result = await cg.sync();
  240. expect(result.filesAdded + result.filesModified + result.filesRemoved).toBe(0);
  241. expect(conn.getWalAutocheckpoint()).toBe(1000);
  242. await cg.close();
  243. });
  244. it('produces the same sync result with and without deferral', async () => {
  245. writeFixtureProject();
  246. const cg1 = CodeGraph.initSync(tmpDir);
  247. await cg1.indexAll();
  248. fs.writeFileSync(
  249. path.join(tmpDir, 'src', 'mod1.ts'),
  250. `export function fn1(x: number): number { return helper1(x) + 111; }\n` +
  251. `function helper1(x: number): number { return x * 111; }\n`
  252. );
  253. const r1 = await cg1.sync();
  254. const counts1 = { modified: r1.filesModified, nodes: r1.nodesUpdated };
  255. await cg1.close();
  256. fs.rmSync(path.join(tmpDir, '.codegraph'), { recursive: true, force: true });
  257. process.env.CODEGRAPH_NO_WAL_DEFER = '1';
  258. try {
  259. const cg2 = CodeGraph.initSync(tmpDir);
  260. await cg2.indexAll();
  261. fs.writeFileSync(
  262. path.join(tmpDir, 'src', 'mod1.ts'),
  263. `export function fn1(x: number): number { return helper1(x) + 222; }\n` +
  264. `function helper1(x: number): number { return x * 222; }\n`
  265. );
  266. const r2 = await cg2.sync();
  267. expect({ modified: r2.filesModified, nodes: r2.nodesUpdated }).toEqual(counts1);
  268. await cg2.close();
  269. } finally {
  270. delete process.env.CODEGRAPH_NO_WAL_DEFER;
  271. }
  272. });
  273. });
  274. describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
  275. // The valve's timer-driven passive checkpoints stay perpetually partial
  276. // against the resolver pool's continuous reads, so during resolution the
  277. // writer-side backpressure() hook is the ONLY mechanism that can complete
  278. // a backfill and let the WAL wrap — a kernel-scale run without it grew a
  279. // 22GB WAL on a 4.6GB DB. These pin that the batch loop (a) calls the hook
  280. // at the pool-idle boundary and (b) actually parks on a returned promise.
  281. async function seedPendingRefs(cg: CodeGraph): Promise<void> {
  282. const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
  283. const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
  284. | { id: string; file_path: string }
  285. | undefined;
  286. expect(node).toBeDefined();
  287. const ins = raw.prepare(
  288. "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
  289. );
  290. ins.run(node!.id, 'helper0', node!.file_path);
  291. ins.run(node!.id, 'helper1', node!.file_path);
  292. }
  293. it('calls the backpressure hook once per settled batch', async () => {
  294. writeFixtureProject();
  295. const cg = CodeGraph.initSync(tmpDir);
  296. await cg.indexAll();
  297. await seedPendingRefs(cg);
  298. let calls = 0;
  299. const result = await cg.resolveReferencesBatched(undefined, undefined, () => {
  300. calls++;
  301. return null; // under the hard cap — loop must proceed without waiting
  302. });
  303. expect(result.stats.total).toBeGreaterThan(0);
  304. expect(calls).toBeGreaterThanOrEqual(1);
  305. await cg.close();
  306. });
  307. it('parks the batch loop on a backpressure promise until it resolves', async () => {
  308. writeFixtureProject();
  309. const cg = CodeGraph.initSync(tmpDir);
  310. await cg.indexAll();
  311. await seedPendingRefs(cg);
  312. let release!: () => void;
  313. const gate = new Promise<void>((r) => { release = r; });
  314. let hookHit = false;
  315. const done = cg
  316. .resolveReferencesBatched(undefined, undefined, () => {
  317. if (hookHit) return null; // park only on the first boundary
  318. hookHit = true;
  319. return gate;
  320. })
  321. .then(() => true);
  322. // Give the loop ample turns: it must reach the hook and then be parked.
  323. for (let i = 0; i < 50; i++) await new Promise((r) => setImmediate(r));
  324. expect(hookHit).toBe(true);
  325. const settledEarly = await Promise.race([done, Promise.resolve(false)]);
  326. expect(settledEarly).toBe(false); // still parked on the gate
  327. release();
  328. expect(await done).toBe(true);
  329. await cg.close();
  330. });
  331. });
  332. describe('checkpointWalTruncate (§7a.1 file containment)', () => {
  333. it('chops a fully-backfilled WAL file to zero', async () => {
  334. const db = openDb();
  335. db.setWalAutocheckpoint(0);
  336. writeRows(db, 400);
  337. expect(db.getWalSizeBytes()).toBeGreaterThan(1024 * 1024);
  338. const res = await db.checkpointWalTruncate();
  339. expect(res).not.toBeNull();
  340. expect(res!.busy).toBe(0);
  341. expect(db.getWalSizeBytes()).toBe(0); // the file itself, not just the backlog
  342. db.close();
  343. });
  344. });
  345. describe('valve file-size trigger (§7a.1: backfilled WAL still grows the file)', () => {
  346. it('backpressure trips on file size alone once past the file cap, even with zero backlog', async () => {
  347. const db = openDb();
  348. db.setWalAutocheckpoint(0);
  349. // Grow the file well past a 0.5MB soft cap (file cap = 4× = 2MB), then
  350. // fold the backlog completely so growth-vs-baseline is ~zero.
  351. writeRows(db, 800);
  352. const valve = new WalCheckpointValve(db, 0.5);
  353. await valve.foldNow(); // baseline := file size; backlog now 0; file unchanged
  354. expect(db.getWalSizeBytes()).toBe(0); // foldNow's success path truncates at the barrier
  355. db.close();
  356. });
  357. it('a fully-backfilled but oversized file is chopped at the barrier', async () => {
  358. const db = openDb();
  359. db.setWalAutocheckpoint(0);
  360. writeRows(db, 800);
  361. const before = db.getWalSizeBytes();
  362. expect(before).toBeGreaterThan(2 * 1024 * 1024);
  363. const valve = new WalCheckpointValve(db, 0.5);
  364. const bp = valve.backpressure(); // growth past hard cap → parks
  365. expect(bp).not.toBeNull();
  366. await bp;
  367. expect(db.getWalSizeBytes()).toBe(0); // truncated at the parked barrier
  368. // And the file-size trigger alone re-arms it after regrowth:
  369. writeRows(db, 800);
  370. await valve.foldNow();
  371. writeRows(db, 100); // small backlog, file grows again but under hard cap
  372. const sizeTrigger = valve.backpressure();
  373. // 100 rows ≈ <1MB backlog (under 1MB hard cap) but file is past the 2MB cap
  374. expect(sizeTrigger).not.toBeNull();
  375. await sizeTrigger;
  376. expect(db.getWalSizeBytes()).toBe(0);
  377. db.close();
  378. });
  379. });
  380. describe('resolveWalValveMb DB-size scaling (§7a.2 fold-tax reduction)', () => {
  381. it('scales soft cap ~dbSize/4 within [256, 2048]MB; env always wins', () => {
  382. const GB = 1024 * 1024 * 1024;
  383. expect(resolveWalValveMb(undefined, 100 * 1024 * 1024)).toBe(256); // floor
  384. expect(resolveWalValveMb(undefined, 4.6 * GB)).toBe(1177); // ~dbSize/4
  385. expect(resolveWalValveMb(undefined, 40 * GB)).toBe(2048); // ceiling
  386. expect(resolveWalValveMb('64', 40 * GB)).toBe(64); // env override wins
  387. expect(resolveWalValveMb(undefined, 0)).toBe(256); // unknown size → default
  388. });
  389. });