Repository navigation
Bug: a lookup join probes every row of a full node-table scan, 2.7x slower than a hash join #1394
Description
This work was produced with the help of language models.
Ladybug version
0.22.0. Also 0.22.0.dev20261022 and dev20261024. Clean on 0.22.0.dev20261017 and 0.21.2.
What operating system are you using?
Linux x86-64, Python 3.10–3.14.
What happened?
A key lookup on one table followed by a hop to a second table now resolves the second table with a per-row QUERY_PRIMARY_KEY_LOOKUP, fed by a full scan of the first table. Every row of that scan is probed, not just the rows the key lookup keeps. Results are correct; the query gets slower as the first table grows.
UNWIND $ids AS i MATCH (a:person) WHERE a.ID = i
MATCH (a)-[:workAt]->(b:organisation)
RETURN b.name, b.history, a.ID;The tables are tinysnb's person, organisation and workAt, cut to the columns the query reads, with 10,000 persons and 4,300 organisations.
Expected: the plan 0.21.2 picks — scan organisation once and hash-join it, with a semi-mask from the person side. Actual: QUERY_PRIMARY_KEY_LOOKUP [organisation] over EXTEND [:workAt] over SCAN_NODE_TABLE [person], i.e. 10,000 lookups for 80 rows of output. The same query with a filter that keeps every row, WHERE b.ID >= 0, gets the hash join back and returns identical rows.
| person / organisation rows | as planned | with WHERE b.ID >= 0 |
ratio |
|---|---|---|---|
| 600 / 260 | 0.80 ms | 0.55 ms | 1.5x |
| 1,200 / 520 | 1.66 ms | 0.98 ms | 1.7x |
| 3,000 / 1,300 | 4.66 ms | 2.03 ms | 2.3x |
| 10,000 / 4,300 | 13.03 ms | 4.81 ms | 2.7x |
Every 125th person ID is looked up; each cell is the mean of two runs' medians. The script below runs the last row and prints about 2.7x on 0.22.0; 0.21.2 runs it in 6.2 ms as planned, with no lookup. EXPLAIN LOGICAL puts Cardinality: 1 on every operator of this plan, including the scan of person, although MATCH (a:person) RETURN a.ID alone is estimated at 10,000. ANALYZE does not change the plan.
Not #1307. #1307 is about estimates going wrong on skewed multi-hop fan-out (LDBC IC4/IC5) and proposes a runtime fallback. Here the outer is a single full scan of a table whose size the planner knows, and the case is small and deterministic. #1370 (cap by flat outer rows) is in 0.22.0 and does not catch it either.
Bisected to the nightlies, not to a commit. Clean on 0.22.0.dev20261017, broken on dev20261022. #1285 (lookup join for inner equi-joins on node ID) merged on 2026-10-18 inside that window. Inference: the lookup gate sees the outer at the 1-row estimate of the UNWIND $ids side, as if the scan of person were already filtered by it, but choosing the lookup removes the hash join whose semi-mask did that filtering, so the scan runs unfiltered.
Workaround. A predicate on the probed table that keeps every row, such as WHERE b.ID >= 0, or CALL enable_plan_optimizer=false before the query first runs on that connection. Set after a run, the setting does not reach that query string: EXPLAIN shows the hash join, but execution keeps the cached lookup plan.
Are there known steps to reproduce?
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10,<3.15"
# dependencies = ["ladybug==0.22.0"]
# ///
"""ladybug 0.22.0: the lookup join from #1285 is planned for a join whose outer
side is a full node-table scan, so every scanned row is probed one at a time.
Clean on 0.21.2, which has no lookup join.
Uses the tinysnb tables person, organisation and workAt, cut to the columns the
query reads, at a larger scale.
Exits 1 when the plan probes organisation per row and runs more than 1.5x slower
than the same query with a row-preserving filter that keeps the hash join.
"""
import os
import statistics
import sys
import tempfile
import time
import ladybug
N_PERSONS, N_ORGS, IDS = 10000, 4300, list(range(0, 10000, 125))
path = os.path.join(tempfile.mkdtemp(), "db.lbug")
build = ladybug.Connection(ladybug.Database(path))
build.execute("CREATE NODE TABLE person(ID INT64, fName STRING, PRIMARY KEY(ID));")
build.execute("CREATE NODE TABLE organisation(ID INT64, name STRING, history STRING, PRIMARY KEY(ID));")
build.execute("CREATE REL TABLE workAt(FROM person TO organisation, year INT64, MANY_ONE);")
build.execute("UNWIND range(0, $n - 1) AS i CREATE (:organisation {ID: i, name: 'org' + CAST(i AS STRING), "
"history: repeat('lorem ipsum ', 160)});", {"n": N_ORGS})
build.execute("UNWIND range(0, $n - 1) AS i CREATE (:person {ID: i, fName: 'p' + CAST(i AS STRING)});",
{"n": N_PERSONS})
build.execute("MATCH (a:person), (b:organisation) WHERE b.ID = a.ID % $n "
"CREATE (a)-[:workAt {year: 2015}]->(b);", {"n": N_ORGS})
build.execute("CHECKPOINT;")
del build
conn = ladybug.Connection(ladybug.Database(path, read_only=True))
QUERY = ("UNWIND $ids AS i MATCH (a:person) WHERE a.ID = i MATCH (a)-[:workAt]->(b:organisation) {filter}"
"RETURN b.name, b.history, a.ID;")
AS_PLANNED = QUERY.format(filter="")
CONTROL = QUERY.format(filter="WHERE b.ID >= 0 ")
def rows(q):
result = conn.execute(q, {"ids": IDS})
out = []
while result.has_next():
out.append(result.get_next())
return sorted(out)
def p50(q):
for _ in range(5):
rows(q)
times = []
for _ in range(60):
t0 = time.perf_counter()
rows(q)
times.append((time.perf_counter() - t0) * 1000)
return statistics.median(times)
def probes_organisation(q):
plan = conn.execute("EXPLAIN LOGICAL " + q, {"ids": IDS})
text = ""
while plan.has_next():
text += str(plan.get_next()[0])
return text.count("QUERY_PRIMARY_KEY_LOOKUP") >= 2 # one for a.ID = i, one more for organisation
print(f"ladybug {ladybug.__version__}: {N_PERSONS} person rows, {N_ORGS} organisation rows, {len(IDS)} ids")
same = rows(AS_PLANNED) == rows(CONTROL)
planned, control = p50(AS_PLANNED), p50(CONTROL)
lookup = probes_organisation(AS_PLANNED)
print(f" as planned per-row lookup: {lookup!s:5} p50 {planned:6.2f} ms")
print(f" control: WHERE b.ID >= 0 per-row lookup: {probes_organisation(CONTROL)!s:5} p50 {control:6.2f} ms")
print(f" same rows: {same}; as planned / control = {planned / control:.2f}x")
if lookup and planned > 1.5 * control:
print("\nBUG REPRODUCED")
sys.exit(1)
print("\nclean on this version")
Activity