perf(resolution): batch-loop de-quadratic — keyset reads, changes-based guard, DB-scaled valve caps + resolve profiler (#1339)

The §7a.2 per-ref profile overturned the assumption the whole arc was
built on: resolveOne owns only ~93s of the kernel-scale ~433s batch loop.
Loop-stage attribution (CODEGRAPH_RESOLVE_PROFILE, shipped here) named the
rest: backpressure folds 111.2s, count guard 93.9s, batch reads 54.6s,
deletes/inserts/marks ~84s, settle 85.7s.

- Non-progress guard O(remaining)→O(1): the per-batch COUNT(*) walked every
  remaining pending row (O(N²/batch) per run, 93.9s). The cleanup queries
  now return summed SQLite , and zero-removals-from-claimed-work
  is the guard signal — the DIRECT evidence the count diff inferred (a
  mismatched-name resolver makes keyed cleanup no-op ⇒ changes=0). A real
  COUNT runs only on that suspicious path and arbitrates exactly as before.
- Batch reads OFFSET→keyset (54.6s→O(batch)): OFFSET re-walked the
  accumulated failed-row prefix every read; seeking past the last-seen
  rowid is prefix-independent and enumeration-order identical.
- WAL valve caps scale with DB size (env still wins): every fold re-writes
  hot pages (#1231 in bounded form — 111.2s at the flat 256MB cap);
  soft=clamp(dbSize/4, 256MB, 2GB) trades ~4× fewer folds for a transient
  WAL ≈ project size.
- CODEGRAPH_RESOLVE_PROFILE: per-outcome resolveOne histogram + loop-stage
  attribution, main + workers, off by default.

Gates: dubbo dump byte-identical; suite 2,491 passed / 4 skipped (kernel
required). Kernel-scale payoff run lands in the plan doc next.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-17 11:27:30 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 19cf1ec75b
commit 7cc23668b5
7 changed files with 194 additions and 33 deletions
+11
View File
@@ -325,6 +325,17 @@ export class DatabaseConnection {
}
}
/** Size of the main DB file in bytes (0 for in-memory/unknown) — the WAL
* valve scales its fold caps with it (resolveWalValveMb). */
getDbFileSizeBytes(): number {
if (!this.dbPath || this.dbPath === ':memory:') return 0;
try {
return fs.statSync(this.dbPath).size;
} catch {
return 0;
}
}
/** Current `wal_autocheckpoint` interval in pages (0 = disabled). */
getWalAutocheckpoint(): number {
const v = this.db.pragma('wal_autocheckpoint', { simple: true });
+55 -14
View File
@@ -230,6 +230,7 @@ export class QueryBuilder {
getNodesByLowerName?: SqliteStatement;
getUnresolvedCount?: SqliteStatement;
getUnresolvedBatch?: SqliteStatement;
getUnresolvedBatchAfter?: SqliteStatement;
deleteRefsByRowIdsFull?: SqliteStatement;
getAllFilePaths?: SqliteStatement;
getAllNodeNames?: SqliteStatement;
@@ -2085,6 +2086,34 @@ export class QueryBuilder {
}));
}
/**
* Keyset variant of {@link getUnresolvedReferencesBatch} for the batched
* resolution loop: seek past the last-seen row id instead of OFFSET-walking.
* OFFSET reads re-scan the accumulated failed-row prefix on every batch —
* O(failed rows) per read, measured at 54.6s of the kernel-scale batch loop
* (§7a.2) — while the seek is O(batch) forever. `id` is the rowid alias, so
* the enumeration order is identical to the OFFSET reader's.
*/
getUnresolvedReferencesBatchAfter(afterRowId: number, limit: number): UnresolvedReference[] {
if (!this.stmts.getUnresolvedBatchAfter) {
this.stmts.getUnresolvedBatchAfter = this.db.prepare(
"SELECT * FROM unresolved_refs WHERE status = 'pending' AND id > ? ORDER BY id LIMIT ?"
);
}
const rows = this.stmts.getUnresolvedBatchAfter.all(afterRowId, limit) as UnresolvedRefRow[];
return rows.map((row) => ({
fromNodeId: row.from_node_id,
referenceName: row.reference_name,
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}
/**
* Get all tracked file paths (lightweight — no full FileRecord objects)
*/
@@ -2182,17 +2211,22 @@ export class QueryBuilder {
* Delete specific resolved references by (fromNodeId, referenceName, referenceKind) tuples.
* More precise than deleteResolvedReferences — only removes refs that were actually resolved.
*/
deleteSpecificResolvedReferences(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): void {
if (refs.length === 0) return;
deleteSpecificResolvedReferences(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): number {
if (refs.length === 0) return 0;
const stmt = this.db.prepare(
'DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?'
);
// Returns rows actually removed (SQLite `changes`, summed): the batched
// resolution loop's non-progress guard keys on this — zero removals from
// a batch that claimed work is the direct runaway signal (§7a.2).
let changed = 0;
const deleteMany = this.db.transaction((items: typeof refs) => {
for (const ref of items) {
stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind);
changed += stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind).changes;
}
});
deleteMany(refs);
return changed;
}
/**
@@ -2203,13 +2237,15 @@ export class QueryBuilder {
* caller's same-named call sites, the later sites' edges were silently never
* created (#1269).
*/
deleteReferencesByRowIds(rowIds: number[]): void {
if (rowIds.length === 0) return;
deleteReferencesByRowIds(rowIds: number[]): number {
if (rowIds.length === 0) return 0;
// One transaction for all chunks (each chunk was previously its own
// implicit transaction = its own WAL commit — measurable on 100k+-ref
// resolution persists), and the full-size chunk statement is cached so
// repeat calls skip the re-prepare; only the final partial chunk (if any)
// prepares ad hoc.
// prepares ad hoc. Returns rows actually removed (summed `changes`) for
// the batched loop's non-progress guard (§7a.2).
let changed = 0;
this.db.transaction(() => {
for (let i = 0; i < rowIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = rowIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
@@ -2220,13 +2256,14 @@ export class QueryBuilder {
`DELETE FROM unresolved_refs WHERE id IN (${placeholders})`
);
}
this.stmts.deleteRefsByRowIdsFull.run(...chunk);
changed += this.stmts.deleteRefsByRowIdsFull.run(...chunk).changes;
} else {
const placeholders = chunk.map(() => '?').join(',');
this.db.prepare(`DELETE FROM unresolved_refs WHERE id IN (${placeholders})`).run(...chunk);
changed += this.db.prepare(`DELETE FROM unresolved_refs WHERE id IN (${placeholders})`).run(...chunk).changes;
}
}
})();
return changed;
}
/**
@@ -2238,17 +2275,19 @@ export class QueryBuilder {
* is (re)written here so rows inserted before the v8 migration get their
* tail the first time they're attempted.
*/
markReferencesFailed(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): void {
if (refs.length === 0) return;
markReferencesFailed(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): number {
if (refs.length === 0) return 0;
const stmt = this.db.prepare(
"UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?"
);
let changed = 0;
const markMany = this.db.transaction((items: typeof refs) => {
for (const ref of items) {
stmt.run(referenceNameTail(ref.referenceName), ref.fromNodeId, ref.referenceName, ref.referenceKind);
changed += stmt.run(referenceNameTail(ref.referenceName), ref.fromNodeId, ref.referenceName, ref.referenceKind).changes;
}
});
markMany(refs);
return changed;
}
/**
@@ -2259,17 +2298,19 @@ export class QueryBuilder {
* can differ per call site (receiver-type inference reads the ref's line),
* so a sibling must not inherit this row's failure.
*/
markReferencesFailedByRowIds(refs: Array<{ rowId: number; referenceName: string }>): void {
if (refs.length === 0) return;
markReferencesFailedByRowIds(refs: Array<{ rowId: number; referenceName: string }>): number {
if (refs.length === 0) return 0;
const stmt = this.db.prepare(
"UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE id = ?"
);
let changed = 0;
const markMany = this.db.transaction((items: typeof refs) => {
for (const ref of items) {
stmt.run(referenceNameTail(ref.referenceName), ref.rowId);
changed += stmt.run(referenceNameTail(ref.referenceName), ref.rowId).changes;
}
});
markMany(refs);
return changed;
}
/**
+9 -1
View File
@@ -61,11 +61,19 @@ const CHECK_INTERVAL_MS = 2000;
* Resolve the valve's soft threshold from the `CODEGRAPH_WAL_VALVE_MB`
* override; non-numeric / non-positive values fall back to the default.
*/
export function resolveWalValveMb(envVal: string | undefined): number {
export function resolveWalValveMb(envVal: string | undefined, dbSizeBytes?: number): number {
if (envVal !== undefined && envVal !== '') {
const n = Number(envVal);
if (Number.isFinite(n) && n > 0) return Math.floor(n);
}
// Scale with the project when the caller knows the DB size: every fold
// re-writes hot B-tree pages into the main file (the #1231 pathology in
// bounded form — 111s of a kernel-scale batch loop at the flat 256MB cap,
// §7a.2), so a big project affords a proportionally bigger transient WAL
// (~dbSize/4 soft ⇒ file cap ≈ dbSize) in exchange for ~4× fewer folds.
if (dbSizeBytes !== undefined && dbSizeBytes > 0) {
return Math.min(2048, Math.max(DEFAULT_WAL_VALVE_MB, Math.floor(dbSizeBytes / 4 / (1024 * 1024))));
}
return DEFAULT_WAL_VALVE_MB;
}