Skip to content

Commit e2bb2dc

Browse files
committed
perf(native): use single rusqlite connection for entire build pipeline
When the native addon is available, the build pipeline now uses only rusqlite for all stages via a NativeDbProxy that implements the BetterSqlite3Database interface. This eliminates the dual-connection WAL corruption problem and removes the open/close/reopen dance that forced most stages to fall back to JS. Key changes: - NativeDbProxy wraps NativeDatabase.queryAll/queryGet/exec to satisfy the BetterSqlite3Database interface transparently - setupPipeline() opens only rusqlite when native is available - runPipelineStages() skips WAL checkpoint dance in native-first mode - tryNativeInsert() skips WAL guards when single connection is active - Fallback to better-sqlite3 when native is unavailable (unchanged) - CODEGRAPH_FORCE_JS_PIPELINE=1 and --engine wasm bypass native-first Benchmarks (native v3.9.1, 677 files): - Full build: 6,668ms → 5,844ms (12% faster) - 1-file rebuild: 1,375ms → 960ms (30% faster) - No-op rebuild: 17ms (unchanged) - CFG phase: 466ms → 6.7ms (70x faster) - Finalize: 156ms → 25ms (6x faster) - DB size: 27.2MB → 23.3MB (14% smaller)
1 parent 14d1717 commit e2bb2dc

6 files changed

Lines changed: 201 additions & 36 deletions

File tree

src/db/connection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ function isProcessAlive(pid: number): boolean {
109109
}
110110
}
111111

