Skip to content

perf(graph-db): store each code edge as one relation row - #2277

Merged
ScriptedAlchemy merged 1 commit into
masterfrom
fleet/graph-single-row-edges
Sep 27, 2026
Merged

ScriptedAlchemy merged 1 commit into
masterfrom
fleet/graph-single-row-edges

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

Refs #1103. Continues #2257 and #2268, rebased over #2271 (f1f15ed9's sealed-segment row source; code edges are emitted through its windowed spill). This change stores each code edge as one relation row and code-graph records as compact text. The graph store shrinks 33–50% at the same base. Graph-evidence reads drop 20–60%, and every retrieval response is unchanged except one sampled prefix, explained below.

What changed

(a) One row per code edge. Before, a code edge was:

  • an edge:<digest> entity carrying the JSON CanonicalRelationEdgeV1 record, plus
  • two relations, CodeRelationSource.<kind> (symbol → edge) and CodeRelationTarget (edge → symbol), each stored as a locator node and a native edge.

Now it is one relation edge:<digest> from its source symbol to its target symbol:

  • kind: CodeEdge.<kind>, so the store still filters kinds.
  • native edge row: carries the edge record as its payload.
  • locator node: keeps the key, identity, source, and target from perf(graph-db): store relation identities once, on the locator #2268. That makes it the keyed (edge-id) index lookup onto the single row; it is not a materialized entity.

Graph-db relation payloads now live only on the native edge; the locator's second copy is gone.

(b) Compact records. Every code-graph record (symbol, edge, file, import) is stored as compact text:

  • The JSON's structural tokens (field keys, repeated enum values, id prefixes such as "symbol.v1.sha256:) become U+0001 and a code.
  • Every 64-hex digest becomes U+0002 and base64url.
  • Free-form values (names, signatures, docstrings, paths) stay JSON text.
  • serde_json escapes every control character, so neither marker can occur in the input, and decoding restores the exact JSON.

Readers migrated. No reader treats an edge as an entity any more. rg over every crate found edge-as-entity reads only in tracedecay-code-index's graph projection. Each reader now takes the payload from the fan-out row:

