Skip to content

fix(planner): bind each relationship once per match - #82

Merged
prrao87 merged 1 commit into
mainfrom
fix/relationship-uniqueness
Oct 28, 2026
Merged

prrao87 merged 1 commit into
mainfrom
fix/relationship-uniqueness

Conversation

@prrao87

@prrao87 prrao87 commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Why

openCypher binds each relationship at most once within one MATCH. Here two single-hop relationships of the same type could bind the same edge, so MATCH (a)-[r1:KNOWS]->(b)<-[r2:KNOWS]-(c) also returned rows with c = a where r1 and r2 are one edge. Named paths already enforced this through the path operator; ordinary patterns did not.

Fixes #59.

What changed

  • After planning a MATCH, the planner adds one filter for each pair of single-hop relationships whose types overlap, requiring the two edges to differ. An edge is identified by its endpoint node ids, so this works on the join and CSR paths alike and adds no new columns.
  • The check covers named and anonymous relationships, one pattern or comma-separated parts, and undirected hops (compared in either orientation). Anonymous endpoint nodes get hidden names so they can be compared.
  • Pairs are skipped when they share a relationship variable (one edge by definition; see Shared relationship variable across pattern parts falls back to a cross join #58), when both sit in one named path (the path operator already checks those exactly, by edge row), or when their endpoints have different labels.
  • Separate MATCH clauses may still bind the same edge, as openCypher allows.
  • The Cypher guide documents the rule and its limits.
Query on edges A->B, C->B Before After
MATCH (a)-[:KNOWS]->(b)<-[:KNOWS]-(c) RETURN a, c A-A, A-C, C-A, C-C A-C, C-A

Known limits, marked as a shortcut: in the code: variable-length and untyped relationships are not checked outside named paths, and two parallel edges between the same pair of nodes count as one edge. The second matches the existing undirected backtracking rule; lifting both needs edge row ids carried through the hops.

Behaviour change: queries with two same-type hops can return fewer rows. One existing integration test expected the old answer: COUNT(DISTINCT shared.id) for people known by two different people is now 1 (only Charlie), which matches that test's own comment.

Validation

  • cargo test --workspace: all pass, including a new planner test for the filter and the pairs it skips.
  • cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all --check: clean.
  • make test in python/: 1602 passed, including a new test of six query shapes with use_csr off and on. It ran before two test-only edits (a reformatted test line and the updated Rust expectation); the engine did not change after it.
  • make lint, zensical build --strict and typos: clean.

Benchmarks

Both suites, built with the harness in CONTRIBUTING.md: release wheels of main (3324055) and of #83's head (9dae1c8, which contains this change), loaded with uv run --with, alternating main and branch, two runs each. No benchmark query reuses a relationship variable, so #83's code isn't reached and these numbers measure this change. Every run passed every expected-answer check (30/30 and 14/14).

Suite Mode Branch / main (geometric mean) Gap between main runs (geometric mean, largest)
Custom (30 queries) memory 1.008 1.007, 6.3%
Custom (30 queries) lazy 0.999 1.026, 36.1%
Interactive complex (IC1–IC14) memory 1.021 0.991, 13.3%
Interactive complex (IC1–IC14) lazy 0.972 0.898, 24.6%

Overall there's no slowdown. Two queries are about 10% slower in both branch runs, minimum latencies included, so it's a real cost rather than noise, though it sits inside the 15–20% noise band: IC14 in both modes, and IC8 in memory (its lazy minimums are unchanged).

  • IC8 (about 2.0 ms to 2.2 ms in memory): its postHasCreator|commentHasCreator alternation expands into a branch with two commentHasCreator hops, which now gets the uniqueness filter.
  • IC14 (about 45 ms to 50 ms): two of its pattern comprehensions have two commentHasCreator hops. Here the check is always true, because a and b are different people, but the planner can't know that.

EXPLAIN shows the same plan on both builds except for one extra FilterExec per affected branch (one in IC8, two in IC14), so the cost is evaluating the filter itself.

The custom suite's memory set was run twice. In the first, one branch run was noisy (higher means, unchanged minimums; the other branch run matched main), which put the geometric mean at 1.113; the table shows the rerun. In lazy mode, main alone moves by up to 36% between runs on some queries.

Custom (30 queries), memory: every query
Query main (ms) branch (ms) Change
1 0.64 0.62 -3.1%
10 0.72 0.73 +1.3%
11 1.58 1.58 +0.1%
12 0.75 0.76 +0.5%
13 0.81 0.82 +1.5%
14 0.53 0.53 +0.4%
15 0.56 0.57 +0.8%
16 0.66 0.66 +0.6%
17 0.91 0.92 +0.7%
18 0.70 0.70 +0.6%
19 0.94 0.94 -0.2%
2 0.53 0.53 -0.6%
20 2.09 2.12 +1.2%
21 0.76 0.75 -0.6%
22 0.72 0.71 -0.5%
23 0.74 0.74 +0.8%
24 0.69 0.69 -0.3%
25 0.80 0.81 +0.5%
26 0.74 0.75 +0.4%
27 0.87 0.88 +0.7%
28 1.41 1.43 +0.9%
29 0.69 0.69 -0.3%
3 0.63 0.64 +1.6%
30 44.80 46.06 +2.8%
4 0.50 0.50 -0.5%
5 0.78 0.78 -0.2%
6 0.42 0.44 +2.8%
7 1.03 1.15 +11.0%
8 0.64 0.64 +0.2%
9 0.81 0.82 +1.0%
Custom (30 queries), lazy: every query
Query main (ms) branch (ms) Change
1 1.28 1.20 -6.4%
10 1.21 0.93 -22.8%
11 2.37 2.23 -6.0%
12 7.04 7.76 +10.3%
13 1.29 1.22 -5.1%
14 0.69 0.73 +5.8%
15 0.76 0.91 +19.5%
16 1.09 1.18 +8.6%
17 1.47 1.42 -3.9%
18 1.22 1.18 -3.5%
19 1.36 1.34 -1.8%
2 1.43 1.56 +8.9%
20 17.15 16.63 -3.0%
21 0.83 0.85 +2.3%
22 0.92 0.95 +3.1%
23 1.25 1.24 -1.3%
24 1.05 1.11 +5.4%
25 1.27 1.26 -0.8%
26 1.06 1.12 +5.5%
27 1.29 1.30 +0.7%
28 2.33 2.35 +0.6%
29 1.02 1.02 +0.5%
3 1.06 0.93 -12.1%
30 44.67 41.88 -6.2%
4 0.83 0.90 +8.8%
5 2.35 2.46 +4.4%
6 0.80 0.85 +5.6%
7 1.22 1.13 -7.1%
8 1.85 1.75 -5.6%
9 1.33 1.38 +3.5%
Interactive complex (IC1–IC14), memory: every query
Query main (ms) branch (ms) Change
IC1 25.10 25.69 +2.3%
IC10 97.83 97.26 -0.6%
IC11 3.63 3.61 -0.5%
IC12 18.58 19.48 +4.8%
IC13 1.09 1.08 -1.0%
IC14 45.18 49.88 +10.4%
IC2 2.38 2.41 +1.3%
IC3 57.60 58.35 +1.3%
IC4 5.84 5.88 +0.6%
IC5 71.97 72.58 +0.8%
IC6 21.32 21.42 +0.5%
IC7 20.19 19.92 -1.3%
IC8 2.00 2.21 +10.8%
IC9 36.14 36.70 +1.5%
Interactive complex (IC1–IC14), lazy: every query
Query main (ms) branch (ms) Change
IC1 31.82 32.68 +2.7%
IC10 132.53 129.82 -2.0%
IC11 6.60 5.79 -12.2%
IC12 25.84 26.80 +3.7%
IC13 1.47 1.45 -1.3%
IC14 46.05 52.60 +14.2%
IC2 26.19 25.56 -2.4%
IC3 492.10 389.33 -20.9%
IC4 14.80 12.99 -12.2%
IC5 112.92 109.53 -3.0%
IC6 25.04 23.97 -4.3%
IC7 34.91 33.79 -3.2%
IC8 3.18 3.61 +13.4%
IC9 909.98 857.34 -5.8%

Machine: Apple M5, 24 GB, macOS 26.6.2, on AC power. Benchmark repositories: graph-benchmark-ldbc b649bc1, graph-benchmark-ldbc-complex f3e63fd.

🤖 Generated with Claude Code

Two single-hop relationships of the same type in one MATCH could bind
the same edge. Add a filter that their endpoints differ, naming
anonymous endpoints so they can be compared.

Fixes #59
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Deploying lance-csr with  Cloudflare Pages  Cloudflare Pages

Latest commit: 67aca1f
Status: ✅  Deploy successful!
Preview URL: https://f596f25d.lance-csr.pages.dev
Branch Preview URL: https://fix-relationship-uniqueness.lance-csr.pages.dev

View logs

@prrao87
prrao87 marked this pull request as ready for review October 28, 2026 21:22
@prrao87
prrao87 merged commit 00bc2b6 into main Oct 28, 2026
8 checks passed
@prrao87
prrao87 deleted the fix/relationship-uniqueness branch October 28, 2026 21:22
prrao87 added a commit that referenced this pull request Oct 28, 2026
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.

Two relationship variables in one pattern can bind the same edge

1 participant