index.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. /**
  2. * Database Layer
  3. *
  4. * Handles SQLite database initialization and connection management.
  5. */
  6. import { SqliteDatabase, SqliteBackend, createDatabase } from './sqlite-adapter';
  7. import * as fs from 'fs';
  8. import * as path from 'path';
  9. import { SchemaVersion } from '../types';
  10. import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
  11. import { getCodeGraphDir } from '../directory';
  12. export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
  13. /**
  14. * Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two
  15. * paths can't drift.
  16. *
  17. * `busy_timeout` is set FIRST, before any pragma that might touch the database
  18. * file (notably `journal_mode`). If another process holds a write lock at open
  19. * time, the later pragmas — and the connection's first query — then wait out
  20. * the lock instead of throwing "database is locked" immediately. See issue #238.
  21. *
  22. * The 5s window (was 120s) rides out a normal incremental sync; the old
  23. * 2-minute wait presented as a frozen, hung agent. With WAL, reads never block
  24. * on a writer, so this timeout only governs cross-process write contention
  25. * (e.g. the git-hook `codegraph sync` running while the MCP server writes).
  26. */
  27. function configureConnection(db: SqliteDatabase): void {
  28. db.pragma('busy_timeout = 5000'); // MUST be first — see above
  29. db.pragma('foreign_keys = ON');
  30. db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform
  31. db.pragma('synchronous = NORMAL'); // safe with WAL mode
  32. db.pragma('cache_size = -64000'); // 64 MB page cache
  33. db.pragma('temp_store = MEMORY'); // temp tables in memory
  34. db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O
  35. // Without a journal_size_limit the -wal file never shrinks below its
  36. // high-water mark while a connection lives: checkpoints fold frames back but
  37. // leave the file at full size, so one giant deferred-sync WAL stays giant
  38. // forever. With the limit set, any checkpoint that resets the WAL truncates
  39. // the file back down. Killed-process leftovers are handled separately by
  40. // healOversizedWal() at open. (#1431)
  41. db.pragma(`journal_size_limit = ${WAL_HEAL_THRESHOLD_BYTES}`);
  42. }
  43. /**
  44. * WAL size past which `healOversizedWal` (run at every `open`) checkpoints and
  45. * truncates the file, and to which `journal_size_limit` clips the WAL after any
  46. * resetting checkpoint. A SIGKILL'd process (the #850 liveness watchdog, OOM,
  47. * crash) can leave an arbitrarily large WAL behind — a whole deferred-sync
  48. * run's worth (#1248) — and before #1431 no later session ever shrank it: the
  49. * file just grew, killed session after killed session, until the disk filled
  50. * (25.6 GB observed). 64 MB is far above anything a healthy open ever sees
  51. * (a clean close deletes the WAL) yet small enough to cap the leak.
  52. * Override with `CODEGRAPH_WAL_HEAL_MB` (also feeds `journal_size_limit`).
  53. */
  54. export const WAL_HEAL_THRESHOLD_BYTES = resolveWalHealBytes(process.env.CODEGRAPH_WAL_HEAL_MB);
  55. /** Resolve the heal threshold from the env override (MB); invalid ⇒ 64 MB. */
  56. export function resolveWalHealBytes(envVal: string | undefined): number {
  57. if (envVal !== undefined && envVal !== '') {
  58. const n = Number(envVal);
  59. if (Number.isFinite(n) && n > 0) return Math.floor(n * 1024 * 1024);
  60. }
  61. return 64 * 1024 * 1024;
  62. }
  63. /**
  64. * Database connection wrapper with lifecycle management
  65. */
  66. export class DatabaseConnection {
  67. private db: SqliteDatabase;
  68. private dbPath: string;
  69. private backend: SqliteBackend;
  70. /**
  71. * `dev:ino` of the DB file at the moment we opened it (or null when the
  72. * platform/filesystem reports no usable inode). Lets us notice when the file
  73. * we hold open has been unlinked and REPLACED by a new file at the same path
  74. * — a git worktree removed and re-added, or `.codegraph/` deleted and
  75. * re-`init`ed under a long-lived server — at which point our fd reads a now
  76. * dead inode forever (#925). See `isReplacedOnDisk`.
  77. */
  78. private openedInode: string | null;
  79. /**
  80. * Whether FTS5 is available in this Node.js build. When false, search
  81. * falls back to LIKE + fuzzy matching (#1532).
  82. */
  83. readonly fts5Available: boolean;
  84. private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend, fts5Available: boolean) {
  85. this.db = db;
  86. this.dbPath = dbPath;
  87. this.backend = backend;
  88. this.fts5Available = fts5Available;
  89. this.openedInode = statInode(dbPath);
  90. }
  91. /**
  92. * Initialize a new database at the given path
  93. */
  94. static initialize(dbPath: string): DatabaseConnection {
  95. // Ensure parent directory exists
  96. const dir = path.dirname(dbPath);
  97. if (!fs.existsSync(dir)) {
  98. fs.mkdirSync(dir, { recursive: true });
  99. }
  100. // Create and configure database
  101. const { db, backend } = createDatabase(dbPath);
  102. configureConnection(db);
  103. // Run schema initialization, splitting FTS5 from the rest so
  104. // codegraph still works when Node.js was built without FTS5 (#1532).
  105. const schemaPath = path.join(__dirname, 'schema.sql');
  106. const schema = fs.readFileSync(schemaPath, 'utf-8');
  107. const FTS5_MARKER = '-- Full-text search index on node names, docstrings, and signatures';
  108. const ftsIdx = schema.indexOf(FTS5_MARKER);
  109. let fts5Available = true;
  110. if (ftsIdx >= 0) {
  111. const preFts = schema.slice(0, ftsIdx);
  112. // FTS ends after the update trigger; required tables and indexes follow
  113. // it in schema.sql and must still be created when FTS5 is unavailable.
  114. const ftsSection = schema.slice(ftsIdx).match(
  115. /^[\s\S]*?CREATE TRIGGER IF NOT EXISTS nodes_au\b[\s\S]*?END;/
  116. )?.[0];
  117. if (!ftsSection) throw new Error('schema.sql: FTS5 update trigger not found');
  118. // Execute everything before FTS5 first
  119. db.exec(preFts);
  120. // Try FTS5; if it fails, skip it and continue with LIKE-only search
  121. try {
  122. db.exec(ftsSection);
  123. } catch (err: any) {
  124. fts5Available = false;
  125. const msg = err?.message ?? String(err);
  126. console.warn(
  127. `[codegraph] FTS5 not available in this Node.js build (${msg}). ` +
  128. `Search will fall back to LIKE + fuzzy matching. ` +
  129. `For full-text search, use a Node.js build with FTS5 enabled.`
  130. );
  131. }
  132. db.exec(schema.slice(ftsIdx + ftsSection.length));
  133. } else {
  134. db.exec(schema);
  135. }
  136. // Record current schema version so migrations aren't re-applied on open
  137. const currentVersion = getCurrentVersion(db);
  138. if (currentVersion < CURRENT_SCHEMA_VERSION) {
  139. db.prepare(
  140. 'INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
  141. ).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations');
  142. }
  143. return new DatabaseConnection(db, dbPath, backend, fts5Available);
  144. }
  145. /**
  146. * Open an existing database
  147. */
  148. static open(dbPath: string): DatabaseConnection {
  149. if (!fs.existsSync(dbPath)) {
  150. throw new Error(`Database not found: ${dbPath}`);
  151. }
  152. const { db, backend } = createDatabase(dbPath);
  153. configureConnection(db);
  154. // Detect FTS5 availability for search fallback (#1532)
  155. let fts5Available = true;
  156. try {
  157. db.exec("SELECT * FROM nodes_fts LIMIT 0");
  158. } catch {
  159. fts5Available = false;
  160. }
  161. // Check and run migrations if needed
  162. const conn = new DatabaseConnection(db, dbPath, backend, fts5Available);
  163. const currentVersion = getCurrentVersion(db);
  164. if (currentVersion < CURRENT_SCHEMA_VERSION) {
  165. runMigrations(db, currentVersion);
  166. }
  167. // Self-heal a bulk-load window that never closed (crash between
  168. // beginBulkNodeLoad and endBulkNodeLoad): the FTS triggers are missing and
  169. // nodes_fts is stale. Rebuild + recreate so search stays in sync.
  170. conn.healBulkNodeLoad();
  171. conn.healBulkSecondaryIndexes();
  172. // Self-heal a killed session's leftover oversized WAL (#1431) — one
  173. // statSync when healthy, off-thread checkpoint+truncate when not.
  174. void conn.healOversizedWal();
  175. return conn;
  176. }
  177. /**
  178. * FTS maintenance triggers dropped/recreated around a bulk load.
  179. * Names must match schema.sql.
  180. */
  181. private static readonly FTS_TRIGGER_NAMES = ['nodes_ai', 'nodes_ad', 'nodes_au'] as const;
  182. /**
  183. * Enter bulk-load mode: drop the per-row FTS sync triggers so mass node
  184. * inserts skip per-row tokenization. MUST be paired with endBulkNodeLoad()
  185. * (use try/finally); a crash inside the window is healed on the next open().
  186. * The window is DB-wide (triggers are schema objects), which is safe because
  187. * endBulkNodeLoad() rebuilds nodes_fts from the nodes table wholesale — any
  188. * row written by anyone during the window is captured by the rebuild.
  189. */
  190. beginBulkNodeLoad(): void {
  191. if (!this.fts5Available) return;
  192. for (const t of DatabaseConnection.FTS_TRIGGER_NAMES) {
  193. this.db.exec(`DROP TRIGGER IF EXISTS ${t}`);
  194. }
  195. }
  196. /**
  197. * Leave bulk-load mode: rebuild the whole FTS index from the nodes table in
  198. * one pass (far cheaper than per-row trigger firings), then recreate the
  199. * triggers by re-running schema.sql (idempotent — everything in it is
  200. * IF NOT EXISTS).
  201. */
  202. endBulkNodeLoad(): void {
  203. if (!this.fts5Available) return;
  204. this.db.exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`);
  205. this.recreateFtsTriggers();
  206. }
  207. /**
  208. * NON-UNIQUE secondary indexes maintained per-row during the parse phase's
  209. * bulk inserts — the store-architecture arc's first lever (plan §4d: dubbo's
  210. * parse-loop wall is 94% store-writer busy, and the #1320 post-mortem showed
  211. * statement batching and sorted inserts are ~zero on this path because
  212. * B-TREE MAINTENANCE is the floor). A fresh init writes every row of
  213. * nodes/unresolved_refs/files exactly once and reads none of them until
  214. * resolution, so the parse window can drop all of these and rebuild each in
  215. * one table scan afterwards — the same measured trade as the resolution
  216. * phase's edge-index window (2.8s → 1.1s inserting, ~0.3s recreating).
  217. * Primary keys and UNIQUE constraints stay (upserts and OR-IGNORE dedup
  218. * conflict on them).
  219. */
  220. private static readonly BULK_PARSE_INDEX_NAMES = [
  221. 'idx_nodes_kind',
  222. 'idx_nodes_name',
  223. 'idx_nodes_qualified_name',
  224. 'idx_nodes_file_path',
  225. 'idx_nodes_language',
  226. 'idx_nodes_file_line',
  227. 'idx_nodes_lower_name',
  228. 'idx_unresolved_from_node',
  229. 'idx_unresolved_name',
  230. 'idx_unresolved_file_path',
  231. 'idx_unresolved_from_name',
  232. 'idx_unresolved_status',
  233. 'idx_unresolved_failed_tail',
  234. 'idx_files_language',
  235. 'idx_files_modified_at',
  236. ] as const;
  237. /**
  238. * Enter bulk-parse-load mode (FRESH-INIT ONLY — the caller gates on a fresh
  239. * DB, because an incremental index deletes per-file rows mid-phase and needs
  240. * the file_path indexes): drop every parse-lane secondary index, including
  241. * the four non-unique edge indexes (parse inserts contains-edges too; the
  242. * UNIQUE identity index stays for INSERT OR IGNORE dedup, and its `source`
  243. * prefix keeps source-keyed reads indexed, as in the edge window). MUST be
  244. * paired with endBulkParseLoad(); a crash inside the window is healed on the
  245. * next DatabaseConnection open (schema.sql re-applies CREATE INDEX IF NOT
  246. * EXISTS).
  247. */
  248. beginBulkParseLoad(): void {
  249. for (const idx of DatabaseConnection.BULK_PARSE_INDEX_NAMES) {
  250. this.db.exec(`DROP INDEX IF EXISTS ${idx}`);
  251. }
  252. this.beginBulkEdgeLoad();
  253. }
  254. /**
  255. * Leave bulk-parse-load mode: recreate everything the window dropped, one
  256. * table scan per index, with a yield between statements (same
  257. * liveness-watchdog rationale as endBulkEdgeLoad — at kernel scale each
  258. * build is a long synchronous scan). The edge indexes are rebuilt here too,
  259. * so paths that never enter the resolution phase's own bulk-edge window
  260. * (small runs) are left with a complete schema; the batched resolver's
  261. * beginBulkEdgeLoad simply re-drops them (DROP IF EXISTS — idempotent).
  262. */
  263. async endBulkParseLoad(): Promise<void> {
  264. const schemaPath = path.join(__dirname, 'schema.sql');
  265. const schema = fs.readFileSync(schemaPath, 'utf-8');
  266. for (const idx of DatabaseConnection.BULK_PARSE_INDEX_NAMES) {
  267. const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`));
  268. if (!m) throw new Error(`schema.sql: parse index ${idx} not found for bulk-load recreation`);
  269. this.db.exec(m[0]);
  270. await new Promise((resolve) => setImmediate(resolve));
  271. }
  272. await this.endBulkEdgeLoad();
  273. }
  274. /**
  275. * unresolved_refs secondary indexes NOT read by the batched resolution
  276. * loop. The loop pages pending refs by keyset (`status='pending' AND id>?`
  277. * — the status index + PK), deletes resolved rows by id, and parks failures
  278. * with a status UPDATE; every other ref index serves SYNC-time paths
  279. * (per-file re-index deletes, name-keyed retry, failed-tail heal). Each
  280. * per-batch DELETE maintains all of them — the biggest single main-thread
  281. * stage on the dubbo profile (deletes 1.2s of a 5.4s resolution phase) —
  282. * so the batched loop drops them and rebuilds at the end, where the table
  283. * holds only the surviving FAILED refs (resolved rows are gone), making
  284. * the recreate near-free.
  285. */
  286. private static readonly BULK_REF_INDEX_NAMES = [
  287. 'idx_unresolved_from_node',
  288. 'idx_unresolved_name',
  289. 'idx_unresolved_file_path',
  290. 'idx_unresolved_from_name',
  291. 'idx_unresolved_failed_tail',
  292. ] as const;
  293. /**
  294. * Enter bulk-ref mode for the batched resolution loop — see
  295. * BULK_REF_INDEX_NAMES. MUST be paired with endBulkRefLoad(); a crash
  296. * inside the window heals on the next open (schema.sql re-applies
  297. * CREATE INDEX IF NOT EXISTS).
  298. */
  299. beginBulkRefLoad(): void {
  300. for (const idx of DatabaseConnection.BULK_REF_INDEX_NAMES) {
  301. this.db.exec(`DROP INDEX IF EXISTS ${idx}`);
  302. }
  303. }
  304. /** Leave bulk-ref mode: recreate each index in one scan (yield between). */
  305. async endBulkRefLoad(): Promise<void> {
  306. const schemaPath = path.join(__dirname, 'schema.sql');
  307. const schema = fs.readFileSync(schemaPath, 'utf-8');
  308. for (const idx of DatabaseConnection.BULK_REF_INDEX_NAMES) {
  309. const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`));
  310. if (!m) throw new Error(`schema.sql: ref index ${idx} not found for bulk-load recreation`);
  311. this.db.exec(m[0]);
  312. await new Promise((resolve) => setImmediate(resolve));
  313. }
  314. }
  315. /**
  316. * Names of the NON-UNIQUE edge indexes dropped for a bulk edge load.
  317. * idx_edges_identity deliberately stays: INSERT OR IGNORE's dedup conflicts
  318. * on it (#1034), and its leftmost column is `source`, so the source-keyed
  319. * reads resolution makes mid-window (supertype walks over
  320. * `implements`/`extends`) keep an index via its prefix — verified with
  321. * EXPLAIN QUERY PLAN. Target-keyed and kind-keyed reads (traversal,
  322. * synthesis) happen only after endBulkEdgeLoad().
  323. */
  324. private static readonly BULK_EDGE_INDEX_NAMES = [
  325. 'idx_edges_kind',
  326. 'idx_edges_source_kind',
  327. 'idx_edges_target_kind',
  328. 'idx_edges_provenance',
  329. ] as const;
  330. /**
  331. * Enter bulk-edge-load mode: drop the non-unique edge indexes so the mass
  332. * INSERT OR IGNORE stream pays one B-tree (the identity index) instead of
  333. * five — measured 2.8s → 1.1s inserting a 224k-edge resolution set, with
  334. * recreation costing ~0.3s. MUST be paired with endBulkEdgeLoad(); a crash
  335. * inside the window is healed on the next DatabaseConnection open (schema.sql
  336. * re-applies CREATE INDEX IF NOT EXISTS).
  337. */
  338. beginBulkEdgeLoad(): void {
  339. for (const idx of DatabaseConnection.BULK_EDGE_INDEX_NAMES) {
  340. this.db.exec(`DROP INDEX IF EXISTS ${idx}`);
  341. }
  342. }
  343. /**
  344. * Leave bulk-edge-load mode: recreate the dropped indexes in one pass each
  345. * over the (now fully loaded) edges table — far cheaper than maintaining
  346. * them per-insert. DDL is extracted from schema.sql so it cannot drift.
  347. *
  348. * Async with a yield BETWEEN the four CREATE INDEX statements: each build is
  349. * a synchronous scan of the whole edges table (~20s apiece at Linux-kernel
  350. * scale, 79s total measured), and running them back-to-back is a single
  351. * event-loop stall longer than the #850 liveness watchdog's 60s window — a
  352. * daemon-triggered re-index would be SIGKILLed right after doing the work.
  353. * One yield per statement keeps every stall to a single index build, which
  354. * stays inside the window.
  355. */
  356. async endBulkEdgeLoad(): Promise<void> {
  357. const schemaPath = path.join(__dirname, 'schema.sql');
  358. const schema = fs.readFileSync(schemaPath, 'utf-8');
  359. for (const idx of DatabaseConnection.BULK_EDGE_INDEX_NAMES) {
  360. const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`));
  361. if (!m) throw new Error(`schema.sql: edge index ${idx} not found for bulk-load recreation`);
  362. this.db.exec(m[0]);
  363. await new Promise((resolve) => setImmediate(resolve));
  364. }
  365. }
  366. /** Recreate the FTS triggers + rebuild if a bulk-load window never closed. */
  367. private healBulkNodeLoad(): void {
  368. if (!this.fts5Available) return;
  369. const row = this.db
  370. .prepare(
  371. `SELECT count(*) AS c FROM sqlite_master WHERE type = 'trigger' AND name IN ('nodes_ai','nodes_ad','nodes_au')`
  372. )
  373. .get() as { c: number } | undefined;
  374. if ((row?.c ?? 0) >= DatabaseConnection.FTS_TRIGGER_NAMES.length) return;
  375. this.endBulkNodeLoad();
  376. }
  377. /** Recreate every secondary index a killed bulk parse/ref/edge window may leave dropped. */
  378. private healBulkSecondaryIndexes(): void {
  379. const names = [...new Set<string>([
  380. ...DatabaseConnection.BULK_PARSE_INDEX_NAMES,
  381. ...DatabaseConnection.BULK_REF_INDEX_NAMES,
  382. ...DatabaseConnection.BULK_EDGE_INDEX_NAMES,
  383. ])];
  384. const placeholders = names.map(() => '?').join(',');
  385. const row = this.db
  386. .prepare(`SELECT count(*) AS c FROM sqlite_master WHERE type = 'index' AND name IN (${placeholders})`)
  387. .get(...names) as { c: number } | undefined;
  388. if ((row?.c ?? 0) >= names.length) return;
  389. const schemaPath = path.join(__dirname, 'schema.sql');
  390. const schema = fs.readFileSync(schemaPath, 'utf-8');
  391. for (const idx of names) {
  392. const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`));
  393. if (!m) throw new Error(`schema.sql: index ${idx} not found for crash recovery`);
  394. this.db.exec(m[0]);
  395. }
  396. }
  397. /**
  398. * Recreate the FTS sync triggers from schema.sql — extracted from the file
  399. * rather than duplicated here so the DDL cannot drift from the schema.
  400. * (Re-execing the whole schema is not an option: it contains data INSERTs
  401. * that are not idempotent, e.g. schema_versions.)
  402. */
  403. private recreateFtsTriggers(): void {
  404. const schemaPath = path.join(__dirname, 'schema.sql');
  405. const schema = fs.readFileSync(schemaPath, 'utf-8');
  406. const triggerDdls = schema.match(
  407. /CREATE TRIGGER IF NOT EXISTS nodes_a[idu]\b[\s\S]*?END;/g
  408. );
  409. if (!triggerDdls || triggerDdls.length !== DatabaseConnection.FTS_TRIGGER_NAMES.length) {
  410. throw new Error(
  411. `schema.sql: expected ${DatabaseConnection.FTS_TRIGGER_NAMES.length} nodes FTS triggers, found ${triggerDdls?.length ?? 0}`
  412. );
  413. }
  414. for (const ddl of triggerDdls) {
  415. this.db.exec(ddl);
  416. }
  417. }
  418. /**
  419. * Get the underlying database instance
  420. */
  421. getDb(): SqliteDatabase {
  422. return this.db;
  423. }
  424. /**
  425. * Get the SQLite backend serving this connection. Per-instance so
  426. * MCP cross-project queries report the right backend even when
  427. * multiple project DBs are open in the same process.
  428. */
  429. getBackend(): SqliteBackend {
  430. return this.backend;
  431. }
  432. /**
  433. * Get database file path
  434. */
  435. getPath(): string {
  436. return this.dbPath;
  437. }
  438. /**
  439. * The journal mode actually in effect (e.g. 'wal', 'delete').
  440. *
  441. * SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on
  442. * filesystems without shared-memory support (some network/virtualized mounts,
  443. * WSL2 /mnt). So the effective mode can differ
  444. * from what `configureConnection` requested. Surfaced in `codegraph status` so
  445. * a "database is locked" report is triageable: 'wal' ⇒ readers never block on a
  446. * writer; anything else ⇒ they can. See issue #238.
  447. */
  448. getJournalMode(): string {
  449. const raw = this.db.pragma('journal_mode');
  450. const row = Array.isArray(raw) ? raw[0] : raw;
  451. const mode = row && typeof row === 'object'
  452. ? (row as Record<string, unknown>).journal_mode
  453. : row;
  454. return String(mode ?? '').toLowerCase();
  455. }
  456. /**
  457. * Get current schema version
  458. */
  459. getSchemaVersion(): SchemaVersion | null {
  460. const row = this.db
  461. .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1')
  462. .get() as { version: number; applied_at: number; description: string | null } | undefined;
  463. if (!row) return null;
  464. return {
  465. version: row.version,
  466. appliedAt: row.applied_at,
  467. description: row.description ?? undefined,
  468. };
  469. }
  470. /**
  471. * Execute a function within a transaction
  472. */
  473. transaction<T>(fn: () => T): T {
  474. return this.db.transaction(fn)();
  475. }
  476. /**
  477. * Get database file size in bytes
  478. */
  479. getSize(): number {
  480. const stats = fs.statSync(this.dbPath);
  481. return stats.size;
  482. }
  483. /**
  484. * Size of the `-wal` sidecar file in bytes. 0 when it doesn't exist (non-WAL
  485. * journal mode, in-memory DB, or no write since the last checkpoint+reset).
  486. */
  487. getWalSizeBytes(): number {
  488. if (!this.dbPath || this.dbPath === ':memory:') return 0;
  489. try {
  490. return fs.statSync(`${this.dbPath}-wal`).size;
  491. } catch {
  492. return 0;
  493. }
  494. }
  495. /** Size of the main DB file in bytes (0 for in-memory/unknown) — the WAL
  496. * valve scales its fold caps with it (resolveWalValveMb). */
  497. getDbFileSizeBytes(): number {
  498. if (!this.dbPath || this.dbPath === ':memory:') return 0;
  499. try {
  500. return fs.statSync(this.dbPath).size;
  501. } catch {
  502. return 0;
  503. }
  504. }
  505. /** Current `wal_autocheckpoint` interval in pages (0 = disabled). */
  506. getWalAutocheckpoint(): number {
  507. const v = this.db.pragma('wal_autocheckpoint', { simple: true });
  508. const n = Number(v);
  509. return Number.isFinite(n) ? n : 0;
  510. }
  511. /**
  512. * Set the connection's `wal_autocheckpoint` interval (pages; 0 disables).
  513. * Bulk indexing defers checkpoints entirely (#1231): the default 1000-page
  514. * auto-checkpoint re-writes hot B-tree/FTS pages into the main DB file over
  515. * and over — measured at ~95% of ALL disk I/O during a bulk index, and the
  516. * difference between 45s and 19+ minutes on HDD-class storage. During
  517. * deferral a {@link WalCheckpointValve} bounds WAL growth off-thread.
  518. */
  519. setWalAutocheckpoint(pages: number): void {
  520. this.db.pragma(`wal_autocheckpoint = ${Math.max(0, Math.floor(pages))}`);
  521. }
  522. /**
  523. * `PRAGMA wal_checkpoint(PASSIVE)` on a worker thread with its own
  524. * connection. PASSIVE never blocks the writer, and running it off-thread
  525. * means the main thread — and the #850 watchdog heartbeat — keep turning
  526. * even when the backfill is minutes of I/O on slow storage (a synchronous
  527. * checkpoint that exceeds the watchdog's 60s window gets a healthy index
  528. * SIGKILLed — observed in the #1231 repro).
  529. *
  530. * Returns SQLite's checkpoint result row — `log === checkpointed` with
  531. * `busy === 0` means the ENTIRE WAL was backfilled, so the writer's next
  532. * commit restarts the WAL from the top and the file stops growing. The
  533. * WAL valve needs that signal because a WAL file's SIZE never shrinks:
  534. * after the first wrap, raw file size says nothing about the un-backfilled
  535. * backlog. Best-effort: returns null on any failure (including worker
  536. * threads being unavailable — a potentially minutes-long checkpoint must
  537. * never run inline on the main thread).
  538. */
  539. async checkpointWalPassive(): Promise<{ busy: number; log: number; checkpointed: number } | null> {
  540. return this.checkpointWal('PASSIVE');
  541. }
  542. /**
  543. * `PRAGMA wal_checkpoint(TRUNCATE)` — same off-thread pattern as PASSIVE,
  544. * but on success the WAL FILE is chopped to zero. A completed passive
  545. * backfill bounds the un-checkpointed backlog, yet the FILE only stops
  546. * growing when a commit finds ZERO readers holding WAL marks — rare while
  547. * pool workers cycle, so at kernel scale a fully-backfilled WAL still
  548. * accreted the phase's whole write volume on disk (§7a.1: 22GB). The valve
  549. * calls this exactly at a parked barrier (writer parked, pool drained,
  550. * backfill complete) where the no-reader condition is guaranteed rather
  551. * than lucky. The worker sets a short busy_timeout so a racing reader
  552. * degrades this to a no-op (busy=1) instead of a stall.
  553. */
  554. async checkpointWalTruncate(): Promise<{ busy: number; log: number; checkpointed: number } | null> {
  555. return this.checkpointWal('TRUNCATE');
  556. }
  557. /**
  558. * Shrink a leftover oversized WAL (#1431). A SIGKILL'd session — the #850
  559. * liveness watchdog, OOM, a crash — leaves its WAL on disk, the next session
  560. * appends to the same file, and (pre-#1431) nothing ever truncated it:
  561. * PASSIVE checkpoints fold frames but keep the file at its high-water mark,
  562. * and the one shrinking path (a clean last-connection close) is exactly what
  563. * the killed world never takes. Unbounded growth until the disk fills.
  564. *
  565. * Called fire-and-forget from every `open()`: cost is one statSync when the
  566. * WAL is small (the overwhelmingly common case). Past the threshold it runs
  567. * the off-thread PASSIVE fold then TRUNCATE — both on worker connections
  568. * with a busy_timeout, so a racing writer degrades this to a no-op that the
  569. * next open retries rather than a stall.
  570. */
  571. async healOversizedWal(): Promise<{ healed: boolean; beforeBytes: number; afterBytes: number }> {
  572. const beforeBytes = this.getWalSizeBytes();
  573. if (beforeBytes <= WAL_HEAL_THRESHOLD_BYTES) {
  574. return { healed: false, beforeBytes, afterBytes: beforeBytes };
  575. }
  576. // Single-flight: open() fires this fire-and-forget and callers may also
  577. // invoke it explicitly. Two concurrent passes DEFEAT each other — each
  578. // checkpoint worker sees the other as a busy reader and no-ops — so share
  579. // one in-flight pass instead of racing.
  580. this.walHeal ??= this.runWalHeal(beforeBytes).finally(() => { this.walHeal = null; });
  581. return this.walHeal;
  582. }
  583. private walHeal: Promise<{ healed: boolean; beforeBytes: number; afterBytes: number }> | null = null;
  584. private async runWalHeal(beforeBytes: number): Promise<{ healed: boolean; beforeBytes: number; afterBytes: number }> {
  585. // A racing reader/writer (another session healing the same file, a query
  586. // pool warming up) degrades a checkpoint pass to a busy no-op — retry a
  587. // few times before leaving the rest to the next open.
  588. for (let attempt = 0; attempt < 3; attempt++) {
  589. if (attempt > 0) await new Promise((r) => setTimeout(r, 300));
  590. await this.checkpointWalPassive();
  591. await this.checkpointWalTruncate();
  592. if (this.getWalSizeBytes() <= WAL_HEAL_THRESHOLD_BYTES) break;
  593. }
  594. const afterBytes = this.getWalSizeBytes();
  595. if (process.env.CODEGRAPH_WAL_VALVE_DEBUG) {
  596. console.error(`[wal-heal] oversized WAL at open: ${Math.round(beforeBytes / (1024 * 1024))}MB -> ${Math.round(afterBytes / (1024 * 1024))}MB`);
  597. }
  598. return { healed: afterBytes < beforeBytes, beforeBytes, afterBytes };
  599. }
  600. private async checkpointWal(mode: 'PASSIVE' | 'TRUNCATE'): Promise<{ busy: number; log: number; checkpointed: number } | null> {
  601. if (!this.dbPath || this.dbPath === ':memory:') {
  602. try {
  603. const row = this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as Record<string, number> | undefined;
  604. return row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null;
  605. } catch {
  606. return null;
  607. }
  608. }
  609. try {
  610. const { Worker } = await import('node:worker_threads');
  611. const workerSource = `
  612. const { workerData, parentPort } = require('node:worker_threads');
  613. let row = null;
  614. let err = null;
  615. try {
  616. const { DatabaseSync } = require('node:sqlite');
  617. const db = new DatabaseSync(workerData.dbPath);
  618. const mode = workerData.mode === 'TRUNCATE' ? 'TRUNCATE' : 'PASSIVE';
  619. try {
  620. if (mode === 'TRUNCATE') db.exec('PRAGMA busy_timeout = 2000');
  621. row = db.prepare('PRAGMA wal_checkpoint(' + mode + ')').get();
  622. } catch (e) { err = String(e && e.message || e); }
  623. try { db.close(); } catch {}
  624. } catch (e) { err = err || String(e && e.message || e); }
  625. parentPort.postMessage({ row, err });
  626. `;
  627. return await new Promise((resolve) => {
  628. let settled = false;
  629. const finish = (row?: Record<string, number> | null): void => {
  630. if (settled) return;
  631. settled = true;
  632. resolve(row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null);
  633. };
  634. try {
  635. const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath, mode } });
  636. worker.once('message', (m: { row?: Record<string, number> | null; err?: string | null }) => {
  637. if (m?.err && process.env.CODEGRAPH_WAL_VALVE_DEBUG) {
  638. console.error(`[wal-valve] checkpoint worker (${mode}): ${m.err}`);
  639. }
  640. void worker.terminate();
  641. finish(m?.row ?? null);
  642. });
  643. worker.once('error', () => { void worker.terminate(); finish(null); });
  644. worker.once('exit', () => finish(null));
  645. } catch {
  646. finish(null);
  647. }
  648. });
  649. } catch {
  650. return null;
  651. }
  652. }
  653. /**
  654. * Optimize database (vacuum and analyze)
  655. */
  656. optimize(): void {
  657. this.db.exec('VACUUM');
  658. this.db.exec('ANALYZE');
  659. }
  660. /**
  661. * Lightweight maintenance to run after bulk writes (indexAll, sync).
  662. * Two operations:
  663. *
  664. * - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes
  665. * tables whose row counts changed materially since the last
  666. * ANALYZE. Without it, the query planner has no statistics on the
  667. * freshly-bulk-loaded tables and can pick suboptimal indexes.
  668. *
  669. * - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back
  670. * into the main database file so the WAL file doesn't grow
  671. * unboundedly between automatic checkpoints (auto-fires at 1000
  672. * pages by default; large indexAll runs blow past that).
  673. *
  674. * Runs on a WORKER THREAD with its own connection: on a multi-GB index
  675. * these pragmas are minutes of synchronous IO (a 95k-file kernel index
  676. * left a 593MB WAL whose checkpoint alone blew the #850 watchdog's 60s
  677. * window and got a COMPLETED index SIGKILLed at the finish line). WAL
  678. * checkpointing from a second connection is standard SQLite; `PRAGMA
  679. * optimize` persists its statistics in sqlite_stat tables, so the main
  680. * connection benefits the same. The main thread just awaits a message,
  681. * so the event loop — and the watchdog heartbeat — keep turning.
  682. *
  683. * Everything is silently swallowed on failure — best-effort
  684. * optimization, never load-bearing for correctness. If worker threads
  685. * are unavailable, falls back to a bounded in-line `PRAGMA optimize`
  686. * and SKIPS the checkpoint (the final close() checkpoints after the
  687. * CLI has already disarmed its watchdog).
  688. */
  689. async runMaintenance(): Promise<void> {
  690. // In-memory / test databases: nothing worth a worker round-trip.
  691. if (!this.dbPath || this.dbPath === ':memory:') {
  692. try { this.db.exec('PRAGMA optimize'); } catch { /* ignore */ }
  693. try { this.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); } catch { /* ignore */ }
  694. return;
  695. }
  696. await this.runPragmasOffThread(
  697. ['PRAGMA analysis_limit=1000', 'PRAGMA optimize', 'PRAGMA wal_checkpoint(PASSIVE)'],
  698. // Worker threads unavailable — bounded in-line fallback, no checkpoint.
  699. ['PRAGMA analysis_limit=1000', 'PRAGMA optimize']
  700. );
  701. }
  702. /**
  703. * Run pragmas on a worker thread against its own connection to this DB
  704. * (shared machinery for {@link runMaintenance} and
  705. * {@link checkpointWalPassive}). Each pragma is individually best-effort;
  706. * the whole call is best-effort. `inlineFallback` (if any) runs on THIS
  707. * connection only when worker threads are unavailable — keep it to pragmas
  708. * that are safe to run synchronously on the main thread.
  709. */
  710. private async runPragmasOffThread(pragmas: string[], inlineFallback: string[] = []): Promise<void> {
  711. try {
  712. const { Worker } = await import('node:worker_threads');
  713. const workerSource = `
  714. const { workerData, parentPort } = require('node:worker_threads');
  715. try {
  716. const { DatabaseSync } = require('node:sqlite');
  717. const db = new DatabaseSync(workerData.dbPath);
  718. for (const p of workerData.pragmas) { try { db.exec(p); } catch {} }
  719. try { db.close(); } catch {}
  720. } catch {}
  721. parentPort.postMessage('done');
  722. `;
  723. await new Promise<void>((resolve) => {
  724. let settled = false;
  725. const finish = (): void => {
  726. if (!settled) { settled = true; resolve(); }
  727. };
  728. try {
  729. const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath, pragmas } });
  730. worker.once('message', () => { void worker.terminate(); finish(); });
  731. worker.once('error', () => { void worker.terminate(); finish(); });
  732. worker.once('exit', finish);
  733. } catch {
  734. finish();
  735. }
  736. });
  737. } catch {
  738. for (const p of inlineFallback) {
  739. try { this.db.exec(p); } catch { /* ignore */ }
  740. }
  741. }
  742. }
  743. /**
  744. * Close the database connection
  745. */
  746. close(): void {
  747. this.db.close();
  748. }
  749. /**
  750. * Check if the database connection is open
  751. */
  752. isOpen(): boolean {
  753. return this.db.open;
  754. }
  755. /**
  756. * True when the DB file at our path has been REPLACED on disk since we opened
  757. * it — a different inode now lives at the same path, so the fd we still hold
  758. * points at a now-unlinked inode that can never receive new writes (#925).
  759. * The trigger is removing and recreating `.codegraph/` at the same path under
  760. * a long-lived process (`git worktree remove` + re-add, or `rm -rf
  761. * .codegraph` + `codegraph init`). Returns false when the inode is unchanged,
  762. * when the file is momentarily absent (mid-recreate — nothing to reopen onto
  763. * yet), or when the platform doesn't report a usable inode (Windows can't
  764. * unlink an open file and its st_ino is unreliable, so this never fires there).
  765. */
  766. isReplacedOnDisk(): boolean {
  767. if (this.openedInode === null) return false;
  768. const current = statInode(this.dbPath);
  769. return current !== null && current !== this.openedInode;
  770. }
  771. }
  772. /**
  773. * `dev:ino` for a path, or null if it can't be stat'd or the platform doesn't
  774. * report a usable inode. Windows st_ino is unreliable across handle reopens, so
  775. * we deliberately return null there — the deleted-but-open-inode hazard this
  776. * guards (#925) is a POSIX file-semantics issue that doesn't arise on Windows
  777. * (an open file can't be unlinked).
  778. */
  779. function statInode(p: string): string | null {
  780. if (process.platform === 'win32') return null;
  781. try {
  782. const s = fs.statSync(p);
  783. return `${s.dev}:${s.ino}`;
  784. } catch {
  785. return null;
  786. }
  787. }
  788. /**
  789. * Default database filename
  790. */
  791. export const DATABASE_FILENAME = 'codegraph.db';
  792. /**
  793. * SQLite's sidecar files in WAL mode — the write-ahead log and its shared-memory
  794. * index. They sit beside the main DB file and are removed alongside it when the
  795. * database is discarded (see `removeDatabaseFiles`).
  796. */
  797. const WAL_SIDECAR_SUFFIXES = ['-wal', '-shm'] as const;
  798. /**
  799. * Get the default database path for a project
  800. */
  801. export function getDatabasePath(projectRoot: string): string {
  802. return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME);
  803. }
  804. /**
  805. * Delete a database file and its WAL sidecars (`-wal`/`-shm`).
  806. *
  807. * This is how a FULL re-index discards an existing database — rather than
  808. * opening the old graph and DELETE-ing every row. On a large or pre-fix
  809. * poisoned index (e.g. an old graph that scanned an ignored gitlink corpus into
  810. * ~1.6M nodes with a multi-GB WAL, #1065) the per-row `nodes_fts` delete-trigger
  811. * churn blocks the main thread long enough to trip the #850 liveness watchdog
  812. * before indexing even starts, so the rebuild could never recover the bad state
  813. * (#1067). Unlinking is O(1) regardless of DB size and also reclaims the disk
  814. * the bloated WAL would otherwise keep.
  815. *
  816. * POSIX removes the directory entry even while another process (a daemon/MCP
  817. * server) still holds the file open; that holder heals via `reopenIfReplaced`
  818. * (#925). On Windows a live holder can make the unlink fail with EBUSY/EPERM —
  819. * that is thrown for the caller to surface ("stop the other process and retry").
  820. * The `-wal`/`-shm` sidecars are best-effort: SQLite recreates them on the next
  821. * open, so a leftover sidecar is harmless.
  822. */
  823. export function removeDatabaseFiles(dbPath: string): void {
  824. // The main DB file first — its removal is the operation that must succeed (or
  825. // report why it couldn't). force:true treats an already-missing file as done.
  826. fs.rmSync(dbPath, { force: true });
  827. for (const suffix of WAL_SIDECAR_SUFFIXES) {
  828. try {
  829. fs.rmSync(dbPath + suffix, { force: true });
  830. } catch {
  831. // A sidecar still held/locked is harmless — SQLite rebuilds it on open.
  832. }
  833. }
  834. }