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)
- 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).
- 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.
- 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.
- Apply the same fix to the third loop (shared-contract/spec-fetch detection) further down in
cross_service.rs.
- 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.
Summary
Reproduced locally:
infigraph group build --fullagainst 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):Root cause (measured, not inferred — see methodology below)
Step 3 (
link_cross_service_calls,crates/infigraph-core/src/multi/cross_service.rs) callsdetect_cross_service_depsfirst, which contains a loop over every repo in the group (for repo_name in &group.repos), andlink_cross_service_callsitself has a second loop over every distinct caller service. Both loops callbuild_registry()(in practice,infigraph_languages::bundled_registry()) once per iteration — once per repo/caller-service, not once for the wholegroup buildrun.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: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 8CREATE CONSTRAINT/INDEX IF NOT EXISTSstatements): 147-160ms total across 10 calls — cheap, because the Bolt connection is cached process-wide viaNEO4J_CONN: OnceLock(crates/infigraph-core/src/graph/neo4j_backend.rs) and Neo4j'sIF NOT EXISTSno-ops are fast.MATCH/MERGE/CREATEfor 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.jsonfetches 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 pergroup buildinvocation — is blocked by a type constraint, not a logic issue:LanguageRegistrydoes not implementClone, because it storesParserBackend::Custom(Box<dyn CustomExtractor>)(used by the ANTLR-based grammar-plugin system for FormCalc/ConverterCalc/Interview-style DSLs), andBox<dyn CustomExtractor>isn'tClone-able without the trait itself supporting object-safe cloning (e.g. adyn_clone-style pattern).Infigraph::open()takesLanguageRegistryby 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 orArc<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)
Infigraph::open()to acceptArc<LanguageRegistry>instead of an ownedLanguageRegistry(small, contained signature change — noClone/CustomExtractorchanges needed, sinceArcdoesn't require the inner type to beClone).Arc::new(...)— mechanical, since none of them currently need to mutate the registry after construction.cross_service.rs, build the registry once (wrapped inArc) at the top ofdetect_cross_service_depsandlink_cross_service_calls, and clone theArc(cheap refcount bump, not a deep clone) for each loop iteration'sInfigraph::open()call.cross_service.rs.link_cross_service_callsagainst 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 onbuild_registryin 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/CREATEloop andinit_schema's 8 DDL statements were the bottleneck). That was wrong — measured edge-write time was only 250ms total, andinit()includinginit_schemawas 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) andpgvector/pgvector:pg16(with aninfigraph/infigraphuser+db created to matchPostgresMetaStore::connect_from_env's defaultDATABASE_URL).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, theninfigraph group build perf-test --full.