112-
function acquireAdvisoryLock(dbPath: string): void {
112+
export function acquireAdvisoryLock(dbPath: string): void {
113113
const lockPath = `${dbPath}.lock`;
114114
try {
115115
if (fs.existsSync(lockPath)) {
@@ -129,7 +129,7 @@ function acquireAdvisoryLock(dbPath: string): void {
129129
}
130130
}
131131

132-
function releaseAdvisoryLock(lockPath: string): void {
132+
export function releaseAdvisoryLock(lockPath: string): void {
133133
try {
134134
const content = fs.readFileSync(lockPath, 'utf-8').trim();
135135
if (Number(content) === process.pid) {

src/db/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
export type { LockedDatabase, LockedDatabasePair } from './connection.js';
44
export {
5+
acquireAdvisoryLock,
56
closeDb,
67
closeDbDeferred,
78
closeDbPair,
@@ -13,6 +14,7 @@ export {
1314
openReadonlyOrFail,
1415
openReadonlyWithNative,
1516
openRepo,
17+
releaseAdvisoryLock,
1618
} from './connection.js';
1719
export { getBuildMeta, initSchema, MIGRATIONS, setBuildMeta } from './migrations.js';
1820
export {

src/domain/graph/builder/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export class PipelineContext {
3535
nativeDb?: NativeDatabase;
3636
/** Whether native engine is available (deferred — DB opened only when needed). */
3737
nativeAvailable: boolean = false;
38+
/** True when ctx.db is a NativeDbProxy — single rusqlite connection for the entire pipeline. */
39+
nativeFirstProxy: boolean = false;
3840

3941
// ── File collection (set by collectFiles stage) ────────────────────
4042
allFiles!: string[];
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* NativeDbProxy — wraps a NativeDatabase (rusqlite via napi-rs) to satisfy the
3+
* BetterSqlite3Database interface. When the native addon is available, the
4+
* build pipeline uses this proxy as `ctx.db` so that every stage operates on a
5+
* single rusqlite connection — no dual-connection WAL corruption, no
6+
* open/close/reopen dance.
7+
*
8+
* When native is unavailable, the pipeline falls back to real better-sqlite3.
9+
*/
10+
11+
import type { BetterSqlite3Database, NativeDatabase, SqliteStatement } from '../../../types.js';
12+
13+
const RUN_STUB = Object.freeze({ changes: 0, lastInsertRowid: 0 });
14+
15+
export class NativeDbProxy implements BetterSqlite3Database {
16+
readonly #ndb: NativeDatabase;
17+
/** Advisory lock path — set by the pipeline so closeDb() can release it. */
18+
__lockPath?: string;
19+
20+
constructor(nativeDb: NativeDatabase) {
21+
this.#ndb = nativeDb;
22+
}
23+
24+
prepare<TRow = unknown>(sql: string): SqliteStatement<TRow> {
25+
const ndb = this.#ndb;
26+
const stmt: SqliteStatement<TRow> = {
27+
all(...params: unknown[]): TRow[] {
28+
return ndb.queryAll(sql, params as Array<string | number | null>) as TRow[];
29+
},
30+
get(...params: unknown[]): TRow | undefined {
31+
return (ndb.queryGet(sql, params as Array<string | number | null>) ?? undefined) as
32+
| TRow
33+
| undefined;
34+
},
35+
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint } {
36+
ndb.queryAll(sql, params as Array<string | number | null>);
37+
return RUN_STUB;
38+
},
39+
iterate(): IterableIterator<TRow> {
40+
throw new Error('iterate() is not supported via NativeDbProxy');
41+
},
42+
raw(): SqliteStatement<TRow> {
43+
return stmt; // no-op — .raw() is not used in the build pipeline
44+
},
45+
};
46+
return stmt;
47+
}
48+
49+
exec(sql: string): this {
50+
this.#ndb.exec(sql);
51+
return this;
52+
}
53+
54+
pragma(sql: string): unknown {
55+
return this.#ndb.pragma(sql);
56+
}
57+
58+
close(): void {
59+
// No-op: the pipeline manages the NativeDatabase lifecycle directly.
60+
// closeDbPair() calls nativeDb.close() separately.
61+
}
62+
63+
get open(): boolean {
64+
return this.#ndb.isOpen;
65+
}
66+
67+
get name(): string {
68+
return this.#ndb.dbPath;
69+
}
70+
71+
transaction<F extends (...args: any[]) => any>(
72+
fn: F,
73+
): (...args: F extends (...a: infer A) => unknown ? A : never) => ReturnType<F> {
74+
const ndb = this.#ndb;
75+
return ((...args: unknown[]) => {
76+
ndb.exec('BEGIN');
77+
try {
78+
const result = fn(...args);
79+
ndb.exec('COMMIT');
80+
return result;
81+
} catch (e) {
82+
try {
83+
ndb.exec('ROLLBACK');
84+
} catch {
85+
// Ignore rollback errors — the original error is more important
86+
}
87+
throw e;
88+
}
89+
}) as any;
90+
}
91+
}

src/domain/graph/builder/pipeline.ts

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
* This is the heart of the builder refactor (ROADMAP 3.9): the monolithic buildGraph()
55
* is decomposed into independently testable stages that communicate via PipelineContext.
66
*/
7+
import fs from 'node:fs';
78
import path from 'node:path';
89
import { performance } from 'node:perf_hooks';
910
import {
11+
acquireAdvisoryLock,
1012
closeDbPair,
1113
getBuildMeta,
1214
initSchema,
1315
MIGRATIONS,
1416
openDb,
17+
releaseAdvisoryLock,
1518
setBuildMeta,
1619
} from '../../../db/index.js';
1720
import { detectWorkspaces, loadConfig } from '../../../infrastructure/config.js';
@@ -25,6 +28,7 @@ import { getActiveEngine } from '../../parser.js';
2528
import { setWorkspaces } from '../resolve.js';
2629
import { PipelineContext } from './context.js';
2730
import { loadPathAliases } from './helpers.js';
31+
import { NativeDbProxy } from './native-db-proxy.js';
2832
import { buildEdges } from './stages/build-edges.js';
2933
import { buildStructure } from './stages/build-structure.js';
3034
// Pipeline stages
@@ -110,14 +114,48 @@ function loadAliases(ctx: PipelineContext): void {
110114
function setupPipeline(ctx: PipelineContext): void {
111115
ctx.rootDir = path.resolve(ctx.rootDir);
112116
ctx.dbPath = path.join(ctx.rootDir, '.codegraph', 'graph.db');
113-
ctx.db = openDb(ctx.dbPath);
114-
initSchema(ctx.db);
115117

116-
// Detect whether native engine is available, but defer opening NativeDatabase.
117-
// The native orchestrator opens it on demand; the JS pipeline defers until
118-
// after change detection — avoiding ~5ms open+initSchema+close on no-op rebuilds.
118+
// Detect whether native engine is available.
119119
const enginePref = ctx.opts.engine || 'auto';
120-
ctx.nativeAvailable = enginePref !== 'wasm' && !!loadNative()?.NativeDatabase;
120+
const native = enginePref !== 'wasm' ? loadNative() : null;
121+
ctx.nativeAvailable = !!native?.NativeDatabase;
122+
123+
// Native-first: use only rusqlite for the entire pipeline (no better-sqlite3).
124+
// This eliminates the dual-connection WAL corruption problem and enables all
125+
// native fast-paths (bulkInsertNodes, classifyRolesFull, etc.).
126+
// Fallback: if native is unavailable or FORCE_JS is set, use better-sqlite3.
127+
if (
128+
ctx.nativeAvailable &&
129+
native?.NativeDatabase &&
130+
process.env.CODEGRAPH_FORCE_JS_PIPELINE !== '1'
131+
) {
132+
try {
133+
const dir = path.dirname(ctx.dbPath);
134+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
135+
acquireAdvisoryLock(ctx.dbPath);
136+
ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
137+
ctx.nativeDb.initSchema();
138+
const proxy = new NativeDbProxy(ctx.nativeDb);
139+
proxy.__lockPath = `${ctx.dbPath}.lock`;
140+
ctx.db = proxy as unknown as typeof ctx.db;
141+
ctx.nativeFirstProxy = true;
142+
} catch (err) {
143+
warn(`NativeDatabase setup failed, falling back to better-sqlite3: ${toErrorMessage(err)}`);
144+
try {
145+
ctx.nativeDb?.close();
146+
} catch {
147+
/* ignore */
148+
}
149+
ctx.nativeDb = undefined;
150+
ctx.nativeFirstProxy = false;
151+
releaseAdvisoryLock(`${ctx.dbPath}.lock`);
152+
ctx.db = openDb(ctx.dbPath);
153+
initSchema(ctx.db);
154+
}
155+
} else {
156+
ctx.db = openDb(ctx.dbPath);
157+
initSchema(ctx.db);
158+
}
121159

122160
ctx.config = loadConfig(ctx.rootDir);
123161
ctx.incremental =
@@ -434,16 +472,20 @@ async function runPostNativeAnalysis(
434472
analysisFileSymbols = allFileSymbols;
435473
}
436474

437-
// Reopen nativeDb for analysis features (suspend/resume WAL pattern).
438-
const native = loadNative();
439-
if (native?.NativeDatabase) {
440-
try {
441-
ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
442-
if (ctx.engineOpts) ctx.engineOpts.nativeDb = ctx.nativeDb;
443-
} catch {
444-
ctx.nativeDb = undefined;
445-
if (ctx.engineOpts) ctx.engineOpts.nativeDb = undefined;
475+
// In native-first mode, nativeDb is already open — no reopen needed.
476+
if (!ctx.nativeFirstProxy) {
477+
const native = loadNative();
478+
if (native?.NativeDatabase) {
479+
try {
480+
ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
481+
if (ctx.engineOpts) ctx.engineOpts.nativeDb = ctx.nativeDb;
482+
} catch {
483+
ctx.nativeDb = undefined;
484+
if (ctx.engineOpts) ctx.engineOpts.nativeDb = undefined;
485+
}
446486
}
487+
} else if (ctx.engineOpts) {
488+
ctx.engineOpts.nativeDb = ctx.nativeDb;
447489
}
448490

449491
try {
@@ -463,8 +505,8 @@ async function runPostNativeAnalysis(
463505
warn(`Analysis phases failed after native build: ${toErrorMessage(err)}`);
464506
}
465507

466-
// Close nativeDb after analyses
467-
if (ctx.nativeDb) {
508+
// Close nativeDb after analyses (skip in native-first — single connection stays open)
509+
if (ctx.nativeDb && !ctx.nativeFirstProxy) {
468510
try {
469511
ctx.nativeDb.exec('PRAGMA wal_checkpoint(TRUNCATE)');
470512
} catch {
@@ -516,8 +558,8 @@ async function tryNativeOrchestrator(
516558
return undefined;
517559
}
518560

519-
// Open NativeDatabase on demand for the orchestrator.
520-
// Deferred from setupPipeline so no-op JS pipeline rebuilds skip the overhead.
561+
// In native-first mode, nativeDb is already open from setupPipeline.
562+
// Otherwise, open it on demand (deferred to skip overhead on no-op rebuilds).
521563
if (!ctx.nativeDb && ctx.nativeAvailable) {
522564
const native = loadNative();
523565
if (native?.NativeDatabase) {
@@ -591,7 +633,8 @@ async function tryNativeOrchestrator(
591633
const needsStructure = !result.structureHandled;
592634

593635
if (needsAnalysis || needsStructure) {
594-
if (!handoffWalAfterNativeBuild(ctx)) {
636+
// In native-first mode the proxy is already wired — no WAL handoff needed.
637+
if (!ctx.nativeFirstProxy && !handoffWalAfterNativeBuild(ctx)) {
595638
// DB reopen failed — return partial result
596639
return formatNativeTimingResult(p, 0, analysisTiming);
597640
}
@@ -623,6 +666,30 @@ async function tryNativeOrchestrator(
623666
// ── Pipeline stages execution ───────────────────────────────────────────
624667

625668
async function runPipelineStages(ctx: PipelineContext): Promise<void> {
669+
// ── Native-first mode ────────────────────────────────────────────────
670+
// When ctx.nativeFirstProxy is true, ctx.db is a NativeDbProxy backed by
671+
// the single rusqlite connection (ctx.nativeDb). No dual-connection WAL
672+
// dance is needed — every stage uses the same connection transparently.
673+
if (ctx.nativeFirstProxy) {
674+
// Ensure engineOpts.nativeDb is set so stages can use dedicated native methods.
675+
if (ctx.engineOpts) {
676+
ctx.engineOpts.nativeDb = ctx.nativeDb;
677+
}
678+
679+
await collectFiles(ctx);
680+
await detectChanges(ctx);
681+
if (ctx.earlyExit) return;
682+
await parseFiles(ctx);
683+
await insertNodes(ctx);
684+
await resolveImports(ctx);
685+
await buildEdges(ctx);
686+
await buildStructure(ctx);
687+
await runAnalyses(ctx);
688+
await finalize(ctx);
689+
return;
690+
}
691+
692+
// ── Legacy dual-connection mode (WASM / fallback) ────────────────────
626693
// NativeDatabase is deferred — not opened during setup. collectFiles and
627694
// detectChanges only need better-sqlite3. If no files changed, we exit
628695
// early without ever opening the native connection, saving ~5ms.

src/domain/graph/builder/stages/insert-nodes.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -159,23 +159,26 @@ function tryNativeInsert(ctx: PipelineContext): boolean {
159159
}
160160
const fileHashes = buildFileHashes(allSymbols, precomputedData, metadataUpdates, rootDir);
161161

162-
// WAL guard: same suspendJsDb/resumeJsDb pattern used by feature modules
163-
// (ast, cfg, complexity, dataflow). Checkpoint JS side before native write,
164-
// then checkpoint native side after, so neither library reads WAL frames
165-
// written by the other (#696, #709, #715, #717).
162+
// In native-first mode (single rusqlite connection), no WAL dance is needed.
163+
// In dual-connection mode, checkpoint JS side before native write, then
164+
// checkpoint native side after (#696, #709, #715, #717).
166165
let result: boolean;
167-
try {
168-
if (ctx.db) {
169-
ctx.db.pragma('wal_checkpoint(TRUNCATE)');
170-
}
166+
if (ctx.nativeFirstProxy) {
171167
result = ctx.nativeDb!.bulkInsertNodes(batches, fileHashes, removed);
172-
} finally {
168+
} else {
173169
try {
174-
ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
175-
} catch (e) {
176-
debug(
177-
`tryNativeInsert: WAL checkpoint failed (nativeDb may already be closed): ${toErrorMessage(e)}`,
178-
);
170+
if (ctx.db) {
171+
ctx.db.pragma('wal_checkpoint(TRUNCATE)');
172+
}
173+
result = ctx.nativeDb!.bulkInsertNodes(batches, fileHashes, removed);
174+
} finally {
175+
try {
176+
ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)');
177+
} catch (e) {
178+
debug(
179+
`tryNativeInsert: WAL checkpoint failed (nativeDb may already be closed): ${toErrorMessage(e)}`,
180+
);
181+
}
179182
}
180183
}
181184
return result;

0 commit comments

Comments
 (0)