mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
feat(analyze): chunk-level parse cache for full incremental speedup
Composes with the incremental DB writeback (commit 27f3b49d) to deliver
the major-speedup half of incremental indexing. Previously, the parse
phase ran in full on every analyze; the speedup came purely from
selective DB rewriting. With this commit the parse phase also reuses
prior tree-sitter output for chunks whose contents haven't changed.
How it works:
* Cache layer (gitnexus/src/storage/parse-cache.ts):
- File: <repo>/.gitnexus/parse-cache.json. Versioned, atomic write.
- Key: chunk content hash = sha256(sorted(filePath:fileContentHash
for each file in chunk)).
- Value: ParseWorkerResult[] (raw worker output for the chunk,
pre-merge).
- Granularity: per chunk (~20MB byte-budget). A change to one file
invalidates only its chunk — typically 1 of ~50 on a 1000-file
repo (~98% cache hit ratio on a small edit).
* Worker contract (gitnexus/src/core/ingestion/parsing-processor.ts):
- Extracted the chunk-result merge loop into a public
mergeChunkResults() so the same logic applies to live worker
output AND replayed cache entries.
- processParsingWithWorkers / processParsing accept an optional
outRawResults out-parameter that captures worker output before
merging — used by parse-impl to populate the cache after a miss.
* Parse phase wiring (parse-impl.ts):
- For each chunk, compute its content hash (after reading file
contents). Cache hit → mergeChunkResults() on cached results,
skip the worker dispatch entirely. Cache miss → run workers
normally, capture raw results, store under the chunk hash.
- Cache mutations happen in-place on the ParseCache passed via
PipelineOptions.parseCache.
* Lifecycle (run-analyze.ts):
- loadParseCache() before pipeline runs.
- Cache passed via runPipelineFromRepo's PipelineOptions.
- saveParseCache() after the pipeline + DB writeback succeed.
Equivalence verified on this repo (993 files, 24K nodes):
Cold (no cache, full work): 141.1s
Warm cache + 1-file edit, incremental: 63.6s ← 55% speedup
Warm cache + 1-file edit, --force: 71.6s ← 49% speedup
All three runs produce byte-identical {nodes, edges, clusters,
flows}. The cache survives --force (content-addressed = always
correct), so even forced rebuilds get the parse-skip benefit.
Why chunk-level rather than per-file: workers process sub-batches and
emit aggregated ParseWorkerResults. Per-file granularity would require
restructuring the worker contract; chunk-level captures most of the
practical speedup with no worker-side changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27f3b49d56
commit
b3431fb62f
5 changed files with 372 additions and 99 deletions
|
|
@ -83,6 +83,88 @@ export interface WorkerExtractedData {
|
|||
// Worker-based parallel parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Merge a list of `ParseWorkerResult`s into the running graph + symbol
|
||||
* table state and produce the chunk-aggregated `WorkerExtractedData`.
|
||||
*
|
||||
* Extracted from `processParsingWithWorkers` so the same merge logic can
|
||||
* be applied to both freshly-parsed worker output AND cached worker
|
||||
* output replayed during incremental analyze. Idempotent on the
|
||||
* accumulator fields (push-only); idempotent on graph if the caller
|
||||
* starts from a clean graph (otherwise duplicate `addNode` calls are
|
||||
* silently no-op'd by `KnowledgeGraph`).
|
||||
*/
|
||||
export const mergeChunkResults = (
|
||||
graph: KnowledgeGraph,
|
||||
symbolTable: SymbolTableWriter,
|
||||
chunkResults: readonly ParseWorkerResult[],
|
||||
): WorkerExtractedData => {
|
||||
const allImports: ExtractedImport[] = [];
|
||||
const allCalls: ExtractedCall[] = [];
|
||||
const allAssignments: ExtractedAssignment[] = [];
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
const fileScopeBindingsByFile: FileScopeBindings[] = [];
|
||||
const allParsedFiles: ParsedFile[] = [];
|
||||
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
id: node.id,
|
||||
label: node.label as NodeLabel,
|
||||
properties: node.properties,
|
||||
});
|
||||
}
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
for (const sym of result.symbols) {
|
||||
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
|
||||
parameterCount: sym.parameterCount,
|
||||
requiredParameterCount: sym.requiredParameterCount,
|
||||
parameterTypes: sym.parameterTypes,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
ownerId: sym.ownerId,
|
||||
qualifiedName: sym.qualifiedName,
|
||||
});
|
||||
}
|
||||
for (const item of result.imports) allImports.push(item);
|
||||
for (const item of result.calls) allCalls.push(item);
|
||||
for (const item of result.assignments) allAssignments.push(item);
|
||||
for (const item of result.heritage) allHeritage.push(item);
|
||||
for (const item of result.routes) allRoutes.push(item);
|
||||
for (const item of result.fetchCalls) allFetchCalls.push(item);
|
||||
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
|
||||
for (const item of result.toolDefs) allToolDefs.push(item);
|
||||
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
|
||||
for (const item of result.constructorBindings) allConstructorBindings.push(item);
|
||||
if (result.fileScopeBindings)
|
||||
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
|
||||
if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item);
|
||||
}
|
||||
|
||||
return {
|
||||
imports: allImports,
|
||||
calls: allCalls,
|
||||
assignments: allAssignments,
|
||||
heritage: allHeritage,
|
||||
routes: allRoutes,
|
||||
fetchCalls: allFetchCalls,
|
||||
decoratorRoutes: allDecoratorRoutes,
|
||||
toolDefs: allToolDefs,
|
||||
ormQueries: allORMQueries,
|
||||
constructorBindings: allConstructorBindings,
|
||||
fileScopeBindings: fileScopeBindingsByFile,
|
||||
parsedFiles: allParsedFiles,
|
||||
};
|
||||
};
|
||||
|
||||
const processParsingWithWorkers = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
|
|
@ -90,6 +172,14 @@ const processParsingWithWorkers = async (
|
|||
astCache: ASTCache,
|
||||
workerPool: WorkerPool,
|
||||
onFileProgress?: FileProgressCallback,
|
||||
/**
|
||||
* When provided, populated with the raw worker results before merging.
|
||||
* Used by the incremental-indexing parse cache to capture the per-chunk
|
||||
* worker output for caching across runs. The mutation happens in-place
|
||||
* so the caller (parse-impl) can keep a reference. See
|
||||
* `gitnexus/src/storage/parse-cache.ts`.
|
||||
*/
|
||||
outRawResults?: ParseWorkerResult[],
|
||||
): Promise<WorkerExtractedData> => {
|
||||
// Filter to parseable files only
|
||||
const parseableFiles: ParseWorkerInput[] = [];
|
||||
|
|
@ -124,63 +214,16 @@ const processParsingWithWorkers = async (
|
|||
},
|
||||
);
|
||||
|
||||
// Merge results from all workers into graph and symbol table
|
||||
const allImports: ExtractedImport[] = [];
|
||||
const allCalls: ExtractedCall[] = [];
|
||||
const allAssignments: ExtractedAssignment[] = [];
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
const fileScopeBindingsByFile: FileScopeBindings[] = [];
|
||||
const allParsedFiles: ParsedFile[] = [];
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
id: node.id,
|
||||
label: node.label as NodeLabel,
|
||||
properties: node.properties,
|
||||
});
|
||||
}
|
||||
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
|
||||
for (const sym of result.symbols) {
|
||||
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
|
||||
parameterCount: sym.parameterCount,
|
||||
requiredParameterCount: sym.requiredParameterCount,
|
||||
parameterTypes: sym.parameterTypes,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
ownerId: sym.ownerId,
|
||||
qualifiedName: sym.qualifiedName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of result.imports) allImports.push(item);
|
||||
for (const item of result.calls) allCalls.push(item);
|
||||
for (const item of result.assignments) allAssignments.push(item);
|
||||
for (const item of result.heritage) allHeritage.push(item);
|
||||
for (const item of result.routes) allRoutes.push(item);
|
||||
for (const item of result.fetchCalls) allFetchCalls.push(item);
|
||||
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
|
||||
for (const item of result.toolDefs) allToolDefs.push(item);
|
||||
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
|
||||
for (const item of result.constructorBindings) allConstructorBindings.push(item);
|
||||
if (result.fileScopeBindings)
|
||||
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
|
||||
// RFC #909 Ring 2: aggregate per-file scope artifacts. Tolerant of
|
||||
// workers that don't emit the field yet (older worker builds or
|
||||
// partial rollouts), since the additive contract means undefined =
|
||||
// "this worker produced no ParsedFiles for this chunk".
|
||||
if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item);
|
||||
// Capture the raw chunk results for the incremental parse cache before
|
||||
// merging — the cache stores the unmerged worker output so a future run
|
||||
// can re-merge them into a fresh graph state.
|
||||
if (outRawResults) {
|
||||
for (const r of chunkResults) outRawResults.push(r);
|
||||
}
|
||||
|
||||
// Merge results from all workers into graph and symbol table.
|
||||
const merged = mergeChunkResults(graph, symbolTable, chunkResults);
|
||||
|
||||
// Merge and log skipped languages from workers
|
||||
const skippedLanguages = new Map<string, number>();
|
||||
for (const result of chunkResults) {
|
||||
|
|
@ -197,20 +240,7 @@ const processParsingWithWorkers = async (
|
|||
|
||||
// Final progress
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return {
|
||||
imports: allImports,
|
||||
calls: allCalls,
|
||||
assignments: allAssignments,
|
||||
heritage: allHeritage,
|
||||
routes: allRoutes,
|
||||
fetchCalls: allFetchCalls,
|
||||
decoratorRoutes: allDecoratorRoutes,
|
||||
toolDefs: allToolDefs,
|
||||
ormQueries: allORMQueries,
|
||||
constructorBindings: allConstructorBindings,
|
||||
fileScopeBindings: fileScopeBindingsByFile,
|
||||
parsedFiles: allParsedFiles,
|
||||
};
|
||||
return merged;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -733,6 +763,14 @@ export const processParsing = async (
|
|||
scopeTreeCache: ASTCache | undefined,
|
||||
onFileProgress?: FileProgressCallback,
|
||||
workerPool?: WorkerPool,
|
||||
/**
|
||||
* Optional out-parameter for the incremental parse cache. When
|
||||
* provided AND the worker-pool path runs successfully, populated
|
||||
* with the raw `ParseWorkerResult[]` from the workers (pre-merge).
|
||||
* Stays empty for the sequential fallback path (no per-chunk
|
||||
* artifact to cache there). See `gitnexus/src/storage/parse-cache.ts`.
|
||||
*/
|
||||
outRawResults?: ParseWorkerResult[],
|
||||
): Promise<WorkerExtractedData | null> => {
|
||||
let lastProgress = 0;
|
||||
const reportProgress: FileProgressCallback | undefined = onFileProgress
|
||||
|
|
@ -760,6 +798,7 @@ export const processParsing = async (
|
|||
astCache,
|
||||
workerPool,
|
||||
reportProgress,
|
||||
outRawResults,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import {
|
|||
enrichExportedTypeMap,
|
||||
type BindingEntry,
|
||||
} from '../binding-accumulator.js';
|
||||
import { processParsing } from '../parsing-processor.js';
|
||||
import { processParsing, mergeChunkResults } from '../parsing-processor.js';
|
||||
import { fileContentHash, computeChunkHash } from '../../../storage/parse-cache.js';
|
||||
import type { ParseWorkerResult } from '../workers/parse-worker.js';
|
||||
import type { WorkerExtractedData } from '../parsing-processor.js';
|
||||
import {
|
||||
processImports,
|
||||
processImportsFromExtracted,
|
||||
|
|
@ -272,6 +275,14 @@ export async function runChunkedParseAndResolve(
|
|||
const deferredConstructorBindings: FileConstructorBindings[] = [];
|
||||
const deferredAssignments: ExtractedAssignment[] = [];
|
||||
|
||||
// Incremental parse cache (Option B): chunk-level content-addressed.
|
||||
// When the chunk's (filePath, content-hash) signature matches a prior
|
||||
// run's, replay the cached ParseWorkerResult[] instead of dispatching
|
||||
// to workers. See gitnexus/src/storage/parse-cache.ts.
|
||||
const parseCache = options?.parseCache;
|
||||
let chunkCacheHits = 0;
|
||||
let chunkCacheMisses = 0;
|
||||
|
||||
try {
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
const chunkPaths = chunks[chunkIdx];
|
||||
|
|
@ -281,29 +292,79 @@ export async function runChunkedParseAndResolve(
|
|||
.filter((p) => chunkContents.has(p))
|
||||
.map((p) => ({ path: p, content: chunkContents.get(p)! }));
|
||||
|
||||
const chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
(current, _total, filePath) => {
|
||||
const globalCurrent = filesParsedSoFar + current;
|
||||
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: globalCurrent,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
workerPool,
|
||||
);
|
||||
// Compute the chunk's content-hash signature (if cache available).
|
||||
let chunkHash: string | null = null;
|
||||
if (parseCache) {
|
||||
const entries = chunkFiles.map((f) => ({
|
||||
filePath: f.path,
|
||||
contentHash: fileContentHash(f.content),
|
||||
}));
|
||||
chunkHash = computeChunkHash(entries);
|
||||
}
|
||||
|
||||
let chunkWorkerData: WorkerExtractedData | null;
|
||||
const cachedRaw = chunkHash ? parseCache!.entries.get(chunkHash) : undefined;
|
||||
|
||||
if (cachedRaw && cachedRaw.length > 0) {
|
||||
// Cache hit: replay the cached worker output through the same
|
||||
// merge logic the live worker path uses.
|
||||
chunkCacheHits++;
|
||||
chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 parse-cache hit: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash!.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
// Progress update so UI advances even on a cache hit.
|
||||
const cachedFiles = chunkFiles.length;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(20 + ((filesParsedSoFar + cachedFiles) / totalParseable) * 62),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks} (cache)...`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar + cachedFiles,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Cache miss: dispatch to workers, capture the raw results, store
|
||||
// them under the chunk hash for the next run.
|
||||
chunkCacheMisses++;
|
||||
const rawResults: ParseWorkerResult[] = [];
|
||||
chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
(current, _total, filePath) => {
|
||||
const globalCurrent = filesParsedSoFar + current;
|
||||
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: globalCurrent,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
workerPool,
|
||||
// Capture raw results only when we have a cache to write to —
|
||||
// otherwise we'd retain extra arrays for nothing.
|
||||
parseCache && chunkHash ? rawResults : undefined,
|
||||
);
|
||||
// Persist the raw results for this chunk hash. Sequential path
|
||||
// doesn't populate rawResults (it writes directly to graph), so
|
||||
// small repos without worker pool simply don't cache. That's fine.
|
||||
if (parseCache && chunkHash && rawResults.length > 0) {
|
||||
parseCache.entries.set(chunkHash, rawResults);
|
||||
}
|
||||
}
|
||||
|
||||
const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62;
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,19 @@ export interface PipelineOptions {
|
|||
minFiles?: number;
|
||||
minBytes?: number;
|
||||
};
|
||||
/**
|
||||
* Incremental-indexing parse cache. When provided:
|
||||
* - The parse phase looks up each chunk's content hash in
|
||||
* `parseCache.entries`. On hit, it replays the cached
|
||||
* `ParseWorkerResult[]` instead of dispatching to workers.
|
||||
* - On miss, it runs the workers as today and stores the new
|
||||
* results in `parseCache.entries` keyed by chunk hash.
|
||||
* The caller (`run-analyze.ts`) is responsible for loading the cache
|
||||
* before the pipeline runs and persisting it after. Cache survives
|
||||
* `--force` because keys are content-addressed.
|
||||
* See `gitnexus/src/storage/parse-cache.ts`.
|
||||
*/
|
||||
parseCache?: import('../../storage/parse-cache.js').ParseCache;
|
||||
}
|
||||
|
||||
// ── Phase registry ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from '../storage/repo-manager.js';
|
||||
import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
|
||||
import { extractChangedSubgraph } from './incremental/subgraph-extract.js';
|
||||
import { loadParseCache, saveParseCache } from '../storage/parse-cache.js';
|
||||
import {
|
||||
getCurrentCommit,
|
||||
getRemoteUrl,
|
||||
|
|
@ -318,13 +319,24 @@ export async function runFullAnalysis(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Load incremental parse cache ──────────────────────────────────
|
||||
// Content-addressed: safe to reuse across `--force` runs (chunks whose
|
||||
// file contents haven't changed produce identical worker output).
|
||||
// Loaded into a single ParseCache object that the pipeline mutates
|
||||
// in-place (cache hits leave entries unchanged; misses add new ones).
|
||||
const parseCache = await loadParseCache(storagePath);
|
||||
|
||||
// ── Phase 1: Full Pipeline (0–60%) ────────────────────────────────
|
||||
const pipelineResult = await runPipelineFromRepo(repoPath, (p) => {
|
||||
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
|
||||
const scaled = Math.round(p.percent * 0.6);
|
||||
const message = p.detail ? `${p.message || phaseLabel} (${p.detail})` : p.message || phaseLabel;
|
||||
progress(p.phase, scaled, message);
|
||||
});
|
||||
const pipelineResult = await runPipelineFromRepo(
|
||||
repoPath,
|
||||
(p) => {
|
||||
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
|
||||
const scaled = Math.round(p.percent * 0.6);
|
||||
const message = p.detail ? `${p.message || phaseLabel} (${p.detail})` : p.message || phaseLabel;
|
||||
progress(p.phase, scaled, message);
|
||||
},
|
||||
{ parseCache },
|
||||
);
|
||||
|
||||
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
|
||||
progress('lbug', 60, 'Loading into LadybugDB...');
|
||||
|
|
@ -634,6 +646,16 @@ export async function runFullAnalysis(
|
|||
| undefined,
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
|
||||
// Persist the incremental parse cache for the next run. Wraps in
|
||||
// try/catch so a cache-write failure never breaks an otherwise
|
||||
// successful indexing run.
|
||||
try {
|
||||
await saveParseCache(storagePath, parseCache);
|
||||
} catch (e) {
|
||||
log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
|
||||
}
|
||||
|
||||
// Forward the --name alias and the registry-collision bypass bit.
|
||||
// `allowDuplicateName` is its own concern — independent from the
|
||||
// pipeline `force` above. The CLI maps it from
|
||||
|
|
|
|||
138
gitnexus/src/storage/parse-cache.ts
Normal file
138
gitnexus/src/storage/parse-cache.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Chunk-level content-addressed parse cache.
|
||||
*
|
||||
* The pipeline always parses every file (correctness invariant: cross-file
|
||||
* resolution and downstream phases need full graph data). What this cache
|
||||
* does is skip the tree-sitter worker dispatch when a chunk's contents
|
||||
* haven't changed since the last run.
|
||||
*
|
||||
* Granularity: chunk-level. The parse phase chunks files into ~20MB byte
|
||||
* budgets. The cache key is `sha256(joined(filePath:contentHash for each
|
||||
* file in the chunk, sorted))`. A change to a single file invalidates only
|
||||
* that file's chunk — typically 1 of ~50 chunks on a 1000-file repo.
|
||||
*
|
||||
* Why not per-file:
|
||||
* - Workers process sub-batches and emit aggregated `ParseWorkerResult`s.
|
||||
* Splitting back to per-file would require reworking the worker contract.
|
||||
* - Chunk-level invalidation gives a useful speedup floor (98% on a single
|
||||
* 1-of-50 invalidated chunk) without touching the worker.
|
||||
*
|
||||
* Survives `--force` because it's content-addressed: the same bytes always
|
||||
* produce the same key. `--force` only matters for the LadybugDB writeback;
|
||||
* the cache itself is always safe to reuse.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.js';
|
||||
|
||||
/** Bump on incompatible changes to ParseWorkerResult or upstream parse semantics. */
|
||||
export const PARSE_CACHE_VERSION = 1;
|
||||
|
||||
const CACHE_FILENAME = 'parse-cache.json';
|
||||
|
||||
/** On-disk shape. */
|
||||
interface ParseCacheFile {
|
||||
version: number;
|
||||
/** key = chunk hash (hex) → cached chunk result list. */
|
||||
entries: Record<string, ParseWorkerResult[]>;
|
||||
}
|
||||
|
||||
/** Runtime view: keyed Map for fast lookup; mutated in place during a run. */
|
||||
export interface ParseCache {
|
||||
version: number;
|
||||
entries: Map<string, ParseWorkerResult[]>;
|
||||
}
|
||||
|
||||
/** SHA-256 hex of a single string or buffer. */
|
||||
const sha256Hex = (input: Buffer | string): string =>
|
||||
createHash('sha256')
|
||||
.update(typeof input === 'string' ? Buffer.from(input) : input)
|
||||
.digest('hex');
|
||||
|
||||
/** Stable hash of a single file's contents — used by callers to compose a chunk hash. */
|
||||
export const fileContentHash = (content: Buffer | string): string => sha256Hex(content);
|
||||
|
||||
/**
|
||||
* Compute the canonical cache key for a chunk's contents.
|
||||
*
|
||||
* `entries` is the list of (filePath, file content hash) for every file
|
||||
* in the chunk. We sort by filePath before hashing so chunks composed of
|
||||
* the same files in different order produce the same key.
|
||||
*/
|
||||
export const computeChunkHash = (entries: Array<{ filePath: string; contentHash: string }>): string => {
|
||||
const sorted = [...entries].sort((a, b) => (a.filePath < b.filePath ? -1 : 1));
|
||||
const joined = sorted.map((e) => `${e.filePath}:${e.contentHash}`).join('\n');
|
||||
return sha256Hex(joined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the parse cache. Returns an empty cache on any failure (missing
|
||||
* file, corrupt JSON, version mismatch). Never throws on a normal load.
|
||||
*/
|
||||
export const loadParseCache = async (storagePath: string): Promise<ParseCache> => {
|
||||
const cachePath = path.join(storagePath, CACHE_FILENAME);
|
||||
try {
|
||||
const raw = await fs.readFile(cachePath, 'utf-8');
|
||||
const data = JSON.parse(raw) as ParseCacheFile;
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
data === null ||
|
||||
data.version !== PARSE_CACHE_VERSION ||
|
||||
typeof data.entries !== 'object' ||
|
||||
data.entries === null
|
||||
) {
|
||||
return emptyCache();
|
||||
}
|
||||
const entries = new Map<string, ParseWorkerResult[]>();
|
||||
for (const [k, v] of Object.entries(data.entries)) {
|
||||
if (Array.isArray(v)) entries.set(k, v as ParseWorkerResult[]);
|
||||
}
|
||||
return { version: PARSE_CACHE_VERSION, entries };
|
||||
} catch {
|
||||
return emptyCache();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the cache to disk atomically (write-and-rename) so a crash
|
||||
* mid-write doesn't leave a corrupt file.
|
||||
*/
|
||||
export const saveParseCache = async (
|
||||
storagePath: string,
|
||||
cache: ParseCache,
|
||||
): Promise<void> => {
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
const cachePath = path.join(storagePath, CACHE_FILENAME);
|
||||
const tmpPath = `${cachePath}.tmp`;
|
||||
const out: ParseCacheFile = {
|
||||
version: cache.version,
|
||||
entries: Object.fromEntries(cache.entries),
|
||||
};
|
||||
// Compact JSON; this file can be tens of MB on a large repo and pretty-
|
||||
// printing roughly doubles size for no value.
|
||||
await fs.writeFile(tmpPath, JSON.stringify(out), 'utf-8');
|
||||
await fs.rename(tmpPath, cachePath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop entries whose hashes are not in `usedHashes`. Called at the end
|
||||
* of a run so chunks that no longer correspond to any current chunk
|
||||
* don't keep their stale entries forever.
|
||||
*/
|
||||
export const pruneCache = (cache: ParseCache, usedHashes: ReadonlySet<string>): number => {
|
||||
let removed = 0;
|
||||
for (const k of cache.entries.keys()) {
|
||||
if (!usedHashes.has(k)) {
|
||||
cache.entries.delete(k);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
const emptyCache = (): ParseCache => ({
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map<string, ParseWorkerResult[]>(),
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue