Skip to content

perf(code-index): build code-graph rows from sealed segments - #2271

Merged
ScriptedAlchemy merged 2 commits into
masterfrom
fleet/graph-rows-from-segments
Sep 26, 2026
Merged

ScriptedAlchemy merged 2 commits into
masterfrom
fleet/graph-rows-from-segments

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

Refs #2123. Third slice. Code-graph publication now builds its rows from the sealed segments on disk, one window of files at a time. It no longer decodes the whole generation or builds the one-piece manifest. The published graph, its recovered digest, and its sealed container sections are byte-identical to what the old path produced.

This does not close #2123. At 6 GB the daemon now reaches the sealed compact-store build and is OOM-killed there. That build peaks at 5.39 GB on this repository (measured below), not the 1.3 GB estimated on the issue.

Design as built

graph-db: a streaming row source (generation/spill.rs)

  • GraphGenerationRowSpill takes rows in batches, in any order. Each row is validated the way the manifest constructor validates it and stored as its canonical JSON (the exact bytes the recovered digest hashes).
  • Rows are buffered up to 32 MiB, sorted by identity, and written as a run. Only the entity identities stay resident.
  • finish does a k-way merge of the runs into one sorted entity file and one sorted relation file:
    • An identical repeat collapses into one row. A differing repeat, a dangling endpoint, or an endpoint in another projection is refused typed.
    • The merged stream is hashed through the same identity and row frames as the in-memory proof, which yields the recovered digest.
  • SpilledGraphGeneration streams the merged rows back. Its sealed build (push_spilled_rows) pushes entities and then relations in identity order. Each relation endpoint resolves by binary search over the resident identities, the same node ids push_manifest_rows assigns.
  • GraphGenerationRows (a manifest or spilled rows) replaces Option<Arc<GraphGenerationManifest>> on publish_verified and prepare_verified_publication. It also carries the journaled sealed replay and the supplied-rows binding check.
  • The staging lane is used only for memory-backed stores, the kill switch, and quarantine repair. That lane materializes spilled rows.
  • GraphGenerationManifestProvider now hydrates a sealed generation into a spill the registry creates. The registry creates it under the store's *.sealed/.rows-<pid>-<n> directory.
  • A spill removes itself when dropped. A spill left by a killed process is swept at the next eager store open. Spills named for the opening process are kept, because they belong to a live publisher in the same daemon.

code-index: per-file-batch rows (graph_projection/builder.rs, production/graph_inputs.rs, SealedGenerationFileWindowsV1)

  • Pass 1 decodes the segments window by window and keeps only what cross-file resolution reads: symbols, unresolved references, imports, per-file edges, and the document.
    • It then runs the existing resolve_cross_file_references, unresolved_typescript_import_calls, and receiver-site rules.
    • The pass keeps resident only the bound-symbol set, the cross-file edges, and the unresolved calls.
  • Pass 2 re-decodes each window, emits that window's file, import, symbol, and edge rows, pushes them to the spill, and drops the window before reading the next.
    • Snapshot files without a segment and the cross-file edges are emitted last.
    • The generation marker takes the distinct entity count.
  • One emitter (emit_code_graph_rows) serves both the per-window pass and the whole-set hermetic build, so the two row sets are unioned the same way.
  • Deleted: build_published_code_graph_manifest_checked, the generation's graph-manifest memo, and the provider's decoded-generation offers (offer, hydration retention, pressure reclaimer).

store-runtime / scheduler

  • publish_verified_snapshot no longer takes a decoded generation. It classifies against the journal first. Rows are built from the runtime's sealed replay binding only when a publish arm needs them, before the replay-pool lock is taken. A retry or twin publisher of a landed generation recovers the head without reading a segment.
  • Graph prepare publishes the head from the segments before the serving decode, so the two working sets are never resident together. Activation after the decode then recovers the published head.
  • The sealed graph build is admitted like the decode it replaces (admit_sealed_graph_build, charged the measured bytes of the active generation). While the text build holds its reservation, the graph build is refused and parked on the same ResidentMemory park the decode used.
  • The perf(code-index): make the seal the build's one durable point #2186 contract is untouched: segments are still written under temporary names with one syncfs at seal. The sealed graph container still builds under .staging-* and installs by rename.

