Skip to content

perf(ast): bulk-insert AST nodes via native Rust/rusqlite#651

Merged
carlos-alm merged 8 commits intomainfrom
perf/ast-node-bulk-insert
Mar 27, 2026
Merged

perf(ast): bulk-insert AST nodes via native Rust/rusqlite#651
carlos-alm merged 8 commits intomainfrom
perf/ast-node-bulk-insert

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • Adds bulk_insert_ast_nodes napi function in new crates/codegraph-core/src/ast_db.rs — opens SQLite directly from Rust via rusqlite, pre-fetches parent node definitions, and inserts all AST rows in a single transaction
  • JS-side buildAstNodes tries the native fast path first (when all files have native astNodes arrays), falling back to existing JS loop for WASM/mixed-engine scenarios
  • Adds bulkInsertAstNodes to NativeAddon interface in src/types.ts

Motivation

AST node insertion was the bottleneck in the analysis phase (~393ms), iterating per-row through JS with individual insertStmt.run() calls plus bulkNodeIdsByFile() per file. This moves the entire operation to a single Rust transaction, targeting astMs < 50ms on native full builds.

Test plan

  • All 2129 existing tests pass (no behavior change — same data inserted)
  • CI builds the native addon and runs the full test suite
  • Verify astMs timing in build output drops significantly on native engine builds

Move AST node SQLite inserts from per-row JS iteration to a single
native Rust transaction via napi-rs + rusqlite. The new
bulkInsertAstNodes function opens the DB directly from Rust,
pre-fetches parent node definitions, and inserts all rows in one
transaction — eliminating the JS-native FFI overhead per row.

The JS-side buildAstNodes tries the native fast path first (when
all files have native astNodes arrays), falling back to the existing
JS loop for WASM or mixed-engine scenarios.

Target: astMs < 50ms on native full builds (was ~393ms).
@claude
Copy link
Copy Markdown

claude bot commented Mar 27, 2026

Claude finished @carlos-alm's task —— View job


PR Review: Bulk AST Node Insertion Performance Optimization

Reviewing this performance optimization that adds native Rust bulk insertion for AST nodes.

Todo List

  • Analyze PR scope and root cause
  • Read and understand all changed files
  • Check for configuration/test integrity issues
  • Review Rust implementation for safety and correctness
  • Review TypeScript integration and fallback logic
  • Evaluate against backlog compliance criteria
  • Provide final assessment and rating

Root Cause Analysis

Problem Being Solved: AST node insertion during analysis phase was taking 393ms due to per-row JS iteration calling individual insertStmt.run() plus bulkNodeIdsByFile() per file. This represents a legitimate performance bottleneck, not a symptom of deeper issues.

Why This Approach: Moving to native bulk insertion eliminates JS-native FFI overhead per row and leverages SQLite transaction batching. The approach is justified as it preserves exact same data/behavior while dramatically improving performance.

Risk Assessment: Low risk - well-contained change with proper fallback logic.

Configuration Change Detection ✅

  • No test timeouts changed
  • No linting rules disabled
  • No coverage thresholds lowered
  • Only legitimate addition of rusqlite dependency to Cargo.toml

Test Integrity ✅

  • No test expectations lowered - Claims all 2129 existing tests pass
  • No assertions weakened - Same data inserted, just via different path
  • Proper test coverage exists - Found comprehensive AST tests in tests/parsers/ast-nodes.test.ts and tests/parsers/ast-all-langs.test.ts

Code Quality Analysis

Rust Implementation (ast_db.rs) ⭐⭐⭐⭐

Strengths:

  • Proper error handling with early returns on DB failures
  • Uses prepared statements to prevent SQL injection
  • Applies performance pragmas (PRAGMA synchronous = NORMAL) matching existing JS
  • Validates table existence before insertion
  • Single transaction for atomicity
  • Clean parent resolution algorithm in find_parent_id()

Minor Concerns:

  • Line 73: SQLITE_OPEN_NO_MUTEX flag could cause issues if other threads access the same DB, but likely safe given single-threaded Node.js context
  • Lines 74,86,94,117,127: Silent failure by returning 0 on errors - could make debugging harder, but matches JS patterns

TypeScript Integration (ast.ts) ⭐⭐⭐⭐

Excellent fallback logic:

  • Lines 86-102: Detects if all files have native astNodes arrays before attempting bulk path
  • Falls back to existing JS implementation for mixed WASM/native scenarios
  • Lines 98-101: Properly detects need for JS fallback when symbols.calls or symbols._tree exist
  • Line 138-159: Maintains existing behavior - avoids duplication when native provides AST nodes

