Repository navigation
code-index: a one-line edit re-encodes the whole lexical text artifact #2906
Description
Symptom
On a full clone of this repository (6,735 tracked files, 5,877 indexed), a one-line comment edit takes 187 s to reach fresh even with no memory pressure. The graph refresh is already incremental. What isn't is the text projection: it rebuilds the generation's whole lexical artifact from the full sealed source. This is the main cost behind #2880.
Evidence
origin/master aec6e07, --profile perf --features production,hotpath, one isolated daemon under systemd-run --user --scope -p MemoryMax=6G -p MemorySwapMax=1G, isolated HOME/TRACEDECAY_DATA_DIR. Cold init reached fresh in 186 s. After a restart (fresh in 5.4 s), the edit was echo '// probe line' >> crates/tracedecay-domain/src/lib.rs && tracedecay sync.
Status, polled once per second:
+2ms ["refreshing","ready",5877,7773]
+24954ms ["verifying",null,null,null] <- generation sealed
+27563ms ["indexing","source_scan",0,0]
+129954ms ["indexing","index_build",5877,7773]
+173007ms ["indexing","verification",5877,7773]
+187230ms ["fresh","ready",5877,7773]
Hotpath deltas for that one edit:
| counter | value |
|---|---|
code_index.graph.layered.files_reextracted / files_reused |
1 / 5,876 (graph is incremental) |
daemon.code_index.capture.captured_files |
1 |
code_index.restore.segment_decode calls |
32,841 (every sealed segment) |
code_index.restore.segment_bytes_total |
714,231,004 |
code_index.restore.identity_restored_bytes_total |
6,121,811,495 |
code_index_pages (text artifact pages written) |
7,773 (all of them) |
query.artifact.batch.scheduler_wake (wall) |
158 s |
query.artifact.index.build / finalization.advance_wake |
42.6 s / 52.2 s |
code_graph.catalog.build (background warm, 882 pages) |
75.6 s |
daemon.code_index.generation.decode (parent, peak growth 3.3 GB) |
14.2 s |
serving.rs::open_published_head_or_begin_build always calls CodeLexicalArtifactBuilderV1::create_with_memory_budget for the new generation and streams every page of VerifiedSealedLexicalPageSource. No parent artifact is reused, although schema.rs (revision 30) already describes "a byte copy of a parent artifact" whose rewritten blocks go through encode_row_blocks.
Expected
The roadmap's incremental-indexing rule (00-plan-set-index.md): "only changed chunks and evidenced dependency closures reach lexical projection". A one-file edit should write the changed file's pages plus the posting deltas they imply, so time to fresh stays proportional to the change, not the corpus. The same applies to the interactive graph catalog, which also rescans all 882 pages, and to the whole-parent decode the incremental seal needs, whose 2.4–3.3 GB is what the 6 GiB cap refuses (see the livelock issue filed with this one).
Activity
ScriptedAlchemy commentedon Oct 2, 2026
Measurement and design notes from the incremental-text-2906 lane. The first slice landed in #2943, merge dcbf5e8: the text build now reads each sealed segment once. On a cold build that took segment decodes from 26,632 to 5,881, identity-restored bytes from 5.1 GB to 1.0 GB, and the text source scan from 99.5 s to 73.2 s. The edit-path findings below are still open.
What decodes the whole parent on a one-line edit: the incremental build itself, not text projection, catalog or serving.
reconcile_now_with_capture→reserve_worker_memory→load_active_shared, thenCodeIndexProductionOwnerV1::build_and_publish→materialize_increment. The child generation is an in-memory value built byArc-sharing every parent file:staged_generation/aggregate_from_parentbuild whole-corpus chunk and symbol rosters.edge_evidence_over_parentconcatenates every file's edges and scans the parent's edges and unresolved calls.PersistedCrossFileEdgesV1, lineage) names endpoints by position in the whole-corpus symbol roster. Even decoding the evidence needs every file's symbols.Size of that decode on a full clone at 730026c: 5,881 files, 2.53 GB retained, 3.5–3.9 GB peak growth.
No single field dominates, so field trimming cannot make the decode proportional.
Why the 6 GiB edit stalls:
decoded_generationresident, and the edit was refused.nominal: "needs 3,918,462,976 resident bytes; 3,866,012,057 are available below the 5,798,205,849-byte admission watermark". With nothing left to release, that park never clears. Master's 852 s run passed only because its earlier decode measured 3.53 GB.What a proportional build needs (each item is a format or authority change):
materialize_incrementarc-share path is deleted. Large changes take the cold build.perf(code-index)!: seal cross-file evidence per file (#2951)
ScriptedAlchemy commentedon Oct 2, 2026
State of the sparse-build lane after step 1. Step 1 has landed. Steps 2 and 3 are not started, and the reasons are below.
Step 1 landed: #2951, merge 455d41d
Each file now has its own content-addressed sealed evidence segment: its name-resolved edges, its unbound call sites, and its symbols' lineage. Rows name the file's own symbols and references by their content-fixed positions, and edge targets by target path plus symbol identity. The whole-corpus roster is gone from the format, so one file's evidence decodes against that file and the snapshot alone, and a carried file keeps its segment bytes. The format moved to revision 19 with a typed rebuild; a revision-18 profile logged
sealed generation format revision 18 predates this build; the generation will be rebuilt from sourceand rebuilt cold once. A differential on the full clone compared a cold build on master with a cold build on the branch: 41 callers/callees/file_dependents/find_exact answers, 0 different.Workload: full clone at dcbf5e8,
--profile perf --features production,hotpath, 6 GiB scope, restart, then a one-line edit. Two runs on each side:currentsegment_decodecalls / text pages writtenbuild.and_publish(of whichsealed_encode.evidence)The baseline's refusal didn't reproduce in either master run here. It did reproduce once on the branch, because the build still decodes the whole parent.
Why steps 2 and 3 stop here
Step 2 cannot land on its own. The in-memory build holds every parent file, so nothing in production would read persisted name→definition, name→reference, or module/import rows before the sparse build exists. A standalone step 2 would be a write-only authority, which AGENTS.md rules out. It has to land with step 3, which is its only reader.
Step 3 is a cross-crate cutover. Each item below decodes or rebuilds the whole parent today. The sparse build has to replace all of them in one slice before
materialize_incrementcan be deleted:Admission decodes the parent first.
reconcile.rs::reserve_worker_memorycallsload_active_shared, which is a full decode (9–13 s, 3.2–3.5 GB peak), before the build starts.git_tree_capture.rs(650, 748),ignored_dependencies.rs:206, andregistry/mount.rs:2328also load the decoded generation. Publication would have to hand them a sealed-only child, or each one would decode the child right after it publishes.Publication takes an in-memory generation.
build_and_publish→publish_atomically(Arc<CodeIndexPublishedGenerationV1>)→encode_partitioned_sealed_with_parent. A sealed increment needs its own publish that writes the child manifest from parent descriptors plus the changed segments.Projection request and receipt rows are still keyed by whole-corpus chunk-roster positions (
projection_rows.rs,PersistedProjectionRequestRefV1::new(request, &chunk_roster)). That needs another per-file or by-id format change.Code graph pages.
for_each_sealed_code_graph_pagerebuilds and digests all 5,882 pages on every edit, plus a fullemit_persisted_code_graph_pageper page forpage_build_footprint. This is ~32 s of the 37 s build. A sparse plan has to carry unaffected page descriptors and rewrite only:to_occurrenceandtarget_fileschange, because the occurrence ids embed the content digest.That last group needs a target→source reverse index, part of step 2.
Resolution for moved sites needs the whole corpus.
ChangedSitesV1::newscans every file's references and runsforward_through_importsover every file's imports.resolve_selected_cross_file_referencesbuildsby_simple_name,RustFileIndexV1,TypeScriptModuleIndexV1, andModuleImportIndexV1over all files.These are the step 2 indexes. The resolvers in
helpers.rs,module_resolution.rs, andtypescript_resolution.rsreadfiles[candidate_index]directly, so they would need an index-backed file view.Statistics and coverage are recomputed from every file (
CodeIndexGenerationStatisticsV1::from_generation_parts,coverage_summary). They need to become reversible per-file aggregates: parent, minus replaced files, plus new files.Lineage. Files edited in the parent generation carry non-trivial lineage rows. In the child they become all-unchanged, so their evidence segments have to be decoded and rewritten. That set is bounded by the previous edit, but it needs each file's symbol count. Today the count comes only from the decoded segment, not from the descriptor.
The
currenttime is dominated by the text rebuild. The text artifact still rewrites all 7,735 pages and the catalog rescans every page (scheduler_wake138 s,catalog.build77 s per edit). "Current/ready in a small fraction of cold time" cannot be met until the segmented lexical artifact and incremental catalog (items 4 of the design comment) land too, even with items 1–7 done.Lane stopped here; no daemon, worktree, or scratch data left behind. Filed #2952, a timing race in
a_read_past_its_budget_answers_the_measured_remaining_rewarmthat I hit while testing.perf(code-index)!: seal one-file edits over the sealed parent (#2985)
ScriptedAlchemy commentedon Oct 2, 2026
State after #2985 (merged as 88e5b13): steps 2 and 3 are landed. The incremental lexical artifact and the incremental catalog are not started, so this issue stays open.
Landed in #2985. A one-file edit seals its successor over the sealed parent and never decodes it.
Measured on this repo at 455d41d (5885 files, 7740 pages, perf+hotpath CLI,
MemoryMax=6G):tracedecay-domain/src/lib.rs) → currentcode_index.reconcile.passOn 60 sampled queries (
find_exact_symbol,callers,callees,file_dependents), the sparse-built generation answers identically to a cold build of the same tree.Where edit → current goes now. These costs are not part of step 2 or 3:
Lexical text artifact, about 150–200 s per edit (next PR). Every edit rebuilds the whole artifact:
query.artifact.batch.scheduler_wakeis about 220 s per edit;code_index.restore.file_admitadmits all 5885 files andrestore.segment_decodemakes 5885+ calls per edit;This stage also drives RSS to 5.5–5.98 GB (
mem=over_budget) under the 6 GiB cap. So even with the code index fixed, a one-line edit still runs close to refusal.Per fix(memory): retain cgroup pressure after allocator trim #2851, fix(code-index): resume lexical text from the parent artifact #2437 was withdrawn because the parent-carry delta (
artifact/delta.rs) sealed incomplete source receipts under the current revision. The plan for the next PR:Catalog, about 45 s per
code_graph.catalog.buildcall; 2–3 calls per edit. This is the incremental catalog (reversible aggregates).Smaller items seen while measuring:
code_index.graph.build_layered_rowstakes about 12.9 s per edit, butemitandfinishtake under 1.6 s of that. The rest is open, plan, or admission.code_index.sealed_encode.graph_pages, newly measured) runs serially.code_index.sparse.index_bytes_decoded). A path→dependents index would remove most of that read.The lexical artifact and catalog follow-ups are not started; they are the remaining work on this issue.
ScriptedAlchemy commentedon Oct 3, 2026
State after #3004 (merged as 1386c99): PR B, the incremental lexical artifact, is landed. PR C (incremental catalog) and the smaller items are not started, so this issue stays open.
Landed in #3004. An edit's text artifact is built from the nearest published ancestor's artifact:
Format revision 31 makes every stored page value position-free. Revision 30 takes the typed rebuild. More than an eighth of the files changed, a different file roster, or no published ancestor builds cold.
Measured on this repo at 88e5b13 (5,891 files, perf+hotpath CLI, one daemon under
MemoryMax=6G, the sameecho '// probe line' >> crates/tracedecay-domain/src/lib.rs && tracedecay syncworkload):code_index.restore.file_admit/segment_decodecallsquery.artifact.batch.scheduler_wakeAfter the edit, I cold-built the same tree in a fresh profile with the same binary:
482f3834…, 461,713,408 bytes);search, 10 phrasesearch, 10grep) returned identical results, 367 rows.Where the 48 s goes now:
code_index.graph.build_layered_rows). This is the "12.9 s where <1.6 s is real work" item.current: the catalog, 46 s over 2code_graph.catalog.buildcalls. This is PR C.Remaining work on this issue:
devin-ai-integration commentedon Oct 4, 2026
Devin is picking up the remaining roster-change gap here: a checkout that adds or removes files still builds the lexical text artifact cold because
changed_files_sincereturnsNoneon any roster difference. Plan: align parent/successor rosters by path and let the parent-artifact carry delete removed files' pages and stage added files' pages at their cold positions, with a byte-identical-to-cold regression test.Written by Devin
fix(lexical): carry text artifact across added and removed files