Problem
On large Java projects (10,000+ files), the parse phase of codegraph index is significantly slower than expected. This issue documents the root causes identified through code analysis and proposes a prioritized optimization roadmap.
Root Cause Analysis
The parse pipeline has several serialization bottlenecks that compound on large codebases.
1. Single-file, multi-transaction SQLite writes
Every call to storeExtractionResult() (src/extraction/index.ts:2065-2179) opens 4~5 separate SQLite transactions per file:
Transaction 1: deleteFile (DELETE nodes + DELETE file)
Transaction 2: insertNodes (N single-row INSERTs)
Transaction 3: insertEdges (M single-row INSERTs)
Transaction 4: insertUnresolvedRefs (R single-row INSERTs)
Auto-commit: upsertFile
For a 10,000-file project, this generates 40,000~50,000 transaction commits. Each commit flushes the WAL and creates disk I/O, all running serially on the main thread (SQLite is not thread-safe, index.ts:1592). Meanwhile, parse workers may be idle waiting for the commit cursor to advance.
2. Single-row INSERT statements
Within each transaction, nodes and edges are inserted one row at a time (src/db/queries.ts:insertNode, insertEdge). A file with 100 nodes produces 100 separate INSERT OR REPLACE round-trips. SQLite performs significantly better with multi-row INSERT ... VALUES (...), (...) syntax, which would reduce the number of SQL compilation and B-tree insertion cycles.
3. Ordered commit backpressure (head-of-line blocking)
The rolling window design (windowSize = max(4, pool.size * 2), index.ts:1580) limits how far parsing can run ahead of the in-order commit cursor. The commit cursor advances in original file order to ensure deterministic graph output (resolution phase disambiguates same-named symbols by insertion order).
If a file at position N takes a long time to parse (e.g. a large generated Java file), all files at N+1 through N+windowSize that have already finished parsing are blocked from committing until position N completes:
File order: [1] [2] [3] [4] [5] [6] [7] ...
Parse done: ✓ ✓ ✓ ✓ ✓ ✓ ...
Commit cursor: 1 2 [blocked on 3]
↑ files 4-8 finished but waiting
The backpressure (index.ts:1675) then prevents dispatching new parses, leaving workers idle.
4. WASM tree-sitter overhead
codegraph uses web-tree-sitter (WASM) for parsing, which introduces several performance penalties:
- WASM binary loading and compilation per language at startup
- WASM linear memory that only grows, never shrinks (WebAssembly specification limitation), forcing worker recycling every 250 files (
WORKER_RECYCLE_INTERVAL, index.ts:66) to reclaim heap
- Worker termination and respawn cost (cold start includes grammar WASM reload)
- WASM ↔ JS marshalling overhead for every AST query
The 1 MB per-file size limit (MAX_FILE_SIZE, index.ts:125) exists partly because WASM heap pressure makes larger files dangerous.
5. Small I/O batch size
FILE_IO_BATCH_SIZE = 10 (index.ts:36) means files are read from disk in batches of 10. For 10,000 files, this requires 1,000 I/O round-trips, each gated by the backpressure window.
6. Small worker pool
Default pool size is clamp(CPU_COUNT - 1, 1, 8), hard-capped at 16 (MAX_PARSE_POOL_SIZE, parse-pool.ts:59). On machines with 32+ cores, 50~75% of CPU capacity is left idle during the CPU-bound parse phase.
Optimization Roadmap
P0 — Batch transaction commit (highest impact, smallest change)
Problem: 4~5 individual transactions per file, each committing to disk independently.
Solution: Wrap the entire storeExtractionResult() body in a single transaction. Better yet, accumulate results from N files in the flushOrdered callback and commit them together. For example, instead of committing per-file, batch-commit every 50~100 files:
pendingStore = []
flushOrdered:
dequeue completed items into pendingStore
if pendingStore.length >= BATCH_SIZE:
transaction:
for each item in pendingStore:
run storeExtractionResult logic
clear pendingStore
This reduces transaction count from O(files) to O(files / batchSize).
Files: src/extraction/index.ts (lines ~1589-1665, ~2065-2179)
Estimated speedup: 5~10x on the storage path.
P1 — Multi-row INSERTs
Problem: Individual INSERT OR REPLACE per node/edge row.
Solution: Replace the per-row loop in insertNodes() and insertEdges() with INSERT OR REPLACE INTO nodes (...) VALUES (...), (...), ... multi-row syntax. SQLite processes multi-row INSERTs as a single B-tree insertion operation, dramatically reducing overhead.
Files: src/db/queries.ts (insertNodes, insertEdges)
Estimated speedup: 2~5x on the SQL write path.
P2 — Remove ordered-commit dependency (medium change)
Problem: Insertion-order determinism forces head-of-line blocking, which keeps workers idle.
Solution: Pre-allocate stable IDs during the scan phase (before parsing begins) so that workers can write results to SQLite in any order without affecting resolution determinism. This eliminates the need for ordered commit entirely.
Approach: assign each file a sequential fileId during scanning. Use fileId as a prefix or component of node IDs. Since IDs are pre-determined, insertion order no longer matters for disambiguation, and flushOrdered can be replaced with free-order commit.
Trade-off: Requires auditing the resolution phase (ReferenceResolver) to ensure disambiguation no longer depends on insertion order.
Files: src/extraction/index.ts, src/db/queries.ts, possibly resolution phase
Estimated speedup: 2~3x (eliminates worker idle time from backpressure).
P3 — Native tree-sitter bindings (large change)
Problem: WASM overhead for CPU-bound parsing + forced worker recycling.
Solution: Replace web-tree-sitter with the native tree-sitter Node.js addon (C-based native binding via N-API). Benefits:
- Eliminates WASM loading/compilation time per grammar
- Removes the WASM heap growth problem — native heap is managed by the system allocator, so worker recycling at 250 files is no longer needed
- Removes WASM ↔ JS marshalling overhead
- Reduces per-worker memory footprint (no separate WASM linear memory region)
Files: src/extraction/ (parse-worker.ts, grammars.ts, tree-sitter.ts, parse-pool.ts)
Estimated speedup: 2~3x on parse CPU time.
P4 — Tune constants (trivial change)
Problem: Conservative batch/worker limits.
Solution:
- Increase
FILE_IO_BATCH_SIZE from 10 to 50~100
- Raise
MAX_PARSE_POOL_SIZE from 16 to 64 (or make it proportional to core count)
- Allow
resolveParsePoolSize to scale more aggressively (e.g. clamp(cores - 1, 1, 32) instead of clamp(cores - 1, 1, 8))
Files: src/extraction/index.ts, src/extraction/parse-pool.ts
Estimated speedup: 1.5~2x.
Summary
| Priority |
Optimization |
Est. Speedup |
Risk |
Scope |
| P0 |
Batch transaction commit |
5~10x |
Low |
index.ts only |
| P1 |
Multi-row INSERTs |
2~5x |
Low |
queries.ts only |
| P2 |
Remove ordered-commit dependency |
2~3x |
Medium |
Multiple files |
| P3 |
Native tree-sitter bindings |
2~3x |
High |
Extraction layer |
| P4 |
Tune constants |
1.5~2x |
None |
Constants only |
The highest ROI items are P0 and P1 — purely SQLite usage optimizations that require no architectural changes and no dependency swaps. Together they would reduce per-file write overhead by roughly an order of magnitude, making the parse phase on large Java projects significantly faster without altering codegraph's architecture.
Problem
On large Java projects (10,000+ files), the
parsephase ofcodegraph indexis significantly slower than expected. This issue documents the root causes identified through code analysis and proposes a prioritized optimization roadmap.Root Cause Analysis
The parse pipeline has several serialization bottlenecks that compound on large codebases.
1. Single-file, multi-transaction SQLite writes
Every call to
storeExtractionResult()(src/extraction/index.ts:2065-2179) opens 4~5 separate SQLite transactions per file:For a 10,000-file project, this generates 40,000~50,000 transaction commits. Each commit flushes the WAL and creates disk I/O, all running serially on the main thread (SQLite is not thread-safe,
index.ts:1592). Meanwhile, parse workers may be idle waiting for the commit cursor to advance.2. Single-row INSERT statements
Within each transaction, nodes and edges are inserted one row at a time (
src/db/queries.ts:insertNode,insertEdge). A file with 100 nodes produces 100 separateINSERT OR REPLACEround-trips. SQLite performs significantly better with multi-rowINSERT ... VALUES (...), (...)syntax, which would reduce the number of SQL compilation and B-tree insertion cycles.3. Ordered commit backpressure (head-of-line blocking)
The rolling window design (
windowSize = max(4, pool.size * 2),index.ts:1580) limits how far parsing can run ahead of the in-order commit cursor. The commit cursor advances in original file order to ensure deterministic graph output (resolution phase disambiguates same-named symbols by insertion order).If a file at position N takes a long time to parse (e.g. a large generated Java file), all files at N+1 through N+windowSize that have already finished parsing are blocked from committing until position N completes:
The backpressure (
index.ts:1675) then prevents dispatching new parses, leaving workers idle.4. WASM tree-sitter overhead
codegraph uses
web-tree-sitter(WASM) for parsing, which introduces several performance penalties:WORKER_RECYCLE_INTERVAL,index.ts:66) to reclaim heapThe 1 MB per-file size limit (
MAX_FILE_SIZE,index.ts:125) exists partly because WASM heap pressure makes larger files dangerous.5. Small I/O batch size
FILE_IO_BATCH_SIZE = 10(index.ts:36) means files are read from disk in batches of 10. For 10,000 files, this requires 1,000 I/O round-trips, each gated by the backpressure window.6. Small worker pool
Default pool size is
clamp(CPU_COUNT - 1, 1, 8), hard-capped at 16 (MAX_PARSE_POOL_SIZE,parse-pool.ts:59). On machines with 32+ cores, 50~75% of CPU capacity is left idle during the CPU-bound parse phase.Optimization Roadmap
P0 — Batch transaction commit (highest impact, smallest change)
Problem: 4~5 individual transactions per file, each committing to disk independently.
Solution: Wrap the entire
storeExtractionResult()body in a single transaction. Better yet, accumulate results from N files in theflushOrderedcallback and commit them together. For example, instead of committing per-file, batch-commit every 50~100 files:This reduces transaction count from O(files) to O(files / batchSize).
Files:
src/extraction/index.ts(lines ~1589-1665, ~2065-2179)Estimated speedup: 5~10x on the storage path.
P1 — Multi-row INSERTs
Problem: Individual
INSERT OR REPLACEper node/edge row.Solution: Replace the per-row loop in
insertNodes()andinsertEdges()withINSERT OR REPLACE INTO nodes (...) VALUES (...), (...), ...multi-row syntax. SQLite processes multi-row INSERTs as a single B-tree insertion operation, dramatically reducing overhead.Files:
src/db/queries.ts(insertNodes,insertEdges)Estimated speedup: 2~5x on the SQL write path.
P2 — Remove ordered-commit dependency (medium change)
Problem: Insertion-order determinism forces head-of-line blocking, which keeps workers idle.
Solution: Pre-allocate stable IDs during the scan phase (before parsing begins) so that workers can write results to SQLite in any order without affecting resolution determinism. This eliminates the need for ordered commit entirely.
Approach: assign each file a sequential
fileIdduring scanning. UsefileIdas a prefix or component of node IDs. Since IDs are pre-determined, insertion order no longer matters for disambiguation, andflushOrderedcan be replaced with free-order commit.Trade-off: Requires auditing the resolution phase (
ReferenceResolver) to ensure disambiguation no longer depends on insertion order.Files:
src/extraction/index.ts,src/db/queries.ts, possibly resolution phaseEstimated speedup: 2~3x (eliminates worker idle time from backpressure).
P3 — Native tree-sitter bindings (large change)
Problem: WASM overhead for CPU-bound parsing + forced worker recycling.
Solution: Replace
web-tree-sitterwith the nativetree-sitterNode.js addon (C-based native binding via N-API). Benefits:Files:
src/extraction/(parse-worker.ts, grammars.ts, tree-sitter.ts, parse-pool.ts)Estimated speedup: 2~3x on parse CPU time.
P4 — Tune constants (trivial change)
Problem: Conservative batch/worker limits.
Solution:
FILE_IO_BATCH_SIZEfrom 10 to 50~100MAX_PARSE_POOL_SIZEfrom 16 to 64 (or make it proportional to core count)resolveParsePoolSizeto scale more aggressively (e.g.clamp(cores - 1, 1, 32)instead ofclamp(cores - 1, 1, 8))Files:
src/extraction/index.ts,src/extraction/parse-pool.tsEstimated speedup: 1.5~2x.
Summary
index.tsonlyqueries.tsonlyThe highest ROI items are P0 and P1 — purely SQLite usage optimizations that require no architectural changes and no dependency swaps. Together they would reduce per-file write overhead by roughly an order of magnitude, making the parse phase on large Java projects significantly faster without altering codegraph's architecture.