Skip to content

feat(planner): lookup join for inner equi-joins on node ID - #1285

Open
adsharma wants to merge 4 commits into
mainfrom
feat/lookup-join-inner-pk-fetch
Open

adsharma wants to merge 4 commits into
mainfrom
feat/lookup-join-inner-pk-fetch

Conversation

@adsharma

Copy link
Copy Markdown
Contributor

What

Replaces full node-table scan + hash join with per-tuple primary-key lookups when one side of an inner equi-join on a single node ID is a bare single-node scan of that node (pure payload fetch) and the other side already binds the node ID with small estimated cardinality (gated by new knob PlannerKnobs::LOOKUP_JOIN_MAX_OUTER_CARDINALITY = 1024, INLJ-style pruning precedent).

Storage: NodeTable::lookupPK resolves INTERNAL_ID keys directly to offsets (table-ID + bounds + visibility, local-storage aware for open write transactions) without probing the HASH/ART PK index.

Motivation (LDBC Q1)

MATCH (p:Person)-[:personIsLocatedIn]->(pl:Place), (p)-[:hasInterest]->(t:Tag)
WHERE pl.name='Glasgow' AND t.name='Napoleon' RETURN p.firstName, p.lastName

The top HASH_JOIN(p._ID) + full 9892-row Person scan + semi-masker becomes a QUERY_PRIMARY_KEY_LOOKUP[p._ID] over the small outer: Person fetch ~1ms -> ~0.07ms; Q1 exec ~2.4ms -> ~2.0ms (remainder is the Tag-rooted 841-row chain + fixed overhead, tracked separately).

Validation

  • Q1 result verified identical (Thomas Brown), plan shows QUERY_PRIMARY_KEY_LOOKUP[table=3 key=p._ID].
  • e2e_test optimizer suite: 4/4 pass.
  • Broad sweep (match/subquery/join/unwind/ldbc/optional/tck/filter/explain/intersect/path/order/agg/delete): only failure is AnonymousParquetDeleteReload, which fails identically on base main (pre-existing).
  • Fixed during dev: local-storage handling in the fast path (caught by DeleteLocalNode + RecursiveJoinManualTransaction, both green now).

Notes

  • HASH PKs kept as-is: all 30 benchmark queries use PKs only for point probes; ART secondaries already cover the range predicates. No index changes in this PR.

When one side of an inner equi-join on a single node ID is a bare
single-node scan of that node (pure payload fetch, e.g. LDBC Q1's
Person scan) and the other side already binds the node ID with a
small estimated cardinality (<= LOOKUP_JOIN_MAX_OUTER_CARDINALITY),
replace the full scan + hash join with per-tuple primary-key lookups
(LogicalQueryPrimaryKeyLookup, inner semantics: non-matches dropped).

Storage: NodeTable::lookupPK resolves INTERNAL_ID keys directly to
offsets (table-ID + bounds + visibility checks, local-storage aware)
without probing the HASH/ART primary-key index.

Q1: top HASH_JOIN(p._ID) + full Person scan + semi-masker becomes a
0.07ms lookup over the small outer (was ~1ms of the ~2.4ms exec).
LogicalQueryPrimaryKeyLookup assumed nodeID/properties are new,
but lookup joins use the already-bound join node ID as the key
(same unique name), tripping the schema duplicate-insert assert
and breaking shell tests.

Use insertToGroupAndScopeMayRepeat (same as hash joins on node
IDs) and skip properties already bound by the outer plan.

Also fix clang-format layout flagged by CI.
The third CTRL_P was commented 'move up line' but on Linux CTRL_P
navigates history instead of moving a row (row-up-first applies only
on Windows and for the up-arrow escape on all platforms). With just
this test's own entries in history.txt the extra history-prev clamps
to a no-op, leaving the cursor on the bottom row, so the two Downs
walk off the recalled entry onto the empty line and ENTER submits
nothing until the 30s expect times out. The test only passed when
older shared-history entries happened to absorb the overshoot, hence
flaky across selections/orders.

Send up-arrow for the row-up step so the sequence is independent of
history depth.
…t results

Planner (join order):
- Lookup join no longer prunes hash alternatives. Both lookup
  orientations are added alongside the hash candidates, each
  maxCost-gated, and the cheapest plan wins.
