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:
@@ -186,6 +186,36 @@ export class DatabaseConnection {
|
||||
this.db.exec('ANALYZE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, non-blocking maintenance to run after bulk writes
|
||||
* (indexAll, sync). Two operations:
|
||||
*
|
||||
* - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes
|
||||
* tables whose row counts changed materially since the last
|
||||
* ANALYZE. Without it, the query planner has no statistics on the
|
||||
* freshly-bulk-loaded tables and can pick suboptimal indexes.
|
||||
*
|
||||
* - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back
|
||||
* into the main database file so the WAL file doesn't grow
|
||||
* unboundedly between automatic checkpoints (auto-fires at 1000
|
||||
* pages by default; large indexAll runs blow past that).
|
||||
*
|
||||
* Both operations are silently swallowed on failure — they're a
|
||||
* best-effort optimization, never load-bearing for correctness.
|
||||
*/
|
||||
runMaintenance(): void {
|
||||
try {
|
||||
this.db.exec('PRAGMA optimize');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
this.db.exec('PRAGMA wal_checkpoint(PASSIVE)');
|
||||
} catch {
|
||||
// ignore (e.g., not in WAL mode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user