Measured

Fixture, fails on the old path. Test a_sealed_graph_build_holds_windows_not_the_decoded_generation (resident_accounting, counting allocator, 300-file generation, one worker):

  • Budget: 19,000,000 bytes of peak held above the sealed input.
  • This branch: 17,718,116 bytes.
  • origin/master's equivalent (decode_partitioned_sealed plus build_published_code_graph_manifest_checked, same fixture and width) peaks at 38,827,295 bytes for the same 4,201 entities and 5,100 relations, so it fails the budget.

This repository's sealed generation, in-process (counting allocator, glibc test binary, perf profile):

step live peak VmHWM
whole decode (old path's input) 2.73 GB live, 2.93 GB peak –
row build, resolution pass 0.79 GB 0.98 GB
row build, emission pass 0.67 GB 1.44 GB
sealed compact-store build of the 858,397 entities / 1,415,305 relations – 5.39 GB (3.50 GB before finish)

The last row is the new wall. grafeo's IncrementalCompactStoreBuilder keeps every pushed Value until finish has encoded all columns. Both paths pay it; the old one paid it on top of a resident decode and manifest.

Daemon journeys. Clone of this repository at 3ce6d2d, perf+hotpath CLI, isolated profile, one systemd-run --user --scope -p MemoryMax=… -p MemorySwapMax=1G scope, cgroup RSS+swap every 250 ms (per-second maximum).

run seal text-artifact build graph build serving decode end
master, 6 GB 5.8+ GB (stack capture) 2.6–3.4 GB – 210–236 s, 3.4 → 6.8 GB OOM 241 s (peak 7.48 GB; Hotpath max 4.48 GB)
this PR, 6 GB 6.75–6.80 GB at 12–14 s 2.6–3.5 GB; graph build refused at 33 s 258 s, 3.72 → 7.47 GB in the sealed store build – OOM ~289 s (Hotpath max 4.86 GB)
this PR, 12 GB 5.5 GB 3.7 GB 88–131 s, peak 8.65 GB (overlaps text, admitted) 175 s fresh with graph ready at 248 s (peak 13.65 GB, after ready, in activation, catalog warm and idle retention; Hotpath max 10.78 GB)

For comparison from #2123: master at 12 GB with 4 workers was OOM-killed at 364 s, and #2237 at 12 GB reached fresh at 676 s.

Identical results

The #2185 80-response fixture: search, context, callers, and callees on 20 symbols. Timestamps, request ids, cursors, handles, wall_micros, and the profile-keyed generation id are normalized.

  • rsbuild clone (125d209ef, 3,293 files):
    • Master against this PR, fresh profiles: 80/80 identical.
  • 768-file journey corpus:

Direct digest comparison of the graph store files. The same profile, the same code generation id, rebuilt by this PR from master's journal:

  1. Index with the master binary.
  2. Delete that profile's tracedecay.grafeo, its WAL, and tracedecay.sealed/.
  3. Restart with this PR's binary. It republishes through the repair arm from the sealed segments and binds the digest master journaled.

The container header's first 12,288 bytes carry the wall-clock write time. Everything after them is compared:

corpus master sections sha256 this PR, same profile recovered digest rows
j768 3cfa82adc754e7240279… 3cfa82adc754e7240279… sha256:8d35f215208e4d85… both 99,075 / 98,305
rsbuild 438005fdf983473e022d… 438005fdf983473e022d… sha256:8123d63f73cad052… both 21,517 / 25,076

After the republish, the query set is 80/80 (rsbuild) and 79/80 (j768, the same root context warm race) identical to master's answers.

Seal spike (report only, not bounded here)

  • At 6 GB the seal rises from 5.27 to 6.75 GB within one second (about 16 s daemon time), holds for about 3 s, and returns to 5.25 GB.
  • The Hotpath spans in that window are the seal's cross-file resolution (seal.reference_candidate_lookup 426,850 calls, seal.qualified_path_walk 18.3M) and the first 2,006 sealed_encode.file calls.
  • In-process on the same generation at the production width (36), the live transients are small: sealed encode 0.351 GB, resolution about 0.1 GB, validation 0.19 GB (perf(code-extraction): hold clone token streams compactly #2237).
  • A 1.48 GB RSS rise in one second is therefore allocator growth across the worker heaps, not live data.
  • Bounding it needs a mimalloc-heap measurement (purge delay against thread-local retention) before choosing between a heap collection between the seal phases and narrower encode windows.

Tests

  • New, graph-db (graph_db_suite):
    • spilled_rows_seal_the_same_generation_as_their_manifest: shuffled batches with repeats give the same digest, journaled replay, receipt, and container sections as the manifest.
    • spilled_rows_refuse_conflicting_repeats_and_dangling_endpoints
    • store_open_sweeps_row_spills_abandoned_by_another_process
  • New, code-index:
    • an_interrupted_graph_build_fails_typed_and_the_next_build_completes
    • The fixture budget test above.
  • Ported: the code-index graph tests now build from PartitionedSealV1 segments, including the serial/parallel width test that forces different window sizes. The store-runtime provider and publication tests now use spills.
  • Deleted with their machinery: the graph-manifest memo test and the decoded-offer tests.
  • SIGKILL durability (daemon_suite::sealed_generation_crash_test, run against the built CLI): sigkill_after_the_seal_restarts_on_the_identical_sealed_generation and sigkill_during_the_seal_keeps_the_prior_generation_published, 2 passed.

Suites

Run on this branch rebased onto bf0ddaf. The journeys above ran on the pre-rebase tip.

  • tracedecay-code-index: lib 258, code_index_suite 169, resident_accounting 2
  • tracedecay-code-index-runtime: lib 516 (2 ignored)
  • tracedecay-graph-db: lib 143 (1 ignored), graph_db_suite 153 (2 ignored)
  • tracedecay-query: lib 263 (1 ignored), search_quality_suite 71, canonical_execution_equivalence 5, retrieval_contract_spine 2
  • tracedecay-store-runtime: lib 121 (1 ignored)
  • tracedecay lib tests (ported daemon activation and store-runtime tests) compile.

cargo clippy -p tracedecay-graph-db -p tracedecay-code-index -p tracedecay-code-index-runtime -p tracedecay-store-runtime --all-targets --features tracedecay-graph-db/test-helpers,tracedecay-code-index/test-helpers -- -D warnings and cargo fmt --all -- --check are clean.

@changeset-bot

changeset-bot Bot commented Sep 26, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1193449

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ScriptedAlchemy
ScriptedAlchemy merged commit beefa81 into master Sep 26, 2026
1 check passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-26T21:59:19.888945Z 1193449 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1193449c0c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +517 to +520
if entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(ROW_SPILL_PREFIX) && !name.starts_with(&own))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish stale spills from a reused process ID

When a daemon is restarted with a PID previously used by a process that was killed during a spill, this filter treats the abandoned .rows-<pid>-* directories as belonging to the new process and never removes them. Since NEXT_SPILL also restarts at zero, the next graph publication can collide with .rows-<pid>-0 and fail at directory creation, while potentially large stale spills remain on disk; use an instance-unique owner token or otherwise prove liveness instead of PID alone.

AGENTS.md reference: AGENTS.md:L41-L46

Useful? React with 👍 / 👎.

Comment on lines +556 to +559
let entities = self
.entities()?
.map(|row| row.map(|(value, _)| value))
.collect::<Result<Vec<_>, _>>()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check cancellation while materializing spilled rows

When direct sealing is unavailable—such as with TRACEDECAY_GRAPH_SEALED_STORE=off, or during a staging repair—this collection can deserialize the entire corpus-sized spill without invoking check; cancellation or deadline expiry is only observed later by new_checked, after all entities and relations have been loaded. For multi-GB graphs that defeats cancellation and can exhaust memory before returning the typed interruption, so the spill should be read in checked windows or probe between rows.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

daemon: initial index of this repository is OOM-killed under a 6 GB memory cap

1 participant