reader before after
relation_keys (the #2224 key walk) 2 fan-outs 1 fan-out
semantic_neighbors (callers, callees, context related symbols, edge-kind counts) one entity point read per edge payload from the row
degrees incoming counted CodeRelationTarget code-edge kinds, same totals
edges_among outgoing_relation_targets then an entity decode outgoing_relations
evidence traversal (reader.rs) two-hop symbol/edge-entity alternation plus an entity read per edge a one-hop frontier walk over batched fan-outs with payloads
catalog warm edge entities for dependency edges, relation kinds for degrees one pass over code-edge rows

by_qualified_name, receipts, and dashboard edge views consume these reader APIs; none of them addressed edge entities directly.

Format revisions. Graph-db format 4 → 5 and CODE_GRAPH_PROJECTOR_REVISION v9 → v10, with the same typed FormatSuperseded rebuild (delete, then republish from source; no conversion).

Rebuild race fix. An upgrade-in-place run found a real race in the #2257 rebuild. With a projector bump, the new generation was sealed before the staging container was opened. The superseded-format deletion then removed the whole .sealed root, including that fresh artifact. The first run served from memory, and the next restart had to repair. Sealed receipts now record graph_format, and the rebuild discards only sealed generations that do not name the current format; .staging-* seals in flight are left to their builder. After the fix, the upgraded profile keeps its sealed store (111,109,420 B) across a restart with zero repairs.

Bytes

Per symbol (j768 has 98,305 symbols and 0 edges): 1,687 → 1,130 B of graph store per symbol.

Code-graph manifest of a 3-function, 3-call fixture:

entities relations record bytes
master 8 9 4,259
this branch 5 6 2,049

Per code edge (estimate: graph store minus symbols at the j768 per-symbol cost, divided by edges):

corpus before after
repo clone ~1,413 B ~627 B
starship ~1,147 B ~437 B

Per store per source byte. Base is master 402b14456b and after is this branch at that base (rebased onto eedc714bf5, a contracts-only change, before merge). Both use the perf CLI, a fresh isolated profile, and one capped daemon: 6 GB, or 12 GB with 4 workers for the clone. Source is graph_statistics.source_total_bytes.

corpus (source) graph store before graph store after text / segments profile before → after
j768 (6,796,827 B) 165.9 MB (24.40×) 111.1 MB (16.35×), −33.0% unchanged 317.3 → 262.6 MB
rsbuild (2,110,240 B) 26.4 MB (12.51×) 16.7 MB (7.92×), −36.7% unchanged 69.0 → 59.4 MB
starship (11,543,857 B) 89.4 MB (7.75×) 44.5 MB (3.85×), −50.3% unchanged 160.8 → 115.9 MB
repo clone (94,394,320 B) 1,143.7 MB (12.12×) 584.7 MB (6.19×), −48.9% unchanged 1,800.8 → 1,240.4 MB

Clone peak memory fell from 10.60 GB to 8.60–9.05 GB. Time to ready was 500 s on master and 539 s / 725 s on two runs of this branch under a machine load of 60–72. The graph was ready at about 300 s on master and 285 s on the second branch run, so the slow run reflects load variance, not the graph build.

Identical results

This uses the #2185 fixture (search, context, callers, callees per symbol) against same-base master. Per-request fields and the profile-keyed generation id are normalized. Cost receipts are compared separately, because they are meant to improve.

corpus responses identical
repo clone 80/80
j768 80/80
starship 78/78 (2 symbols have no graph node)
upgrade in place (j768) 80/80
rsbuild 79/80

The one rsbuild difference is context for createRsbuild. Its related_symbols list is a truncated prefix of callers and callees in store order (context_related_relation_budget caps it at 16–64 rows, by design "just a prefix"). Master's prefix order came from two different relation-id orders: callees in source:<sha(edge)> order and callers in target:<sha(edge)> order. With one row per edge, both directions follow edge:<digest> order. No single row order can reproduce two unrelated hash orders, so this sample differs. Both orders are arbitrary and deterministic, and the untruncated neighbor sets are the same. The stale comment on that budget is updated.

Cost receipts (fixture totals for callers and callees). Before → after, as reads / adjacency queries / rows / bytes hydrated:

corpus before after
rsbuild 2,864 / 112 / 2,591 / 2.05 MB 1,143 / 84 / 2,266 / 1.17 MB
starship 461 / 91 / 2,689 / 693 KB 198 / 72 / 1,825 / 588 KB
clone 1,282 / 181 / 4,725 / 2.54 MB 810 / 123 / 3,299 / 1.83 MB
j768 24,106 B hydrated 13,006 B hydrated (compact records)

#2224 receipt. On starship handle callees (depth 1, no dispatch), all 12 pages return the same 112 rows (identical row digest), with 11 graph_sealed point reads per page on both sides. Adjacency drops from 2 queries / 226 rows to 1 query / 113 rows per page, and bytes_hydrated drops by about 15%.

Upgrade in place. A master-built j768 profile was restarted on this binary. It reached ready in 22 s with status fresh, and republished its sealed store (111,109,420 B, byte-identical in size to a fresh index) in that first run. It gave 80/80 identical responses after warm-up (WARM=1). A second restart served that store with no repair. The code-index artifacts are byte-identical.

Tests: fail on master, pass here

To check that these fail on master, master's graph-db and code-index src were checked out over this commit, and the tests below were run against them.

  • graph_manifest_stores_each_code_edge_as_one_row_within_the_byte_budget (code_index_suite): builds a real parser-backed generation. It asserts 5 entities, 6 relations (3 CodeEdge.calls and 3 file-symbol), record bytes ≤ 2,200, and that edges_among returns alpha→beta, alpha→gamma, and beta→gamma. Master: 8 entities, 9 relations, 4,259 bytes (fails).
  • superseded_format_store_is_rebuilt_fresh_keeping_only_current_sealed_generations (graph_db_suite): a format-4 store with a format-4 keyed row, one old sealed generation, and one sealed generation that names the current format. After open, the store is fresh, the old generation is gone, and the current one is kept. Master: opens format 4 as current (fails).
  • single_row_edge_reads_back_its_payload_through_keys_and_fanouts (graph_db_suite): a raw format-5 store where one native edge row carries the payload and the locator carries a literal key and compact identities. relation, outgoing_relations, and outgoing_relation_ids all return the literal relation with its payload. Master: refuses format 5 (fails).
  • persisted_scalar_identity_mismatch_is_corrupt_on_point_read: now format 5 (fails on master).
  • resident_accounting (the perf(code-index): build code-graph rows from sealed segments #2271 test): its row count is re-pinned from (4,201, 5,100) to (3,001, 3,900), because each of the fixture's 1,200 edges is now one relation row. Its peak-memory budget still holds.
  • graph_projection_publication: an import assertion that parsed the raw record JSON is replaced by the existing reader assertion, external_type_import_candidates returning the import.

Checks

On the final rebased tree, graph-db and code-index were rerun after the last rebase. The other suites ran on this diff at 402b14456b. All use --profile perf.

suite passed
graph-db lib 143
graph_db_suite 153
code-index lib 258
code_index_suite 171
resident_accounting 2
code-index-runtime lib 516
query lib 263
search_quality_suite 71
canonical_execution_equivalence 5
retrieval_contract_spine 2
store-runtime lib 121
session-temporal-store lib 150

code-index-runtime failed 4 scheduler timeouts (Elapsed) in one run under a machine load of about 65; an isolated rerun passed 516/516.

cargo clippy -p tracedecay-graph-db -p tracedecay-code-index --all-targets --features tracedecay-graph-db/test-helpers -- -D warnings is clean, and cargo fmt --all -- --check is clean. The tracedecay-mcp change is a doc comment only.

Remaining multiple of source

corpus graph store profile
repo clone 6.19× 13.1×
starship 3.85× —
rsbuild 7.92× —
j768 16.35× —

j768 is 98k one-line functions, so its per-symbol cost dominates. What remains:

  • Per-symbol records (about 1.1 KB per symbol on j768). The symbol occurrence is stored twice (top level and metadata.occurrence), and several digests per record remain even in compact form.
  • Per-entity cost. Each entity keeps its 71-byte ENTITY_ID plus a 63-byte key.
  • Per-edge locator. Each edge still has its locator: a key plus three compact identities, about 200 B.
  • Outside the graph. The text artifact is now the largest store on the clone, at 5.45× source.

A code edge was an edge entity carrying its JSON record plus two relations
(symbol -> edge, edge -> symbol), each with a locator node and a native
edge. It is now one relation from its source symbol to its target symbol,
kind `CodeEdge.<kind>`, whose native edge carries the edge record. Readers
take the payload from the fan-out row: adjacency, key walks, degrees,
edges-among, the evidence traversal, and the catalog warm read no edge
entity and walk one hop instead of two.

Graph-db relation payloads now live only on the native edge. Code-graph
records are stored as compact text: structural JSON tokens and 64-hex
digests become short marked codes; free-form values stay JSON.

A superseded-format rebuild now discards only sealed generations whose
receipt names an older graph format, so a generation sealed before the
staging container is replaced keeps serving.

Graph format 4 -> 5, projector v9 -> v10, same typed rebuild.

Refs #1103
@changeset-bot

changeset-bot Bot commented Sep 27, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 31b2130

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 718d251 into master Sep 27, 2026
1 check passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 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-27T00:23:40.146064Z 31b2130 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.

@ScriptedAlchemy
ScriptedAlchemy deleted the fleet/graph-single-row-edges branch September 27, 2026 00:19

@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: 31b2130c00

ℹ️ 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".

decode_graph_properties(
locator
.properties
edge.properties

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 Validate native-edge owner scalars before decoding payload

When a format-5 store has a semantically corrupted native edge whose namespace, projection, or RELATION_KIND_PROPERTY disagrees with its locator, keyed relation reads now decode and return that edge's payload without checking those owner scalars; only the edge type is compared with the locator kind. Since this commit moves the sole payload copy from the validated locator to the edge, db.relation can silently attribute data to the wrong relation/projection instead of returning GraphDbError::Corrupt; validate the edge owner scalars against the locator before accepting edge.properties.

AGENTS.md reference: AGENTS.md:L189-L190

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.

1 participant