diff --git a/src/domain/graph/builder/helpers.ts b/src/domain/graph/builder/helpers.ts index 16dda21f..2232e488 100644 --- a/src/domain/graph/builder/helpers.ts +++ b/src/domain/graph/builder/helpers.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { purgeFilesData } from '../../../db/index.js'; import { debug, warn } from '../../../infrastructure/logger.js'; +import { getOrCreatePerDbChunkStmt } from '../../../shared/chunked-stmt-cache.js'; import { buildIgnoreSet, EXTENSIONS, normalizePath } from '../../../shared/constants.js'; import { toErrorMessage } from '../../../shared/errors.js'; import { compileGlobs, globToRegex, matchesAny } from '../../../shared/globs.js'; @@ -357,33 +358,8 @@ const BATCH_CHUNK = 500; const nodeStmtCache = new WeakMap>(); const edgeStmtCache = new WeakMap>(); -/** - * Get (or lazily prepare + cache) a multi-value INSERT statement for a given - * chunk size, keyed per-database. Shared by getNodeStmt/getEdgeStmt, which - * previously duplicated this exact WeakMap> - * cache-getter shape — only the SQL text differed. - */ -function getOrCreateBatchStmt( - cache: WeakMap>, - db: BetterSqlite3Database, - chunkSize: number, - buildSql: (chunkSize: number) => string, -): SqliteStatement { - let perDb = cache.get(db); - if (!perDb) { - perDb = new Map(); - cache.set(db, perDb); - } - let stmt = perDb.get(chunkSize); - if (!stmt) { - stmt = db.prepare(buildSql(chunkSize)); - perDb.set(chunkSize, stmt); - } - return stmt; -} - function getNodeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement { - return getOrCreateBatchStmt(nodeStmtCache, db, chunkSize, (n) => { + return getOrCreatePerDbChunkStmt(nodeStmtCache, db, chunkSize, (n) => { const ph = '(?,?,?,?,?,?,?,?,?)'; return ( 'INSERT OR IGNORE INTO nodes (name,kind,file,line,end_line,parent_id,qualified_name,scope,visibility) VALUES ' + @@ -393,7 +369,7 @@ function getNodeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatem } function getEdgeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement { - return getOrCreateBatchStmt(edgeStmtCache, db, chunkSize, (n) => { + return getOrCreatePerDbChunkStmt(edgeStmtCache, db, chunkSize, (n) => { const ph = '(?,?,?,?,?,?,?)'; return ( 'INSERT INTO edges (source_id,target_id,kind,confidence,dynamic,technique,dynamic_kind) VALUES ' + @@ -451,7 +427,7 @@ export function batchInsertEdges(db: BetterSqlite3Database, rows: unknown[][]): const exportStmtCache = new WeakMap>(); function getExportStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement { - return getOrCreateBatchStmt(exportStmtCache, db, chunkSize, (n) => { + return getOrCreatePerDbChunkStmt(exportStmtCache, db, chunkSize, (n) => { const conditions = Array.from( { length: n }, () => '(name = ? AND kind = ? AND file = ? AND line = ?)', diff --git a/src/features/structure.ts b/src/features/structure.ts index 015bd6c3..81a4838f 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -2,8 +2,13 @@ import path from 'node:path'; import { getBuildMeta, getNodeId, setBuildMeta, testFilterSQL } from '../db/index.js'; import { cachedStmt } from '../db/repository/cached-stmt.js'; import { debug } from '../infrastructure/logger.js'; +import { getOrCreateChunkStmt } from '../shared/chunked-stmt-cache.js'; import { getAncestorDirs, normalizePath } from '../shared/constants.js'; -import type { BetterSqlite3Database, StmtCache } from '../types.js'; +import type { + BetterSqlite3Database, + SqliteStatement as DbSqliteStatement, + StmtCache, +} from '../types.js'; // isBarrelProdReachable's two queries are identical text on every call (the // interpolated test-file filter is a fixed set of LIKE patterns keyed only @@ -547,19 +552,17 @@ function batchUpdateRoles( resetFn: () => void, ): void { const ROLE_CHUNK = 500; - const roleStmtCache = new Map(); + const roleStmtCache = new Map(); db.transaction(() => { resetFn(); for (const [role, ids] of idsByRole) { for (let i = 0; i < ids.length; i += ROLE_CHUNK) { const end = Math.min(i + ROLE_CHUNK, ids.length); const chunkSize = end - i; - let stmt = roleStmtCache.get(chunkSize); - if (!stmt) { - const placeholders = Array.from({ length: chunkSize }, () => '?').join(','); - stmt = db.prepare(`UPDATE nodes SET role = ? WHERE id IN (${placeholders})`); - roleStmtCache.set(chunkSize, stmt); - } + const stmt = getOrCreateChunkStmt(roleStmtCache, db, chunkSize, (n) => { + const placeholders = Array.from({ length: n }, () => '?').join(','); + return `UPDATE nodes SET role = ? WHERE id IN (${placeholders})`; + }); const vals: unknown[] = [role]; for (let j = i; j < end; j++) vals.push(ids[j]); stmt.run(...vals); diff --git a/src/shared/chunked-stmt-cache.ts b/src/shared/chunked-stmt-cache.ts new file mode 100644 index 00000000..0c9c0bea --- /dev/null +++ b/src/shared/chunked-stmt-cache.ts @@ -0,0 +1,58 @@ +/** + * Chunk-size-keyed prepared-statement cache. + * + * Many call sites build a multi-value SQL statement (`INSERT ... VALUES + * (?,?,?),(?,?,?),...` or `UPDATE ... WHERE id IN (?,?,?)`) sized to a batch + * of rows, then re-run that exact statement for every batch of the same size. + * Recompiling the statement per batch is wasteful — this module memoizes the + * prepared statement by chunk size so it is built once per distinct size. + * + * Extracted from a duplicate between the node/edge/export batch-insert + * helpers in `domain/graph/builder/helpers.ts` and the node-role batch-update + * in `features/structure.ts`, which each re-implemented this exact + * check-cache/prepare/cache-set shape independently. + */ +import type { BetterSqlite3Database, SqliteStatement } from '../types.js'; + +/** + * Get (or lazily prepare + cache) a SQL statement for a given chunk size, + * memoized in `cache`. `buildSql` is only invoked on a cache miss. + */ +export function getOrCreateChunkStmt( + cache: Map>, + db: BetterSqlite3Database, + chunkSize: number, + buildSql: (chunkSize: number) => string, +): SqliteStatement { + let stmt = cache.get(chunkSize); + if (!stmt) { + stmt = db.prepare(buildSql(chunkSize)); + cache.set(chunkSize, stmt); + } + return stmt; +} + +/** + * Per-database variant of {@link getOrCreateChunkStmt}: resolves (or lazily + * creates) the chunk-size cache scoped to `db` inside `dbCache`, then + * delegates to it. + * + * Use this when the cache must persist across many calls (e.g. repeated + * batch inserts over the lifetime of a build) and needs to be keyed per + * database instance — so independent connections (such as isolated test + * databases) never collide, and cached statements are released once `db` is + * garbage collected. + */ +export function getOrCreatePerDbChunkStmt( + dbCache: WeakMap>>, + db: BetterSqlite3Database, + chunkSize: number, + buildSql: (chunkSize: number) => string, +): SqliteStatement { + let perDb = dbCache.get(db); + if (!perDb) { + perDb = new Map(); + dbCache.set(db, perDb); + } + return getOrCreateChunkStmt(perDb, db, chunkSize, buildSql); +}