Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/db/repository/cached-stmt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { BetterSqlite3Database, SqliteStatement, StmtCache } from '../../types.js';
import type {
BetterSqlite3Database,
ChunkStmtCache,
SqliteStatement,
StmtCache,
} from '../../types.js';

/**
* Resolve a cached prepared statement, compiling on first use per db.
Expand All @@ -18,3 +23,30 @@ export function cachedStmt<TRow = unknown>(
}
return stmt;
}

/**
* Resolve a cached prepared statement for a multi-value INSERT/UPDATE whose
* SQL text depends on a chunk size (e.g. the number of `?` placeholders in
* an `IN (...)` clause), compiling on first use per db + chunk size.
*
* `buildSql` is only invoked on a cache miss; subsequent calls with the same
* `db`/`chunkSize` pair return the cached statement without re-invoking it.
*/
export function cachedChunkStmt<TRow = unknown>(
cache: ChunkStmtCache<TRow>,
db: BetterSqlite3Database,
chunkSize: number,
buildSql: (chunkSize: number) => string,
): SqliteStatement<TRow> {
let perDb = cache.get(db);
if (!perDb) {
perDb = new Map();
cache.set(db, perDb);
}
let stmt = perDb.get(chunkSize);
if (!stmt) {
stmt = db.prepare<TRow>(buildSql(chunkSize));
perDb.set(chunkSize, stmt);
}
return stmt;
}
2 changes: 1 addition & 1 deletion src/db/repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
export type { FnDepsCallerNode, FnDepsEntry, FnDepsNode, FnDepsResult } from './base.js';
export { Repository } from './base.js';
export { purgeFileData, purgeFilesData } from './build-stmts.js';
export { cachedStmt } from './cached-stmt.js';
export { cachedChunkStmt, cachedStmt } from './cached-stmt.js';
export { deleteCfgForNode, getCfgBlocks, getCfgEdges, hasCfgTables } from './cfg.js';
export { getCoChangeMeta, hasCoChanges, upsertCoChangeMeta } from './cochange.js';
export { getComplexityForNode } from './complexity.js';
Expand Down
39 changes: 8 additions & 31 deletions src/domain/graph/builder/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { purgeFilesData } from '../../../db/index.js';
import { cachedChunkStmt } from '../../../db/repository/cached-stmt.js';
Comment on lines 9 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 cachedChunkStmt is now re-exported from the db barrel (db/index.js), which this file already imports for purgeFilesData. Keeping the direct sub-path import (db/repository/cached-stmt.js) as a separate line is inconsistent with how every other db/ helper is consumed here.

Suggested change
import { purgeFilesData } from '../../../db/index.js';
import { cachedChunkStmt } from '../../../db/repository/cached-stmt.js';
import { cachedChunkStmt, purgeFilesData } from '../../../db/index.js';

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

