fix(extraction): decode kernel results in indexAll retry passes; self-heal wiped rows (#1541) (#1575)
The parse-pool workers return kernel-language extractions as an undecoded buffer transport (nodes/edges EMPTY, tables in kernelBuffers). indexAll's main loop decodes them (or hands the buffers to the store worker), but its two retry passes — plain retry and the comments-stripped last resort — stored the transport as-is: the storage gate passed via errors.length === 0, zero nodes were inserted, and the files row was written with node_count = 0 while the original error was spliced out of the summary. Any worker crash/timeout whose in-flight file was a kernel-routed language permanently recorded that file as "(0 symbols)" — silently, and immune to later syncs because the stored hash matches the on-disk bytes (#1541; v1.4.1 predates the kernel path, which is why it was unaffected). - Both retry passes now materialize kernel results before the gate, store, counters, and log lines. - storeExtractionResult materializes at entry as defense-in-depth, so no storage path can persist an undecoded transport again. - Zero-node rows on symbol-bearing languages (only the wipe produces these — every real extraction stores at least the file node) are dropped during full-reconcile sync and indexAll so already-affected files re-index automatically after upgrading. Scoped watcher syncs leave rows outside their scope untouched. - The comments-stripped salvage now downgrades the failure to a visible warning instead of erasing it: the recovered result can be incomplete, and reporting clean success made a fresh index quietly disagree with a later per-file re-parse of the same bytes (#1565's init-vs-sync divergence). Repro (released 1.5.0): CODEGRAPH_PARSE_TIMEOUT_MS=1 codegraph init on any Python project → "Retry OK: <file> (0 nodes)" and permanent "(python, 0 symbols)" rows. Fixed build stores real symbols under the same forcing, and heals rows wiped by prior runs. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d806317897
commit
26045b3159
+55
-4
@@ -1577,6 +1577,11 @@ export class ExtractionOrchestrator {
|
||||
});
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] scan: ${Date.now() - tScan}ms (${files.length} files)`);
|
||||
|
||||
// A re-index over an existing DB skips unchanged-hash files at the store,
|
||||
// which would preserve wiped zero-node rows (#1541) — drop them first so
|
||||
// this run stores their files fresh. No-op on a fresh DB.
|
||||
this.healZeroNodeRows();
|
||||
|
||||
// Detect frameworks once per indexAll run using the scanned file list.
|
||||
// Names are passed to each parse call so framework-specific extractors
|
||||
// (route nodes, middleware, etc.) run after the tree-sitter pass.
|
||||
@@ -2025,8 +2030,16 @@ export class ExtractionOrchestrator {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The pool hands kernel results back as an undecoded buffer transport
|
||||
// (`nodes`/`edges` EMPTY, tables in kernelBuffers). The main loop
|
||||
// decodes or forwards to the store worker; this path stores directly,
|
||||
// so decode here — otherwise a kernel-language retry passes the gate
|
||||
// below via `errors.length === 0`, stores nothing, and the file is
|
||||
// permanently recorded as "(0 symbols)" with the error erased (#1541).
|
||||
const language = detectLanguage(filePath, content, overrides);
|
||||
result = materializeKernelResult(result, filePath, language);
|
||||
|
||||
if (result.nodes.length > 0 || result.errors.length === 0) {
|
||||
const language = detectLanguage(filePath, content, overrides);
|
||||
const stats = await fsp.stat(path.join(this.rootDir, filePath));
|
||||
await this.storeExtractionResult(filePath, content, language, stats, result, commitYield);
|
||||
|
||||
@@ -2075,13 +2088,21 @@ export class ExtractionOrchestrator {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Same undecoded-transport hazard as the first retry pass (#1541).
|
||||
const language = detectLanguage(filePath, fullContent, overrides);
|
||||
result = materializeKernelResult(result, filePath, language);
|
||||
|
||||
if (result.nodes.length > 0 || result.errors.length === 0) {
|
||||
const language = detectLanguage(filePath, fullContent, overrides);
|
||||
const stats = await fsp.stat(path.join(this.rootDir, filePath));
|
||||
await this.storeExtractionResult(filePath, fullContent, language, stats, result, commitYield);
|
||||
|
||||
const idx = errors.indexOf(errEntry);
|
||||
if (idx >= 0) errors.splice(idx, 1);
|
||||
// Salvaged from comment-stripped source: keep a visible trace in
|
||||
// the summary instead of erasing the failure outright — the
|
||||
// stored result may be missing whatever the failing parse choked
|
||||
// on, and a silently "clean" file here is how an index quietly
|
||||
// disagrees with a later per-file sync of the same bytes (#1565).
|
||||
errEntry.severity = 'warning';
|
||||
errEntry.message = `Indexed from comment-stripped source after repeated parse failures (symbols may be incomplete until the file is re-indexed): ${errEntry.message}`;
|
||||
filesErrored--;
|
||||
filesIndexed++;
|
||||
totalNodes += result.nodes.length;
|
||||
@@ -2267,6 +2288,26 @@ export class ExtractionOrchestrator {
|
||||
/**
|
||||
* Store extraction result in database
|
||||
*/
|
||||
/**
|
||||
* Delete file rows recorded with ZERO nodes so their files re-index.
|
||||
*
|
||||
* No extraction path stores an empty, error-free result for a
|
||||
* symbol-bearing language — even an empty file keeps its file node — so a
|
||||
* zero-node row is a wiped one (#1541: an interrupted parse's retry stored
|
||||
* an undecoded kernel transport). The wiped row's content hash matches the
|
||||
* on-disk bytes, so every hash-based reconcile skips the file forever;
|
||||
* deleting the row lets the normal add path repair it. File-level-only
|
||||
* languages (yaml, twig, properties) are left alone. Deleting a zero-node
|
||||
* row cascades nothing: it has no nodes, so no edges or refs either.
|
||||
*/
|
||||
private healZeroNodeRows(): void {
|
||||
for (const f of this.queries.getAllFiles()) {
|
||||
if (f.nodeCount === 0 && !isFileLevelOnlyLanguage(f.language)) {
|
||||
this.queries.deleteFile(f.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async storeExtractionResult(
|
||||
filePath: string,
|
||||
content: string,
|
||||
@@ -2275,6 +2316,12 @@ export class ExtractionOrchestrator {
|
||||
result: ExtractionResult,
|
||||
onYield?: MaybeYield
|
||||
): Promise<void> {
|
||||
// A kernel result can arrive as an undecoded buffer transport (empty
|
||||
// node/edge arrays, tables riding in kernelBuffers). Decode it before
|
||||
// storing — persisting the transport as-is records the file as having no
|
||||
// symbols at all (#1541). No-op for already-decoded results.
|
||||
result = materializeKernelResult(result, filePath, language);
|
||||
|
||||
// Bulk inserts run in bounded sub-transactions with a yield between, so a
|
||||
// giant generated file (tens of thousands of symbols) can't block the
|
||||
// event loop — and the #850 watchdog heartbeat — for the whole store.
|
||||
@@ -2636,6 +2683,10 @@ export class ExtractionOrchestrator {
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
|
||||
filesChecked = currentFiles.length;
|
||||
|
||||
// Full reconcile only (scoped syncs must not touch rows outside their
|
||||
// scope): drop zero-node rows so the wiped files re-index as adds below.
|
||||
this.healZeroNodeRows();
|
||||
|
||||
const tTracked = Date.now();
|
||||
trackedFiles = this.queries.getAllFiles();
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-tracked-load: ${Date.now() - tTracked}ms (${trackedFiles.length} tracked)`);
|
||||
|
||||
Reference in New Issue
Block a user