perf(db): batch node lookups, fix insertNode cache, run maintenance after writes (#108)

Batch getNodesByIds to collapse N+1 reads in graph traversal, invalidate the
insertNode LRU cache so INSERT OR REPLACE doesn't serve a stale row, and run
incremental PRAGMA optimize + passive WAL checkpoint after bulk writes.

Closes #108
This commit is contained in:
andreinknv
2026-05-22 13:16:34 -05:00
committed by GitHub
parent 4e34ba8399
commit b13f2f1ba1
5 changed files with 330 additions and 47 deletions
+59
View File
@@ -224,6 +224,12 @@ export class QueryBuilder {
return;
}
// INSERT OR REPLACE may overwrite a node we have cached. Drop the
// stale entry so the next getNodeById sees the new row, not the old
// one (matches the cache-invalidation pattern used by updateNode and
// deleteNode below).
this.nodeCache.delete(node.id);
try {
this.stmts.insertNode.run({
id: node.id,
@@ -380,6 +386,59 @@ export class QueryBuilder {
return node;
}
/**
* Batch lookup: fetch many nodes by ID in a single SQL round-trip.
*
* Replaces the N+1 pattern in graph traversal where every edge would
* trigger its own `getNodeById` call. For a function with 50 callers
* this collapses 50 point reads into one IN-list query (~10-50x
* faster end-to-end).
*
* Returns a Map keyed by id so callers can preserve their own ordering
* (typically the order edges were returned from the graph). Missing IDs
* are simply absent from the map.
*
* Cache-aware: ids already in the LRU cache are served from memory and
* the SQL query only touches the misses.
*/
getNodesByIds(ids: readonly string[]): Map<string, Node> {
const out = new Map<string, Node>();
if (ids.length === 0) return out;
// Serve cache hits first; build the miss list for SQL.
const misses: string[] = [];
for (const id of ids) {
const cached = this.nodeCache.get(id);
if (cached !== undefined) {
// LRU touch
this.nodeCache.delete(id);
this.nodeCache.set(id, cached);
out.set(id, cached);
} else {
misses.push(id);
}
}
if (misses.length === 0) return out;
// Chunk under SQLite's parameter limit (default 999, raised to 32766
// in better-sqlite3 builds — chunk at 500 for safety across both
// backends and to keep the query plan simple).
const CHUNK = 500;
for (let i = 0; i < misses.length; i += CHUNK) {
const chunk = misses.slice(i, i + CHUNK);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`)
.all(...chunk) as NodeRow[];
for (const row of rows) {
const node = rowToNode(row);
out.set(node.id, node);
this.cacheNode(node);
}
}
return out;
}
/**
* Add a node to the cache, evicting oldest if needed
*/