- Cost via CostModel::computeLookupJoinCost (outer cost + per-tuple
  probes).
- Eligibility is semantic: bare single-node inner scan, single-table
  node, PK index present, key evaluable, payload properties that exist,
  outer within probe budget and smaller than the inner table (probes
  must save more scan than they cost; this also keeps full-coverage
  joins on the hash path so COUNT_REL_TABLE/degree/reachability
  rewrites still match).
- encodeJoin renders QUERY_PRIMARY_KEY_LOOKUP as LKP(key) so plan tests
  can pin the new shape; SingleNodeTwoHopJoins expectations updated
  (results verified identical with the optimizer on/off).

Storage:
- ArrowNodeTable/IceDiskNodeTable resolve INTERNAL_ID keys directly to
  offsets in lookupPK.

Parquet writer:
- readFromFT bulk reads into fresh flat single-value vectors, which can
  only ever hold the first tuple (FactorizedTable::lookup asserts
  !(isFlat && numTuples > 1); release builds silently wrote corrupt
  files, surfacing as TProtocolException on import). Always read one
  tuple here; the bulk path could never be correct.
@adsharma
adsharma force-pushed the feat/lookup-join-inner-pk-fetch branch from a19e5a4 to 82ffc3a Compare October 18, 2026 00:52
@adsharma

Copy link
Copy Markdown
Contributor Author

This is a fundamental change vs kuzu hash join algorithms. Could use more testing.

Cc: @zahariash

@zahariash

Copy link
Copy Markdown
Contributor

Tested 82ffc3a against its base, main at a7e923e, with release builds through the Python driver.

Correctness: no wrong results. I added checks to our workload simulator for the shapes that plan QUERY_PRIMARY_KEY_LOOKUP, and confirmed with EXPLAIN that they do: a neighbour's payload through a second MATCH (b), the reverse direction, columns added by ALTER TABLE ADD, and the Q1 shape with two patterns sharing a node. The simulator checks every result against a model, inside write transactions, in read-only transactions across another connection's CHECKPOINT, and around crashes and DDL; 300 random workloads passed. Run with the plan optimizer on and off, the same queries also gave identical results for outer sides of 900 to 1150 rows around the 1024 cap, for inserted, updated and deleted rows in an open write transaction and after ROLLBACK, and for relationship tables between two node tables. On LDBC SF1 (the 30 benchmark queries and IC1–IC14) all 44 return the same results as main.

Performance: the heavy IC queries regress. 23 of the 44 plans change. Execute plus drain, median of 9 runs, the lower of two alternating rounds per build:

query main this PR
IC5 2308 ms 5395 ms 2.34×
IC4 59 ms 125 ms 2.11×
Q11 8.8 ms 13.5 ms 1.54×
IC2 72 ms 90 ms 1.24×
IC3 459 ms 489 ms 1.07×
Q3 1.39 ms 0.81 ms 0.58×
Q7 11.6 ms 10.4 ms 0.90×
Q1 1.68 ms 1.45 ms 0.86×

The outer side is far larger than its estimate. In IC5 the lookup on Forum (key forum._ID) takes 1,569,282 tuples and 4.3 s of its own time in PROFILE, although the plan passed the outerCard <= 1024 gate; in IC4 the lookups on Post and Tag take 112,285 tuples each. The gate trusts the estimate, and nothing switches back to the masked scan and hash join when the actual count passes the budget. A runtime fallback once the probes exceed LOOKUP_JOIN_MAX_OUTER_CARDINALITY, or a tighter condition on when the estimate is trusted, would keep Q1 and Q3 without the IC4/IC5 cost.

Arrow tables never take the lookup. On tables from create_arrow_table/create_arrow_rel_table (3,000 nodes, 12 edges from one node) the same two shapes stay on the hash join, probably because tryPlanLookupJoin finds no primary-key index, so the new INTERNAL_ID path in ArrowNodeTable::lookupPK was not reached by any query I tried.

Separately, OPTIONAL MATCH (a)-[:K]->(b) ... MATCH (b) keeps the rows where b is NULL; it is the same on main (plain hash-join plan), so it predates this PR: #1298.

This branch has not been deployed

No deployments
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.

2 participants