import { debug, warn } from '../../../infrastructure/logger.js';
import { buildIgnoreSet, EXTENSIONS, normalizePath } from '../../../shared/constants.js';
import { toErrorMessage } from '../../../shared/errors.js';
import { compileGlobs, globToRegex, matchesAny } from '../../../shared/globs.js';
import { sleepSync } from '../../../shared/sleep.js';
import type {
BetterSqlite3Database,
ChunkStmtCache,
CodegraphConfig,
PathAliases,
SqliteStatement,
Expand Down Expand Up @@ -354,36 +356,11 @@ export function purgeFilesFromGraph(
const BATCH_CHUNK = 500;

// Statement caches keyed by chunk size — avoids recompiling for every batch.
const nodeStmtCache = new WeakMap<BetterSqlite3Database, Map<number, SqliteStatement>>();
const edgeStmtCache = new WeakMap<BetterSqlite3Database, Map<number, SqliteStatement>>();

/**
* 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<db, Map<chunkSize, stmt>>
* cache-getter shape — only the SQL text differed.
*/
function getOrCreateBatchStmt(
cache: WeakMap<BetterSqlite3Database, Map<number, SqliteStatement>>,
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;
}
const nodeStmtCache: ChunkStmtCache = new WeakMap();
const edgeStmtCache: ChunkStmtCache = new WeakMap();

function getNodeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement {
return getOrCreateBatchStmt(nodeStmtCache, db, chunkSize, (n) => {
return cachedChunkStmt(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 ' +
Expand All @@ -393,7 +370,7 @@ function getNodeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatem
}

function getEdgeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement {
return getOrCreateBatchStmt(edgeStmtCache, db, chunkSize, (n) => {
return cachedChunkStmt(edgeStmtCache, db, chunkSize, (n) => {
const ph = '(?,?,?,?,?,?,?)';
return (
'INSERT INTO edges (source_id,target_id,kind,confidence,dynamic,technique,dynamic_kind) VALUES ' +
Expand Down Expand Up @@ -448,10 +425,10 @@ export function batchInsertEdges(db: BetterSqlite3Database, rows: unknown[][]):
});
}

const exportStmtCache = new WeakMap<BetterSqlite3Database, Map<number, SqliteStatement>>();
const exportStmtCache: ChunkStmtCache = new WeakMap();

function getExportStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement {
return getOrCreateBatchStmt(exportStmtCache, db, chunkSize, (n) => {
return cachedChunkStmt(exportStmtCache, db, chunkSize, (n) => {
const conditions = Array.from(
{ length: n },
() => '(name = ? AND kind = ? AND file = ? AND line = ?)',
Expand Down
29 changes: 19 additions & 10 deletions src/features/structure.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import path from 'node:path';
import { getBuildMeta, getNodeId, setBuildMeta, testFilterSQL } from '../db/index.js';
import { cachedChunkStmt } from '../db/repository/cached-stmt.js';
Comment on lines 2 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Both structure.ts and builder/helpers.ts already import other symbols from the db barrel (../db/index.js), which now re-exports cachedChunkStmt. Adding a second import line that reaches into the implementation file directly (../db/repository/cached-stmt.js) creates two entry-points for the same module and diverges from the pattern used for every other db/ symbol in this file.

Suggested change
import { getBuildMeta, getNodeId, setBuildMeta, testFilterSQL } from '../db/index.js';
import { cachedChunkStmt } from '../db/repository/cached-stmt.js';
import { cachedChunkStmt, getBuildMeta, getNodeId, setBuildMeta, testFilterSQL } from '../db/index.js';

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

import { debug } from '../infrastructure/logger.js';
import { getAncestorDirs, normalizePath } from '../shared/constants.js';
import type { BetterSqlite3Database } from '../types.js';
import type { BetterSqlite3Database, ChunkStmtCache } from '../types.js';

// ─── Build-time helpers ───────────────────────────────────────────────

Expand Down Expand Up @@ -527,6 +528,22 @@ function buildRoleSummary(
return { summary, idsByRole };
}

/** Batch UPDATE chunk size for role writes. */
const ROLE_CHUNK = 500;

// Statement cache keyed by chunk size, per db — avoids recompiling the
// `UPDATE nodes SET role = ? WHERE id IN (...)` statement on every batch or
// build. Shared shape with the node/edge/export batch-statement caches in
// domain/graph/builder/helpers.ts, via `cachedChunkStmt`.
const roleStmtCache: ChunkStmtCache = new WeakMap();

function getRoleStmt(db: BetterSqlite3Database, chunkSize: number) {
return cachedChunkStmt(roleStmtCache, db, chunkSize, (n) => {
const placeholders = Array.from({ length: n }, () => '?').join(',');
return `UPDATE nodes SET role = ? WHERE id IN (${placeholders})`;
});
}

/**
* Batch-update node roles in the database. Executes a reset callback
* first (full resets all nodes, incremental resets only affected files),
Expand All @@ -537,20 +554,12 @@ function batchUpdateRoles(
idsByRole: Map<string, number[]>,
resetFn: () => void,
): void {
const ROLE_CHUNK = 500;
const roleStmtCache = new Map<number, SqliteStatement>();
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 = getRoleStmt(db, end - i);
const vals: unknown[] = [role];
for (let j = i; j < end; j++) vals.push(ids[j]);
stmt.run(...vals);
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2238,6 +2238,17 @@ export interface BetterSqlite3Database {
/** WeakMap-based statement cache: one prepared statement per db instance. */
export type StmtCache<TRow = unknown> = WeakMap<BetterSqlite3Database, SqliteStatement<TRow>>;

/**
* WeakMap-based statement cache keyed by db instance, then by a numeric
* chunk size — for multi-value INSERT/UPDATE statements whose SQL text
* depends on how many rows are batched together (e.g. the number of `?`
* placeholders in an `IN (...)` clause).
*/
export type ChunkStmtCache<TRow = unknown> = WeakMap<
BetterSqlite3Database,
Map<number, SqliteStatement<TRow>>
>;

// ════════════════════════════════════════════════════════════════════════
// §22 Native Addon (napi-rs FFI boundary)
// ════════════════════════════════════════════════════════════════════════
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,4 +449,79 @@ describe('classifyNodeRoles', () => {
expect(getRole('ChaContext.implementors')).toBe('leaf');
expect(getRole('trulyDeadHelper')).toBe('dead-unresolved');
});

// ── Chunked role-statement cache (issue #1767) ──

it('correctly writes roles for a batch spanning multiple statement chunk sizes', () => {
// batchUpdateRoles chunks writes in groups of 500. A single role with
// 650 ids exercises both a full-size (500) and a remainder-size (150)
// prepared statement from the shared chunk-keyed statement cache.
insertNode('src/big.ts', 'file', 'src/big.ts', 0);
const total = 650;
for (let i = 0; i < total; i++) {
insertNode(`field${i}`, 'property', 'src/big.ts', i);
}

const summary = classifyNodeRoles(db);

expect(summary['dead-leaf']).toBe(total);
expect(summary.dead).toBe(total);
const count = db
.prepare("SELECT COUNT(*) AS cnt FROM nodes WHERE role = 'dead-leaf'")
.get() as { cnt: number };
expect(count.cnt).toBe(total);
});

it('keeps the shared chunk-keyed statement cache isolated across separate database instances', () => {
// The chunk-size statement cache (shared with domain/graph/builder/helpers.ts
// via cachedChunkStmt) is a module-level WeakMap keyed by db instance. Running
// classification against two independent in-memory databases back-to-back must
// not leak a prepared statement compiled against one db's connection into the other.
const dbA = new Database(':memory:');
dbA.pragma('journal_mode = WAL');
initSchema(dbA);
dbA
.prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)')
.run('a.ts', 'file', 'a.ts', 0);
for (let i = 0; i < 5; i++) {
dbA
.prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)')
.run(`fieldA${i}`, 'property', 'a.ts', i);
}

const dbB = new Database(':memory:');
dbB.pragma('journal_mode = WAL');
initSchema(dbB);
dbB
.prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)')
.run('b.ts', 'file', 'b.ts', 0);
for (let i = 0; i < 9; i++) {
dbB
.prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)')
.run(`fieldB${i}`, 'property', 'b.ts', i);
}

const summaryA = classifyNodeRoles(dbA);
const summaryB = classifyNodeRoles(dbB);

expect(summaryA['dead-leaf']).toBe(5);
expect(summaryB['dead-leaf']).toBe(9);
expect(
(
dbA.prepare("SELECT COUNT(*) AS cnt FROM nodes WHERE role = 'dead-leaf'").get() as {
cnt: number;
}
).cnt,
).toBe(5);
expect(
(
dbB.prepare("SELECT COUNT(*) AS cnt FROM nodes WHERE role = 'dead-leaf'").get() as {
cnt: number;
}
).cnt,
).toBe(9);

dbA.close();
dbB.close();
});
});
Loading