Type Safety (types.ts) ⭐⭐⭐

  • Proper interface definition for bulkInsertAstNodes function
  • Type structure matches Rust implementation

Backlog Compliance

Zero-dep: ✓ - Only adds rusqlite as Rust dependency, no new runtime npm packages

Foundation-aligned: ✓ - Fully compliant

  • Builds on existing tree-sitter/SQLite foundation
  • No cloud API calls or external dependencies
  • Purely performance optimization, doesn't change behavior
  • Maintains programmatic API contract

Problem-fit: 5/5 - Directly addresses core problem

  • Reduces analysis phase bottleneck from 393ms to target <50ms
  • Improves agent responsiveness during codebase analysis
  • Eliminates per-row FFI overhead that was wasting compute cycles

Breaking: No - Purely additive with transparent fallback

Tier: Tier 1 (zero-dep ✓ + foundation-aligned ✓)

Critical Concerns

None identified - This is a well-implemented performance optimization with proper safeguards.

Final Recommendation

  • Rating: ⭐⭐⭐⭐⭐ (5/5)
  • Action: APPROVE
  • Reasoning: Exceptional implementation that directly addresses a real performance bottleneck. Proper error handling, clean fallback logic, maintains exact behavioral parity, and comprehensive existing test coverage. The 8x+ performance improvement (393ms → <50ms target) will significantly improve analysis speed for large codebases.

This is exactly the type of targeted optimization codegraph needs - eliminates a genuine bottleneck while preserving all existing behavior and contracts.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR moves AST node insertion from a per-row JS loop into a single Rust/rusqlite transaction, targeting a drop in astMs from ~393ms to under 50ms on native builds. A JS fallback is retained for WASM/mixed-engine scenarios, and the native path degrades gracefully back to that fallback on any error.

All five issues raised in the prior review round have been resolved:

  • Silent failure on Rust errorexpectedNodes is now computed before the native call; a count mismatch (including the 0-on-error case) falls through to the JS path instead of returning early.
  • Missing busy_timeoutPRAGMA busy_timeout = 5000 is now included in the Rust connection's execute_batch, matching the JS-side setting and preventing SQLITE_BUSY from silently dropping rows.
  • Dual-SQLite-instance trade-off — The bundled feature choice is now documented inline with its rationale (Windows CI portability, OS-level WAL coordination).
  • find_parent_id null end_line semantics — The Rust function now mirrors findParentDef exactly: end_line = NULL is treated as always-enclosing with a negative sentinel span -(d.line), matching the JS (def.endLine ?? 0) - def.line formula.
  • Partial-commit / duplicate-row risk — Row-level execute failures now return 0 immediately, causing the Transaction to roll back on drop before any commit, ensuring all-or-nothing semantics.

The find_parent_id logic handles all edge cases correctly: multiple NULL end-line definitions are tie-broken by largest line (most-negative span), matching JS behaviour; line = 0 with NULL end_line yields span 0, still beating i64::MAX and being correctly selected.

Confidence Score: 5/5

Safe to merge — all prior critical issues resolved, implementation is correct and degrades gracefully to the existing JS path.

All five previously-flagged issues (silent error swallowing, missing busy_timeout, null end_line semantics mismatch, partial-commit data duplication, bundled SQLite documentation) are fully addressed. The remaining code is straightforward: the Rust function is correctly atomic, the JS fallback is intact, the parent-resolution logic is verified to match JS semantics across all edge cases, and serde is already a direct dependency so the derive macros compile cleanly. No new P0 or P1 issues found.

No files require special attention.

Important Files Changed

Filename Overview
crates/codegraph-core/src/ast_db.rs New module implementing bulk AST insert via rusqlite; all previously-flagged issues (busy_timeout, null end_line semantics, atomic rollback) are correctly addressed.
src/features/ast.ts Native fast path correctly computes expectedNodes before calling Rust, compares against returned count, and falls through to the existing JS loop on any mismatch.
crates/codegraph-core/Cargo.toml Adds rusqlite with bundled feature; rationale for the dual-SQLite-instance trade-off is now documented in an inline comment.
crates/codegraph-core/src/lib.rs One-line addition exposing the new ast_db module; no issues.
src/types.ts Adds bulkInsertAstNodes to NativeAddon interface with accurate type signature matching the Rust function.

