Skip to content

group build: rebuilding LanguageRegistry per-repo in cross_service.rs dominates remote-Neo4j reindex time (~60% of link step) #34

Description

@murari316

Summary

Reproduced locally: infigraph group build --full against 10 microservice fixture repos (25 files total) with a real Neo4j 5 + Postgres/pgvector backend (via podman, both on localhost — so this understates the real-world cost, since localhost hides network latency entirely).

Total: ~38-40s. Step-by-step breakdown of the 5 steps in GroupAction::Build (crates/infigraph-cli/src/group_commands.rs):

Step Time Share
1. Index (parallel, 10 repos) ~4s 10%
2. Sync contracts ~9s 23%
3. Link cross-service calls ~25s 65%
4. Combined graph (skipped — remote Neo4j already namespaced) ~0s
5. Docs + embeddings ~0.2s <1%

Root cause (measured, not inferred — see methodology below)

Step 3 (link_cross_service_calls, crates/infigraph-core/src/multi/cross_service.rs) calls detect_cross_service_deps first, which contains a loop over every repo in the group (for repo_name in &group.repos), and link_cross_service_calls itself has a second loop over every distinct caller service. Both loops call build_registry() (in practice, infigraph_languages::bundled_registry()) once per iteration — once per repo/caller-service, not once for the whole group build run.

bundled_registry() compiles and registers all 62 tree-sitter language packs (parsing each language's entity/relation query files against its grammar) on every call — genuinely CPU-bound work, independent of I/O or network. With instrumentation added around the three sub-phases (build_registry(), Infigraph::open(), .init()) and rerun against the same podman repro:

[perf] detect_cross_service_deps first-loop: build_registry=8517ms, open=0ms, init=160ms, query=111ms
[perf] per-caller: build_registry=6896ms, open=0ms, init=147ms, edge-write=252ms, wall=7.3s

build_registry() alone accounts for ~15.4s of Step 3's ~25s (~60%). By contrast:

  • Infigraph::open(): negligible (0ms measured).
  • .init() (Neo4j connect + init_schema's 8 CREATE CONSTRAINT/INDEX IF NOT EXISTS statements): 147-160ms total across 10 calls — cheap, because the Bolt connection is cached process-wide via NEO4J_CONN: OnceLock (crates/infigraph-core/src/graph/neo4j_backend.rs) and Neo4j's IF NOT EXISTS no-ops are fast.
  • Actual Neo4j data queries (MATCH/MERGE/CREATE for the cross-service edges themselves): 111-252ms total — also cheap.

A third, even more severe instance of the same pattern exists at the "shared-contract detection" loop further down in the same file (for repo_name in &group.repos { ... for (file, symbol_hint, spec_path) in spec_hits { let lang_registry = build_registry()?; ... } }) — this one rebuilds the registry once per spec-fetch match found, not even once per repo. It didn't show up prominently in this measurement only because the fixture repos have no /openapi.json//swagger.json fetches to match; at real scale (repos that do fetch OpenAPI specs) this would compound further.

Why this reads as a "remote Neo4j" problem specifically

On local embedded Kuzu, each repo's Infigraph::open() genuinely needs its own DB handle (each repo is its own on-disk Kuzu database) — so per-repo overhead is expected and was already the design. The registry-rebuild cost is masked there because opening 10 separate small embedded Kuzu DBs is itself not free, so the extra language-registry compile time is proportionally less noticeable. On remote Neo4j, all repos share one namespaced graph — there is no structural reason to reopen a connection or, especially, rebuild the entire language registry, per repo. The registry cost is backend-agnostic CPU work that was always being paid redundantly; remote mode just has less of everything else to hide it behind.

Fix path investigated, correctness constraint found

The natural fix — hoist build_registry() out of the loops so it runs once per group build invocation — is blocked by a type constraint, not a logic issue: LanguageRegistry does not implement Clone, because it stores ParserBackend::Custom(Box<dyn CustomExtractor>) (used by the ANTLR-based grammar-plugin system for FormCalc/ConverterCalc/Interview-style DSLs), and Box<dyn CustomExtractor> isn't Clone-able without the trait itself supporting object-safe cloning (e.g. a dyn_clone-style pattern).

Infigraph::open() takes LanguageRegistry by value (moved into the struct), and has ~19 call sites across the workspace (crates/infigraph-cli/*, crates/infigraph-mcp/*, crates/infigraph-core/*), so changing its signature to accept a reference or Arc<LanguageRegistry> is a real, if mostly mechanical, cross-cutting change — not something to do speculatively inside a narrower perf fix.

Proposed fix (scoped, not yet implemented)

  1. Change Infigraph::open() to accept Arc<LanguageRegistry> instead of an owned LanguageRegistry (small, contained signature change — no Clone/CustomExtractor changes needed, since Arc doesn't require the inner type to be Clone).
  2. Update the ~19 call sites to wrap their existing registry construction in Arc::new(...) — mechanical, since none of them currently need to mutate the registry after construction.
  3. In cross_service.rs, build the registry once (wrapped in Arc) at the top of detect_cross_service_deps and link_cross_service_calls, and clone the Arc (cheap refcount bump, not a deep clone) for each loop iteration's Infigraph::open() call.
  4. Apply the same fix to the third loop (shared-contract/spec-fetch detection) further down in cross_service.rs.
  5. Regression test: time link_cross_service_calls against a synthetic group with N>1 repos before/after, assert the registry-build cost doesn't scale linearly with repo count post-fix (e.g. via a counter/mock on build_registry in a test harness, or a coarse wall-clock assertion).

Methodology note (for whoever picks this up)

The first pass at this investigation incorrectly attributed the cost to network round-trip count (assumed the per-edge MERGE/MATCH/CREATE loop and init_schema's 8 DDL statements were the bottleneck). That was wrong — measured edge-write time was only 250ms total, and init() including init_schema was 147-160ms total. Always add real timing instrumentation around each candidate sub-phase and rerun against a live repro rather than attributing cost from reading code alone; the actual bottleneck (CPU-bound registry compilation, not network I/O) was the opposite of the initial hypothesis.

Repro setup (for reference)

  • podman machine start, then Neo4j 5 (NEO4J_AUTH=neo4j/testpass) and pgvector/pgvector:pg16 (with an infigraph/infigraph user+db created to match PostgresMetaStore::connect_from_env's default DATABASE_URL).
  • Built with cargo build --release -p infigraph-cli --features remote.
  • INFIGRAPH_BACKEND=neo4j, NEO4J_URI=bolt://localhost:7687, NEO4J_USER=neo4j, NEO4J_PASSWORD=testpass, DATABASE_URL="host=localhost user=infigraph password=infigraph dbname=infigraph".
  • infigraph group create perf-test, infigraph group add perf-test tests/fixtures/microservices/<service> for the 10 non-elixir fixture services, then infigraph group build perf-test --full.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions