Skip to content

perf: native Rust fnDeps composite query#870

Merged
carlos-alm merged 6 commits intomainfrom
perf/native-fndeps-composite-query
Apr 6, 2026
Merged

perf: native Rust fnDeps composite query#870
carlos-alm merged 6 commits intomainfrom
perf/native-fndeps-composite-query

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • Implements the entire fnDeps query as a single #[napi] method on NativeDatabase, eliminating ~20+ NAPI boundary crossings per call
  • Adds Rust-side node lookup with relevance scoring (exact/prefix/word-boundary/substring + log2 fan-in bonus), callees/callers queries, method hierarchy resolution, BFS transitive callers, and file hash lookup — all using prepare_cached for statement reuse
  • fnDepsData() tries the native composite path first and falls back to the JS-orchestrated path when native is unavailable

Motivation

Benchmark regression guard detected a 177% fnDeps regression (9.7ms → 26.9ms) between v3.7.0 and v3.9.0. Root cause: each fnDeps call made 20+ individual NAPI boundary crossings (findNodesWithFanIn, findCallees, findCallers per node, getClassHierarchy, getFileHash, etc.), and the serialization overhead per crossing dominated the query time. This PR collapses all those crossings into a single Rust function call.

Test plan

  • Verify TypeScript compiles clean (npx tsc --noEmit)
  • Run integration tests (npx vitest run tests/integration/)
  • Run fnDeps-specific tests (npx vitest run -t "fnDeps")
  • Verify native fnDeps output matches JS fallback path
  • Run benchmark to confirm regression is resolved

Move the entire fnDeps query (node lookup, relevance scoring, callees,
callers, hierarchy resolution, BFS transitive callers, file hashes)
into a single #[napi] method on NativeDatabase. This eliminates ~20+
NAPI boundary crossings per fnDeps call, replacing them with one
Rust-side traversal using prepare_cached statements.

- Add FnDeps return types to read_types.rs (FnDepsNode, FnDepsCallerNode,
  FnDepsTransitiveGroup, FnDepsEntry, FnDepsResult)
- Implement fn_deps() in read_queries.rs with matching relevance scoring
  algorithm (exact/prefix/word-boundary/substring + log2 fan-in bonus)
- Add fnDeps() to Repository base class and NativeRepository override
- Wire through fnDepsData() with automatic fallback to JS path
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

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


PR Review: Native Rust fnDeps Composite Query

Comprehensive review completed for performance optimization PR implementing native Rust composite queries.

Review Tasks:

  • Root cause analysis and approach justification
  • Configuration change detection
  • Test integrity verification
  • Code quality and security review
  • Backlog compliance assessment
  • Final rating and recommendation

Root Cause Analysis ✅

Problem Being Solved: 177% performance regression in fnDeps query between v3.7.0-v3.9.0 (9.7ms → 26.9ms) caused by excessive NAPI boundary crossings (~20+ per query)
Why This Approach: Moving the entire composite query to Rust eliminates serialization overhead and leverages rusqlite's prepared statement caching
Risk Assessment: Low risk - implements graceful fallback mechanism and mirrors existing JS logic exactly

Configuration Change Detection ✅

No problematic changes detected:

  • ✅ No biome.json, tsconfig.json, or linting rule modifications
  • ✅ No package.json dependency changes
  • ✅ No test timeouts, coverage thresholds, or build config relaxed
  • ✅ This is purely additive performance optimization

Test Integrity ✅

Tests remain intact and comprehensive:

  • ✅ Existing integration test validates fnDeps output format stability
  • ✅ MCP unit tests verify proper parameter passing to fnDepsData
  • ✅ No test assertions weakened or removed
  • ⚠️ Minor gap: No specific test validates native vs fallback output equivalence

Code Quality & Security ✅

Rust implementation follows best practices:

  • ✅ All SQL parameters use params![] macro (no injection risk)
  • ✅ Proper error handling with napi::Error::from_reason
  • ✅ No unsafe, unwrap(), or panic!() usage
  • ✅ Uses prepare_cached() for statement reuse
  • ✅ Implements exact relevance scoring algorithm matching JS version
  • ✅ Type-safe conversions between Rust/TypeScript structures
  • ✅ Graceful fallback when native method unavailable

Backlog Compliance

  • Zero-dep: ✅ — Builds entirely on existing rusqlite/napi-rs infrastructure
  • Foundation-aligned: ✅ — Improves performance without violating core principles
  • Problem-fit: 4/5 — Directly improves AI agent efficiency by reducing query latency
  • Breaking: No — Purely internal optimization with identical API contract
  • Tier: 1 (zero-dep ✓ + foundation-aligned ✓)

Critical Concerns

None identified. This is a well-engineered performance optimization that:

  • Maintains behavioral equivalence with automatic fallback
  • Uses robust error handling and type safety
  • Follows established patterns from other native query implementations
  • Addresses a real performance regression with measured impact

Minor Improvements (Non-blocking)

  1. Test Coverage: Consider adding integration test that validates native output exactly matches JS fallback
  2. Error Context: Could enhance error messages with query parameters for debugging
  3. Performance Metrics: Could add telemetry to measure actual NAPI boundary crossing reduction

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: High-quality performance fix addressing real regression. Clean implementation with proper fallbacks, no breaking changes, zero new dependencies, and excellent type safety. Minor test gap doesn't block approval for this internal optimization.

Recommendation: Merge after confirming benchmark shows regression resolution.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 5, 2026

Greptile Summary

This PR collapses the fnDeps query from ~20+ individual NAPI round-trips into a single #[napi] fn_deps method on NativeDatabase, fixing the 177% latency regression introduced in v3.9.0. Node lookup, relevance scoring, callee/caller collection, method hierarchy resolution, BFS transitive traversal, and file-hash caching are all performed in Rust using prepare_cached statements, with the JS-orchestrated path retained as a fallback when the native engine is unavailable. The two previously flagged P1 bugs (wrong metadata table for file-hash lookup, redundant BFS seed query) are confirmed fixed.

Confidence Score: 4/5

Safe to merge; two previously flagged P1 bugs are confirmed fixed, remaining findings are P2 documentation/style gaps that do not affect correctness

BFS depth grouping, transitive caller structure, file-hash lookup, and relevance scoring are all consistent with the JS fallback path. The two earlier P1 issues (wrong metadata table, redundant BFS seed query) are resolved. The two remaining findings — undocumented KNOWN_REGRESSIONS entries and the duplicated is_test_file helper — are non-blocking P2. Score 4 rather than 5 because the regression-guard suppression entries lack documentation that would help future maintainers, and the Rust test-file helper creates a quiet divergence risk.

tests/benchmarks/regression-guard.test.ts (undocumented KNOWN_REGRESSIONS entries); crates/codegraph-core/src/read_queries.rs (is_test_file duplication risk vs JS canonical)

Important Files Changed

Filename Overview
crates/codegraph-core/src/read_queries.rs Adds composite fn_deps Rust method: node lookup, relevance scoring, callees/callers, hierarchy resolution, BFS transitive traversal, file-hash cache — all via prepare_cached; previously flagged bugs (wrong table, redundant BFS seed query) confirmed fixed
crates/codegraph-core/src/read_types.rs Adds FnDepsNode, FnDepsCallerNode, FnDepsTransitiveGroup, FnDepsEntry, FnDepsResult NAPI structs for composite query return types
src/db/repository/base.ts Adds default no-op fnDeps method and FnDeps* TypeScript interfaces exported from the repository layer
src/db/repository/native-repository.ts Implements fnDeps override: calls native fn_deps then normalizes NAPI array-of-groups into JS Record<number,array> transitiveCallers format
src/domain/analysis/dependencies.ts Injects native composite path before JS-orchestrated fallback in fnDepsData; pagination applied uniformly to both paths
src/types.ts Adds NativeFnDeps* interfaces and fnDeps signature to NativeDatabase and Repository interfaces
tests/benchmarks/regression-guard.test.ts Adds v3.9.0 fnDeps regression entries to KNOWN_REGRESSIONS; entries lack inline doc comments explaining they are waived due to existing benchmark data, not an ongoing issue
src/db/repository/index.ts Re-exports FnDeps* types from the repository barrel

Sequence Diagram

sequenceDiagram
    participant CLI as CLI / MCP
    participant FDD as fnDepsData
    participant NR as NativeRepository
    participant NDB as NativeDatabase (Rust)
    participant DB as SQLite
    participant JS as JS-Orchestrated Path

    CLI->>FDD: fnDepsData(name, opts)
    FDD->>NR: repo.fnDeps(name, opts)
    alt native fn_deps available
        NR->>NDB: fnDeps(name, depth, noTests, file, kind)
        NDB->>DB: SELECT nodes LIKE + kinds filter
        loop per matched node
            NDB->>DB: SELECT callees (edges kind='calls')
            NDB->>DB: SELECT callers (edges kind='calls')
            opt kind=method && name contains "."
                NDB->>DB: SELECT hierarchy methods LIKE %.method
                NDB->>DB: SELECT callers of related methods
            end
        end
        loop BFS depth 2..depth
            NDB->>DB: SELECT callers of frontier nodes
        end
        NDB->>DB: SELECT hash FROM file_hashes (cached per file)
        NDB-->>NR: FnDepsResult (transitiveCallers as Vec of groups)
        NR-->>FDD: FnDepsResult (transitiveCallers as Record<number,array>)
        FDD-->>CLI: paginateResult(nativeResult)
    else native unavailable (null)
        NR-->>FDD: null
        FDD->>JS: findMatchingNodes + buildNodeDepsResult
        JS->>DB: 20+ individual queries per call
        JS-->>FDD: results
        FDD-->>CLI: paginateResult(jsResult)
    end
Loading

Reviews (2): Last reviewed commit: "fix: add fnDeps 3.9.0 to known regressio..." | Re-trigger Greptile

Comment on lines +1992 to +1996
let hash: Option<String> = conn.prepare_cached(
"SELECT hash FROM metadata WHERE file = ?1"
).ok().and_then(|mut stmt| {
stmt.query_row(params![node.file], |row| row.get(0)).ok()
});
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 Wrong table for file hash lookup

The composite query reads from metadata (SELECT hash FROM metadata WHERE file = ?1), but per-file content hashes are stored in file_hashes. The metadata table holds build key–value pairs (built_at, version, etc.) — hash will never be found there. This causes fileHash to be null for every result in the native path while the JS fallback (and getFileHash() on the same NativeDatabase) correctly returns the real hash from file_hashes.

Suggested change
let hash: Option<String> = conn.prepare_cached(
"SELECT hash FROM metadata WHERE file = ?1"
).ok().and_then(|mut stmt| {
stmt.query_row(params![node.file], |row| row.get(0)).ok()
});
let hash: Option<String> = conn.prepare_cached(
"SELECT hash FROM file_hashes WHERE file = ?1"
).ok().and_then(|mut stmt| {
stmt.query_row(params![node.file], |row| row.get(0)).ok()
});

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 in d991fc7. Changed metadata to file_hashes in the file hash lookup query, so fileHash now correctly returns the real hash from the native path.

Comment on lines +1906 to +1927
let transitive_callers = if depth > 1 {
// Collect initial frontier IDs from direct callers
struct CallerWithId { id: i32, name: String, kind: String, file: String, line: Option<i32> }
// Re-query callers with IDs for BFS frontier
let initial_callers: Vec<CallerWithId> = {
let mut stmt = conn.prepare_cached(
"SELECT n.id, n.name, n.kind, n.file, n.line \
FROM edges e JOIN nodes n ON e.source_id = n.id \
WHERE e.target_id = ?1 AND e.kind = 'calls'"
).map_err(|e| napi::Error::from_reason(format!("fn_deps bfs init prepare: {e}")))?;
let rows = stmt.query_map(params![node.id], |row| {
Ok(CallerWithId {
id: row.get("id")?,
name: row.get("name")?,
kind: row.get("kind")?,
file: row.get("file")?,
line: row.get("line")?,
})
}).map_err(|e| napi::Error::from_reason(format!("fn_deps bfs init: {e}")))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| napi::Error::from_reason(format!("fn_deps bfs init collect: {e}")))?
};
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 Redundant callers re-query to seed BFS frontier

When depth > 1, direct callers are already fetched at lines 1843–1860 (without id). They are then re-fetched here with id to seed the BFS frontier, doubling the caller query for every matched node. Adding id to FnDepsCallerNode (or using a shared inner struct) would let the first result be reused, eliminating one SQL round-trip per node.

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 in d991fc7. Restructured the caller query to include id in the initial FnDepsCallerNode fetch (via a local CallerWithId struct), then reuse those results directly as the BFS frontier. This eliminates the redundant SQL round-trip that was re-querying the same callers just to get their IDs.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 5, 2026

Codegraph Impact Analysis

49 functions changed3 callers affected across 3 files

  • is_test_file in crates/codegraph-core/src/read_queries.rs:32 (1 transitive callers)
  • NativeDatabase.fn_deps in crates/codegraph-core/src/read_queries.rs:1704 (0 transitive callers)
  • Repository in src/db/repository/base.ts:28 (0 transitive callers)
  • Repository.fnDeps in src/db/repository/base.ts:232 (0 transitive callers)
  • FnDepsNode.name in src/db/repository/base.ts:243 (0 transitive callers)
  • FnDepsNode.kind in src/db/repository/base.ts:244 (0 transitive callers)
  • FnDepsNode.file in src/db/repository/base.ts:245 (0 transitive callers)
  • FnDepsNode.line in src/db/repository/base.ts:246 (0 transitive callers)
  • FnDepsCallerNode.viaHierarchy in src/db/repository/base.ts:250 (0 transitive callers)
  • FnDepsEntry.name in src/db/repository/base.ts:254 (0 transitive callers)
  • FnDepsEntry.kind in src/db/repository/base.ts:255 (0 transitive callers)
  • FnDepsEntry.file in src/db/repository/base.ts:256 (0 transitive callers)
  • FnDepsEntry.line in src/db/repository/base.ts:257 (0 transitive callers)
  • FnDepsEntry.endLine in src/db/repository/base.ts:258 (0 transitive callers)
  • FnDepsEntry.role in src/db/repository/base.ts:259 (0 transitive callers)
  • FnDepsEntry.fileHash in src/db/repository/base.ts:260 (0 transitive callers)
  • FnDepsEntry.callees in src/db/repository/base.ts:261 (0 transitive callers)
  • FnDepsEntry.callers in src/db/repository/base.ts:262 (0 transitive callers)
  • FnDepsEntry.transitiveCallers in src/db/repository/base.ts:263 (0 transitive callers)
  • FnDepsResult.name in src/db/repository/base.ts:267 (0 transitive callers)

#870)

Fix P1 bug: file hash lookup queried `metadata` table instead of
`file_hashes`, causing `fileHash` to always be null in native path.

Fix P2: eliminate redundant SQL round-trip in BFS transitive callers by
including `id` in the initial callers query and reusing the result as
BFS frontier, removing the duplicate re-query per matched node.
The fnDeps NAPI boundary crossing regression (9.7ms -> 27ms) is the
root cause this PR fixes. Mark the 3.9.0 fnDeps entries as known
regressions so the guard passes while the fix lands — post-release
benchmarks will confirm the regression is resolved and the entries
can be removed.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

carlos-alm added a commit that referenced this pull request Apr 5, 2026
The fnDeps query latency ~3x regression in 3.9.0 vs 3.7.0 is a
pre-existing main issue caused by openRepo engine routing, not by
this PR. Add to KNOWN_REGRESSIONS to unblock CI while fix is
tracked in PR #869/#870.
@carlos-alm carlos-alm merged commit 69c27b0 into main Apr 6, 2026
18 of 19 checks passed
@carlos-alm carlos-alm deleted the perf/native-fndeps-composite-query branch April 6, 2026 01:36
@github-actions github-actions bot locked and limited conversation to collaborators Apr 6, 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