Reviews (4): Last reviewed commit: "Merge branch 'perf/ast-node-bulk-insert'..." | Re-trigger Greptile

Comment on lines +104 to +108
if (!needsJsFallback) {
const inserted = native.bulkInsertAstNodes(db.name, batches);
debug(`AST extraction (native bulk): ${inserted} nodes stored`);
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unconditional early return swallows Rust-side failures silently

bulkInsertAstNodes returns 0 for two completely different situations: (a) legitimately zero rows to insert, and (b) any of several hard error conditions in the Rust layer (DB open failure, ast_nodes table missing, conn.transaction() fails with SQLITE_BUSY, prepared-statement error, or tx.commit() failure). The JS code currently treats both outcomes identically and returns early without ever attempting the JS fallback path.

In error scenario (b) every AST node in every file in the batch is silently dropped — no warning is emitted and no fallback occurs. Given that the Rust connection opens the same WAL file without a busy_timeout (see companion comment), a SQLITE_BUSY during conn.transaction() is a realistic trigger.

A minimal fix is to count expected nodes before calling and fall back if the returned count doesn't match:

if (!needsJsFallback) {
  const expectedNodes = batches.reduce((s, b) => s + b.nodes.length, 0);
  const inserted = native.bulkInsertAstNodes(db.name, batches);
  if (inserted === expectedNodes) {
    debug(`AST extraction (native bulk): ${inserted} nodes stored`);
    return;
  }
  debug(`AST extraction (native bulk): expected ${expectedNodes}, got ${inserted} — falling back to JS`);
  // fall through to JS path
}

Alternatively, change the Rust function signature to Result<u32, napi::Error> so errors are propagated as JS exceptions that can be caught and trigger the fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the native bulk-insert call now computes expectedNodes before calling and compares it against the returned count. On mismatch (including Rust-side errors that return 0), we log a debug message and fall through to the JS fallback path instead of returning early.

Comment on lines +77 to +78
// Match the JS-side performance pragmas
let _ = conn.execute_batch("PRAGMA synchronous = NORMAL");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing busy_timeout pragma turns SQLITE_BUSY into silent data loss

The comment says "Match the JS-side performance pragmas", but src/db/connection.ts also sets db.pragma('busy_timeout = 5000') on the JS connection. The Rust connection omits this, so conn.transaction() will immediately fail with SQLITE_BUSY if better-sqlite3 happens to hold a WAL write lock at the moment this function is called (e.g., during a concurrent write in the same build phase).

Without a timeout, conn.transaction() returns an error, the function returns 0, and — because the JS caller makes no distinction between 0-rows-success and 0-rows-error — all AST nodes are silently dropped.

// Match the JS-side performance pragmas
let _ = conn.execute_batch(
    "PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000",
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added \ to the Rust connection's pragma batch, matching the JS-side \ setting. This ensures WAL contention retries for 5 seconds instead of failing immediately with SQLITE_BUSY.

tree-sitter-php = "0.23"
tree-sitter-hcl = "1"
rayon = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 bundled feature embeds a second independent copy of SQLite into the process

With features = ["bundled"], rusqlite statically compiles its own SQLite C library. better-sqlite3 already bundles its own copy. The process will therefore contain two separate SQLite runtime instances that each open the same .db file via OS-level file locking.

This is functionally correct — WAL-mode coordination is handled by the OS — but it has a few downsides:

  • Binary size grows (SQLite is ~1 MB of C compiled)
  • rusqlite's bundled SQLite version is independent of better-sqlite3's, requiring separate tracking when a SQLite CVE lands

If the target platform is guaranteed to have a system SQLite (Linux/macOS), removing bundled and relying on libsqlite3-sys with dynamic linking would keep a single SQLite instance. If portability is the priority (e.g., Windows CI without system SQLite), bundled is the safer choice — just worth a conscious decision here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment documenting the rationale: bundled is intentional because Windows CI lacks a system SQLite, and WAL coordination between the two instances is handled safely at the OS level. Removing it would break Windows builds.

The Rust connection omitted busy_timeout = 5000 which the JS-side
connection.ts sets. Without it, SQLITE_BUSY is returned immediately
on WAL contention instead of retrying for 5 seconds.
)

bulkInsertAstNodes returns 0 for both "nothing to insert" and hard
errors (DB open failure, SQLITE_BUSY, etc). Compare expected vs actual
count and fall through to the JS path on mismatch so errors don't
silently drop all AST nodes.
Explain why bundled is intentional: Windows CI lacks system SQLite,
and dual-instance WAL coordination is OS-safe.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

Comment on lines +41 to +58
fn find_parent_id(defs: &[NodeDef], line: u32) -> Option<i64> {
let mut best_id: Option<i64> = None;
let mut best_span = u32::MAX;
for d in defs {
if d.line <= line {
if let Some(el) = d.end_line {
if el >= line {
let span = el - d.line;
if span < best_span {
best_id = Some(d.id);
best_span = span;
}
}
}
}
}
best_id
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 find_parent_id silently disagrees with JS findParentDef on null end_line

The JS findParentDef (ast.ts line 54) explicitly handles def.endLine == null as a valid enclosing parent:

if (def.line <= line && (def.endLine == null || def.endLine >= line)) {

A definition with endLine == null is treated as though it always encloses the queried line, and the tie-breaking span formula (def.endLine ?? 0) - def.line gives it a negative span, which actually makes it preferred over definitions that have an explicit endLine.

The Rust implementation skips every definition where end_line is None entirely — only the if let Some(el) = d.end_line branch executes. A node whose only enclosing definition has end_line = NULL in the nodes table will receive parent_node_id = NULL from the native path but a non-null value from the JS path.

This violates the PR's "no behavior change — same data inserted" guarantee. To match JS semantics, treat end_line = None as always enclosing with a negative sentinel span:

fn find_parent_id(defs: &[NodeDef], line: u32) -> Option<i64> {
    let mut best_id: Option<i64> = None;
    let mut best_span: i64 = i64::MAX;
    for d in defs {
        if d.line <= line {
            let span: i64 = match d.end_line {
                Some(el) if el >= line => (el - d.line) as i64,
                Some(_) => continue,
                None => -(d.line as i64), // mirrors JS: (null ?? 0) - line
            };
            if span < best_span {
                best_id = Some(d.id);
                best_span = span;
            }
        }
    }
    best_id
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — updated find_parent_id to match JS findParentDef semantics: definitions with end_line = NULL are now treated as always-enclosing with a negative sentinel span, so they are preferred over wider definitions just like in the JS path.

The Rust find_parent_id skipped definitions with end_line = NULL,
but the JS findParentDef treats them as always-enclosing with a
negative span (preferred over wider defs). This caused parent_node_id
mismatches between native and JS paths.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

Comment on lines +144 to +158
if insert_stmt
.execute(params![
&batch.file,
node.line,
&node.kind,
&node.name,
&node.text,
&node.receiver,
parent_id,
])
.is_ok()
{
total += 1;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Individual execute failures commit partial data and misfire JS fallback

insert_stmt.execute(…).is_ok() silently discards row-level errors and lets the outer transaction continue. If any row fails to insert (e.g., a NOT NULL violation on an unexpected NULL in incoming data, or a future schema constraint), the transaction will commit with total < expectedNodes. The JS caller interprets the mismatch as a Rust-side catastrophic failure and re-runs the entire JS path on top of the already-partially-committed rows. Because ast_nodes has no UNIQUE constraint, this produces duplicate rows for every successfully-inserted Rust row.

The fix is to treat any row-level execute failure as fatal for the whole transaction — returning 0 immediately so the transaction drops and rolls back, ensuring all-or-nothing semantics:

for node in &batch.nodes {
    let parent_id = find_parent_id(defs, node.line);
    match insert_stmt.execute(params![
        &batch.file,
        node.line,
        &node.kind,
        &node.name,
        &node.text,
        &node.receiver,
        parent_id,
    ]) {
        Ok(_) => total += 1,
        Err(_) => return 0, // triggers JS fallback; tx rolls back on drop
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced .is_ok() with a match that returns 0 on the first row-level execute() failure. This drops the transaction (rolling back all inserts) and triggers the JS fallback cleanly, ensuring all-or-nothing semantics with no partial commits or duplicate rows.

Return 0 immediately on any insert_stmt.execute() failure so the
transaction drops and rolls back, ensuring all-or-nothing semantics.
Previously, .is_ok() silently swallowed row-level errors which could
commit partial data and misfire the JS fallback causing duplicate rows.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit d2235f0 into main Mar 27, 2026
23 checks passed
@carlos-alm carlos-alm deleted the perf/ast-node-bulk-insert branch March 27, 2026 05:51
@github-actions github-actions bot locked and limited conversation to collaborators Mar 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant