wal-heal.test.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. /**
  2. * Regression tests for #1431: a SIGKILL'd session (the #850 liveness watchdog,
  3. * OOM, a crash) leaves the SQLite WAL on disk; the next session appends to the
  4. * same file; and before the fix NOTHING ever truncated it — PASSIVE
  5. * checkpoints fold frames but keep the file at its high-water mark, and the
  6. * only shrinking path (a clean last-connection close) is exactly what a
  7. * killed-daemon world never takes. Observed in the wild at 25.6 GB on a
  8. * 5.46 GB database, growing until the disk filled.
  9. *
  10. * The fix: `journal_size_limit` on every connection (resetting checkpoints now
  11. * clip the file), plus `healOversizedWal()` fired from every
  12. * `DatabaseConnection.open` (off-thread PASSIVE fold + TRUNCATE when the WAL
  13. * exceeds the threshold).
  14. *
  15. * The killed writer here reproduces the real shape: same open pragmas as
  16. * `configureConnection`, `wal_autocheckpoint = 0` (deferred-checkpoint sync
  17. * mode, #1248), bulk writes, then SIGKILL mid-session with the connection open.
  18. */
  19. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  20. import * as fs from 'fs';
  21. import * as os from 'os';
  22. import * as path from 'path';
  23. import { spawn } from 'child_process';
  24. import {
  25. DatabaseConnection,
  26. WAL_HEAL_THRESHOLD_BYTES,
  27. resolveWalHealBytes,
  28. } from '../src/db/index';
  29. import { watchdogProgressPaths, stampLogChunk } from '../src/mcp/index';
  30. const MB = 1024 * 1024;
  31. // Writer child: real codegraph pragmas + deferred checkpointing, grows the WAL
  32. // past the target, prints READY, then idles with the connection open until the
  33. // parent SIGKILLs it (what the liveness watchdog does to a daemon).
  34. const WRITER_SOURCE = `
  35. const { DatabaseSync } = require('node:sqlite');
  36. const fs = require('fs');
  37. const dbPath = process.argv[1];
  38. const targetBytes = Number(process.argv[2]);
  39. const db = new DatabaseSync(dbPath);
  40. db.exec('PRAGMA busy_timeout = 5000');
  41. db.exec('PRAGMA journal_mode = WAL');
  42. db.exec('PRAGMA synchronous = NORMAL');
  43. db.exec('PRAGMA wal_autocheckpoint = 0');
  44. db.exec('CREATE TABLE IF NOT EXISTS junk (id INTEGER PRIMARY KEY, blob BLOB)');
  45. const ins = db.prepare('INSERT INTO junk (blob) VALUES (?)');
  46. const chunk = Buffer.alloc(256 * 1024, 0xab);
  47. const walSize = () => { try { return fs.statSync(dbPath + '-wal').size; } catch (e) { return 0; } };
  48. while (walSize() < targetBytes) {
  49. db.exec('BEGIN');
  50. for (let i = 0; i < 20; i++) ins.run(chunk);
  51. db.exec('COMMIT');
  52. }
  53. process.stdout.write('READY\\n');
  54. setInterval(() => {}, 1000);
  55. `;
  56. async function growWalThenSigkill(dbPath: string, targetBytes: number): Promise<void> {
  57. const child = spawn(process.execPath, ['-e', WRITER_SOURCE, dbPath, String(targetBytes)], {
  58. stdio: ['ignore', 'pipe', 'inherit'],
  59. // Keep the child's cwd off the temp dir (Windows EPERM-on-cleanup quirk).
  60. cwd: os.tmpdir(),
  61. });
  62. await new Promise<void>((resolve, reject) => {
  63. let out = '';
  64. child.stdout!.on('data', (d) => {
  65. out += String(d);
  66. if (out.includes('READY')) resolve();
  67. });
  68. child.on('exit', (code) => reject(new Error(`writer exited early (code ${code})`)));
  69. setTimeout(() => reject(new Error('timed out growing the WAL')), 90_000);
  70. });
  71. child.kill('SIGKILL');
  72. await new Promise((r) => child.on('exit', r));
  73. }
  74. describe('WAL heal after killed sessions (#1431)', () => {
  75. let dir: string;
  76. let dbPath: string;
  77. const walSize = (): number => {
  78. try { return fs.statSync(`${dbPath}-wal`).size; } catch { return 0; }
  79. };
  80. beforeEach(() => {
  81. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wal-heal-'));
  82. dbPath = path.join(dir, 'codegraph.db');
  83. DatabaseConnection.initialize(dbPath).close();
  84. });
  85. afterEach(() => {
  86. fs.rmSync(dir, { recursive: true, force: true });
  87. });
  88. it('resolves the heal threshold from the env override, defaulting to 64 MB', () => {
  89. expect(resolveWalHealBytes(undefined)).toBe(64 * MB);
  90. expect(resolveWalHealBytes('')).toBe(64 * MB);
  91. expect(resolveWalHealBytes('nope')).toBe(64 * MB);
  92. expect(resolveWalHealBytes('-3')).toBe(64 * MB);
  93. expect(resolveWalHealBytes('128')).toBe(128 * MB);
  94. });
  95. it('sets journal_size_limit on every connection so resetting checkpoints clip the file', () => {
  96. const conn = DatabaseConnection.open(dbPath);
  97. try {
  98. // Private-field peek: journal_size_limit is per-connection, so only this
  99. // connection can report it.
  100. const raw = (conn as unknown as { db: { pragma(q: string, o: { simple: true }): unknown } }).db
  101. .pragma('journal_size_limit', { simple: true });
  102. expect(Number(raw)).toBe(WAL_HEAL_THRESHOLD_BYTES);
  103. } finally {
  104. conn.close();
  105. }
  106. });
  107. it('leaves healthy small WALs alone', async () => {
  108. const conn = DatabaseConnection.open(dbPath);
  109. try {
  110. const res = await conn.healOversizedWal();
  111. expect(res.healed).toBe(false);
  112. expect(res.beforeBytes).toBeLessThanOrEqual(WAL_HEAL_THRESHOLD_BYTES);
  113. } finally {
  114. conn.close();
  115. }
  116. });
  117. it('reproduces the ratchet and heals it: killed sessions stack the WAL, open() truncates it', async () => {
  118. // Session 1 killed mid-write: WAL survives the SIGKILL.
  119. await growWalThenSigkill(dbPath, WAL_HEAL_THRESHOLD_BYTES / 2);
  120. const afterFirstKill = walSize();
  121. expect(afterFirstKill).toBeGreaterThanOrEqual(WAL_HEAL_THRESHOLD_BYTES / 2);
  122. // Session 2 appends to the SAME file — the unbounded ratchet.
  123. await growWalThenSigkill(dbPath, WAL_HEAL_THRESHOLD_BYTES + 8 * MB);
  124. const afterSecondKill = walSize();
  125. expect(afterSecondKill).toBeGreaterThan(afterFirstKill);
  126. expect(afterSecondKill).toBeGreaterThan(WAL_HEAL_THRESHOLD_BYTES);
  127. // The next session opens the DB: the heal folds + truncates. (open() also
  128. // fires the heal itself, so await an explicit pass rather than asserting
  129. // on the racing return values — the on-disk size is the invariant.)
  130. const conn = DatabaseConnection.open(dbPath);
  131. try {
  132. await conn.healOversizedWal();
  133. expect(walSize()).toBeLessThan(WAL_HEAL_THRESHOLD_BYTES);
  134. // The folded data is all there.
  135. const rows = (conn as unknown as { db: { prepare(q: string): { get(): { n: number } } } }).db
  136. .prepare('SELECT COUNT(*) AS n FROM junk').get();
  137. expect(rows.n).toBeGreaterThan(0);
  138. } finally {
  139. conn.close();
  140. }
  141. }, 180_000);
  142. it('open() itself kicks off the heal without being asked', async () => {
  143. await growWalThenSigkill(dbPath, WAL_HEAL_THRESHOLD_BYTES + 8 * MB);
  144. expect(walSize()).toBeGreaterThan(WAL_HEAL_THRESHOLD_BYTES);
  145. const conn = DatabaseConnection.open(dbPath); // fire-and-forget heal
  146. try {
  147. const deadline = Date.now() + 30_000;
  148. while (walSize() > WAL_HEAL_THRESHOLD_BYTES && Date.now() < deadline) {
  149. await new Promise((r) => setTimeout(r, 200));
  150. }
  151. expect(walSize()).toBeLessThanOrEqual(WAL_HEAL_THRESHOLD_BYTES);
  152. } finally {
  153. conn.close();
  154. }
  155. }, 180_000);
  156. });
  157. describe('daemon observability for watchdog kills (#1431)', () => {
  158. it('derives watchdog progressPaths from the project root', () => {
  159. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wd-paths-'));
  160. try {
  161. const { progressPaths } = watchdogProgressPaths(dir);
  162. expect(progressPaths).toHaveLength(2);
  163. expect(progressPaths![0].endsWith(path.join('.codegraph', 'codegraph.db'))).toBe(true);
  164. expect(progressPaths![1]).toBe(`${progressPaths![0]}-wal`);
  165. expect(watchdogProgressPaths(null)).toEqual({});
  166. } finally {
  167. fs.rmSync(dir, { recursive: true, force: true });
  168. }
  169. });
  170. it('stamps log chunks with an ISO-8601 timestamp', () => {
  171. const iso = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] /;
  172. expect(String(stampLogChunk('[CodeGraph daemon] Listening.\n'))).toMatch(iso);
  173. const stamped = stampLogChunk(Buffer.from('bytes\n'));
  174. expect(Buffer.isBuffer(stamped)).toBe(true);
  175. expect(String(stamped)).toMatch(iso);
  176. expect(String(stamped).endsWith('bytes\n')).toBe(true);
  177. });
  178. });