Skip to content

Indexing

Terminal window
kivgraph index --full

A pass analyses every registered repository, merges the facts, validates the canonical graph and publishes it as a new generation. Publication is atomic: a candidate that fails integrity or validation never becomes CURRENT, and the generation already being served is untouched.

Analysis is concurrent, and the unit differs per language:

LanguageUnitBudget
GoModulego.maximum_loads
TypeScriptPackagetypescript.maximum_workers
RustCargo workspacerust.maximum_workspaces

The budgets are separate because the costs are different. A Go load holds a complete type universe; a TypeScript worker is a process; a rust-analyzer invocation holds a whole Cargo workspace and its sysroot. Each queue is drained heaviest-first, with no more workers than it has units, and the first failure cancels the rest.

A pass never runs inside a process that answers queries. It holds the type universe of every Go module, every TypeScript worker and every SCIP index at once, and a Go heap that has grown to that peak keeps the arena for as long as the process lives. Measured on a 41-repository, 102,385-symbol corpus, a server that indexed in its own process parked at 1.68 GB of resident memory against a live heap that stayed flat at 173 MB — a memory measurement on that corpus, not one of the token figures the benchmark reports.

So when a server indexes — because a client called index_project, or because HEAD moved in a registered repository — it runs index --full --json as a child process and reads the result. The peak dies with the child, and the server pays only for the snapshot it then loads.

That flag is public. With --json, stdout carries only newline-delimited JSON events — any number of progress, then exactly one result — and the report a person reads is not written at all:

Terminal window
kivgraph index --full --json
{"event":"progress","progress":{"phase":"go","repository":"data-service","completed":3,"total":41}}
{"event":"result","result":{"passed":true,"generation_id":"000054","counts":{"symbols":102385},"index":{"go_definitions":41230}}}

The corpus this was captured on is private, so the repository name in that event is substituted. The phase, the counts and the generation are the captured ones.

A reader ignores an event kind it does not know, so a new one is not a breaking change. Without the flag, nothing changes: the report goes to stdout and progress to stderr.

A generation carries its snapshot as a file, and a server maps it read-only instead of deriving the graph. So the largest part of it — the string arena — is one copy in physical memory however many servers read the same generation.

Measured on Linux with benchmarks/shared-snapshot, one generation of a 51-repository corpus — 161,819 symbols in a 129 MB file — against the same binary made to derive the graph instead, Pss summed over every server:

serversmapping the filederiving the graphshare
2326 MB654 MB50%
4514 MB1,234 MB42%
8888 MB2,385 MB37%

The share falls as servers are added, which is the whole point: a mapped page is paid for once by the machine however many processes hold it, so each new server adds only what it decodes for itself — 614 B per symbol, flat across all three counts. At eight servers the machine keeps 1.5 GB it would otherwise spend.

The other half is startup. A server that maps the file answers its first query in 261 ms; one that derives the graph takes 3,394 ms, and that ratio does not improve with more servers because each one starts alone.

On Linux, Pss and Shared_Clean in /proc/<pid>/smaps_rollup split shared from private directly. macOS reports a footprint per process and no such split, so there the numbers come from footprint, which separates dirty from clean and names the mapped region — a different quantity, not comparable to the table above. Measured that way with two servers on 123,531 symbols: 94 MB of clean mapped file in one shared copy, 44.5 MB dirty per process.

If the file is absent, foreign, stale or corrupt, the server derives the graph from the canonical store exactly as it always did, says so, and answers. It is an economy, never a precondition. kivgraph doctor tells the two apart on the snapshot.published line.

The merge follows the order of the units, never the order in which they finished. Two passes over the same corpus produce byte-identical facts, whatever the scheduler did.

indexing.fact_cache decides whether an analysis unit may be served from what a previous pass stored for it:

  • off — analyse everything.
  • on — serve an entry whose recorded inputs all still match.
  • verify — analyse everything and fail the pass when a servable entry disagrees with the analysis.

An entry records the whole list of what the unit read and the fingerprint of each item; serving it revalidates that list in full. An entry is never served to a different analyzer: its identity includes the content of the executable, the answer of go env, the content of the TypeScript worker, the build tags, include_tests and go.allow_network.

A module the loader could not read is never cached, because its failure depends on the module cache and no fingerprint of the code describes it.

Comparing two cached passes proves nothing. When touching this area, run with fact_cache: verify.

A pass never writes inside the code it indexes, and it does not reach the network. There are exactly two declared escapes:

  • go.allow_network — lets the go command reach a module proxy while loading. A multi-repository workspace resolves one shared build list, so its selection can need a version no member downloaded on its own.
  • rust.allow_network — lets cargo reach a registry while the analyzer loads a workspace.

Without them, a module or crate the local cache does not hold is reported, not fetched.

The tags Go is loaded with come from go.build_tags. A directory whose files that configuration excludes is not an index failure: it is declared UNRESOLVED with reason PACKAGE_NOT_BUILDABLE and the pass continues. Any other loader diagnostic still aborts it.

Indexing the Kivgraph repository itself requires the ladybug tag.

A Go module the loader cannot read publishes no facts — they would not be trustworthy — and is declared MODULE_NOT_LOADED with the diagnostics observed. One repository whose dependencies nobody downloaded does not decide whether the others have a graph.

A package name declared by several manifests is an ambiguity, not a broken repository: nobody provides it, every manifest leaves the registry, and it is declared AMBIGUOUS_PACKAGE_PROVIDER. Go modules and Rust crates get the same treatment.

index_project registers one or more projects and rebuilds once. Pass every project in a single call: a rebuild resolves cross-repository edges over the complete set of facts, so it costs the whole corpus whatever is added. Calling it once per project pays that cost once per project and keeps only the last graph.