Skip to content

perf(storage,rel): anchor an indexed node lookup by ordinal so (:L {id: $x}) reads the match, not the node topology (#1983) - #2009

Merged
DecisionNerd merged 4 commits into
mainfrom
perf/1983-anchor-without-topology-scan
Oct 12, 2026
Merged

DecisionNerd merged 4 commits into
mainfrom
perf/1983-anchor-without-topology-scan

Conversation

@DecisionNerd

@DecisionNerd DecisionNerd commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1983

What

With a current persisted equality index, (n:L {id: $x}) (and MATCH (n:L) WHERE n.id = $x) no longer decodes the whole node topology to meet the one index result. The index returns the matching UUIDs; the topology identity columns resolve them to node_ids and the fragments that hold them (row-group and page statistics prune everything else); each holding fragment decodes exactly those rows by dense ordinal selection. The label filter and the equality stay in the plan, so the label is checked on the row that was read and the answer is the unanchored scan's answer. EXPLAIN names it: GraphForgeParquetExec: ..., anchor=property_equality(id).

  • graphforge-rel StoredEqualityHints already offers column = literal to the property scan. It now also offers it, as a GraphReadAnchor on the Nodes source, to the node scan that feeds the join's node_uuid key, looking only through column projections and total predicates (so no row that could make a predicate fail is dropped early).
  • graphforge-exec binds an anchored Nodes source to Catalog::anchored_node_table.
  • graphforge-storage NodeAnchor / GraphForgeParquetExec: anchored only while the index is current at plan time and again at execute time; otherwise the scan reads every row exactly as before. A corrupt index is refused (GF_VALIDATION), not bypassed. The anchored scan reports its row count as an upper bound, never exact, and is not a "complete node id scan".
  • Plan shape is unchanged (same partitioning, join mode and tap), so the approval rules and fixed-hop fast paths still see what they matched before. The TCR4 physical plan at SF1 FinBench differs from main only by the two anchor= annotations.

No index, a stale index, an unindexed column or a pending-write union: today's plan and reads, unchanged. Only filtered_parquet.rs gains an additive node-side function; the edge side is untouched.

Tests

New crates/graphforge-api/tests/indexed_node_anchor.rs (6 tests):

  • growth: MATCH (n:Entity {id: $ident}) RETURN n.id AS id, n.name AS name at 12,288 and 24,576 nodes, 1/2/4 partitions, three lookups in different fragments. Random UUIDs, so the identity column cannot compress away; 1 KiB incompressible text per node, so the property route spans fragments. Gates: the plan names anchor= and index=; at one partition exactly one node row is selected by ordinal and none is read whole (io_stats); read bytes grow by at most 32 KiB, measured both as the calling thread's lifecycle read-path bytes (one partition) and as the process's rchar (every thread, so it holds at 2 and 4 partitions, where the plan's exchanges run on worker threads the lifecycle counters do not see);
  • label mismatch returns 0 rows, and the match returns its row, in {} and WHERE forms, labelled and unlabelled;
  • stale index: a write makes the plan drop the anchor, answers are unchanged, the stale statement selects no rows by ordinal; rebuild_property_indexes restores the anchor;
  • multi-label (:A:B), nodes sharing a value, a deleted node (DETACH DELETE then rebuild), and the same answers once the index is stale;
  • an anchored seed feeding an expansion at 1/2/4 partitions;
  • an in-memory project.

stored_equality unit tests cover where the anchor is placed and where it is not (computed projection, failing filter, non-node source, key not the node identity, already anchored, unsupported equality).

Revert proof. With crates/graphforge-rel/src/stored_equality.rs at main's version (nothing else changed; the rest is inert without it), the growth test fails on the plan assertion (no anchor=). With that assertion removed the byte gate fails too: one partition, observed read bytes 2,685,897 (12,288 nodes) to 2,926,069 (24,576 nodes), +240 KB; process rchar 2,719,126 to 3,091,547, +372 KB, the same at 1, 2 and 4 partitions. With the change: 2,714,737 to 2,718,431 observed (+3.7 KB), rchar +2.4 KB. (Run on the pre-#2003 base; the topology path is unchanged by the rebase.)

Verification at the PR head (2a6894317, on main 5ad88dd60)

Command Result
cargo test -p graphforge-rel --lib 265 passed
cargo test -p graphforge-plan --lib 28 passed
cargo test -p graphforge-storage --lib -- topology_identity parquet_scan catalog:: node_anchor property_equality property_scan 90 passed (1845 filtered)
cargo test -p graphforge-api --test indexed_node_anchor --test property_equality_index --test property_read_pushdown --test fixed_hop_limit -- --test-threads=1 6 / 9 / 15 / 20 passed (+2 release-only ignored); includes fixed_hop_limit::fast_paths_survive_multi_file_node_tables
cargo test -p graphforge-api --test bdd 118/118 API scenarios; openCypher TCK 3898 passing of 3898 (0 regressed, 0 xpass)
make check exit 0

All with CARGO_BUILD_JOBS=2 CARGO_PROFILE_DEV_DEBUG=0, through scripts/test_environment.py.

SF1 measurement

Copies (cp -a) of /home/ubuntu/gf-sf1-1982-design/{ic,fb} (indexed), workloads /home/ubuntu/gdc-diag-1914b/inputs/{ic,fb}-workload.json, gdc-probe (from /home/ubuntu/gf-probe-1982-design) built --release against main 5ad88dd60 and against this branch. Warm process, execute_with_params only, before and after back to back. The host was shared and busy (load average 24-36 over these runs), so absolute times are inflated; read the pairs. Raw outputs are on #1983.

Anchor only (MATCH (p:Person {id: $personId}) RETURN p.id, IC; MATCH (a:Account {id: $id1}) RETURN a.id, FB; ms per repetition, first first):

Query Main Branch
IC anchor 2144, 913, 1103, 1239, 1435 434, 92, 123, 135, 310
FB anchor 1126, 398, 333, 314, 266 345, 92, 108, 100, 113
IC anchor, id no node holds 1374, 1120, 1617, 1682, 947 432, 21, 17, 11, 11

Suites, first three bindings, one warm process per query (s per binding; median):

Query Main Branch Rows (both)
IS1 2.89, 0.93, 0.85 (0.93) 0.87, 0.29, 0.26 (0.29) 1, 1, 1
IC2 52.34, 48.33, 44.30 (48.33) 40.61, 41.04, 45.18 (41.04) 20, 20, 20
TCR4 4.83, 2.53, 2.77 (2.77) 5.37, 3.22, 3.58 (3.58) 1, 1, 1

TCR4 reads slower in that one pair. Four further alternating runs at load 25-40 (main / branch, three bindings each: 9.40 3.94 5.67 / 9.20 4.24 5.01; 6.99 3.96 5.50 / 8.40 3.45 3.42; 4.72 3.03 3.38 / 5.17 2.32 3.22; 4.22 2.63 3.21 / 4.19 2.18 2.61) show no consistent sign, and its physical plan differs from main only by the two anchor= annotations, so I read it as host noise, not a regression. On a quieter host (load about 4.5) at the previous main (e3716befd): IS1 6.63 s to 5.53 s, IC2 23.73 s to 22.80 s, TCR4 10.32 s to 10.53 s (medians).

What the anchor removed: the topology scan. With an id no node holds, the query went from 0.9-1.7 s to 11-21 ms warm. For an id that matches, a warm query reads one of the 49 node fragments (968,795 bytes, strace -ff) where the first query of the process admitted all of them (60 MB); the identity statistics prune to that fragment. IC2 and TCR4 stay bound by their enrichment and expansion stages, which this slice does not touch.

Gaps

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@greptile-apps greptile-apps 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: CurateLabs/graphforge/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 40702ddb-e268-4456-a79f-6246419fe232

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

DecisionNerd and others added 4 commits October 12, 2026 11:34
…so (:L {id: $x}) reads the match, not the node topology (#1983)

A current persisted equality index names the UUIDs of the nodes an
equality selects. StoredEqualityHints now offers that equality to the
node-topology scan that feeds the property join's UUID key, and the bound
node provider resolves the index result to node ids through the topology
identity columns and reads exactly those rows by ordinal. The label and the
equality stay in the plan; with no current index the scan reads every row as
before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…acade; document it (#1983)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…und (#1983)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@DecisionNerd
DecisionNerd force-pushed the perf/1983-anchor-without-topology-scan branch from 2a68943 to d2db561 Compare October 12, 2026 17:34
@DecisionNerd
DecisionNerd added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 52b7ce5 Oct 12, 2026
17 checks passed
@DecisionNerd
DecisionNerd deleted the perf/1983-anchor-without-topology-scan branch October 12, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core source code changes documentation Improvements or additions to documentation executor Changes to query executor planner Changes to query planner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(storage,plan): persisted property-equality index so a {prop: $x} node anchor costs O(matches), not O(label rows)

1 participant