Skip to content

fix(processor): keep the child's selection vector intact in hash join build - #1119

Merged
adsharma merged 1 commit into
LadybugDB:mainfrom
zahariash:fix/chained-optional-match
Oct 7, 2026
Merged

adsharma merged 1 commit into
LadybugDB:mainfrom
zahariash:fix/chained-optional-match

Conversation

@zahariash

@zahariash zahariash commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

This work was produced with the help of language models.

Fixes #1109.

An OPTIONAL MATCH that follows one that found nothing returned wrong rows: some (a, c, m) rows came back twice and other comments lost their match, with no error. The null c of the empty leg ends on a hash join build side. JoinHashTable::appendVectors (src/processor/operator/hash_join/join_hash_table.cpp) drops null keys through ValueVector::discardNull, which compacts the key state's selection vector in place, and HashJoinBuild never restored it. The producers below the build, FactorizedTable::readFlatColToUnflatVector and the aggregate hash table scan, write their next batch through that same selection vector after only resetting its size, so every batch after the one with the null was read through stale, compacted positions: one slot is never written and keeps the null, which is dropped again, and one slot is written twice, so one key is lost and another is appended twice. Where the null falls decides how many batches follow it, hence the dependence on data layout and thread count in the issue's table.

Since #1018 (selective correlated-optional unnest) a chained OPTIONAL MATCH leg is planned as a correlated subplan whose outer bindings, including the null node of a leg that found nothing, land on a build side. Correlated EXISTS, non-equality correlated OPTIONAL MATCH and updates that swap the join sides were affected before #1018 too.

Fix. HashJoinBuild inherits SelVectorOverWriter. When a key vector carries no no-null guarantee, executeInternal appends through a copy of the key state's selection vector and restores the child's afterwards, the pattern HashJoinProbe already uses for its key state. Keys with the guarantee skip the copy; discardNull returns early on the same guarantee, so there is nothing to restore for them.

Cost. User-space instruction counts (perf stat -e instructions:u, deterministic to <0.01%), 1 thread, this PR against main:

query instructions
flat-key build, 2M one-row build calls (worst case) +0.40%
chained OPTIONAL MATCH (the issue's shape) +0.22%
correlated EXISTS +0.05%
scan and aggregate queries without a hash join 0.00%

Without the no-null shortcut the worst case was +2.0%. CPU cycles are +1-3% on the hash-join queries; a profile attributes about 0.26% to HashJoinBuild::executeInternal and spreads the rest over unrelated functions in both directions (SUM's int128 add, malloc/free and the progress bar up; restoreSelVector, probe and projection down), which is consistent with code placement rather than added work. Wall-clock A/A noise on this machine is about ±0.5%.

Before and after. The issue's dataset (65 persons with 500 comments each, except person 32), threads=1:

query main this PR
chained OPTIONAL MATCH, count(*) 32007 32001
rows with count(m) <> 1 13 1 (person 32's null row)
OPTIONAL MATCH ... WHERE m.id <= a.id 32007 32001
EXISTS { MATCH (c)-[:replyOf]->(m) WHERE m.id <= a.id } 31994 32000
OPTIONAL MATCH (c)-[:replyOf*1..2]->(m) 32007 32001
update that puts the outer side on the build side, count(DISTINCT c) 31993 32000

The issue's thread x layout grid (person 32, 64 or 0 without comments; 1, 2, 3, 4 and default threads; 5 runs each) reproduced the issue's table on main, up to 33073 at 3 threads; with this PR every cell is 32001.

Tests. test/test_files/issue/issue1109.test runs the six queries above on the issue's dataset at threads=1. All six fail without the source change. ctest (make test-build-release): 0 of 2861 failed.

Measurement setup, profile and the grid

Instruction counts: a synthetic graph with 2M comments, 100k persons and 1M knows edges (Comment, Person, Post, hasCreator, replyOf, knows, loaded with COPY); each query repeated in one shell session at CALL threads=1, perf stat -e instructions:u over the process. The rows of the table: MATCH (c:Comment)-[:hasCreator]->(p:Person) WITH c, p MATCH (c)-[:replyOf]->(m:Post) RETURN sum(p.id + m.id) (a flat-key build called once per comment), MATCH (a:Person) OPTIONAL MATCH (a)<-[:hasCreator]-(c:Comment) OPTIONAL MATCH (c)-[:replyOf]->(m:Post) RETURN count(*), count(m), MATCH (p:Person) WHERE EXISTS { MATCH (p)<-[:hasCreator]-(c:Comment) WHERE c.id < p.id * 20 } RETURN count(*), and for the last row MATCH (c:Comment) WHERE c.id % 3 = 0 RETURN sum(c.id), count(*) and MATCH (c:Comment) WITH c.id % 1000 AS k, count(*) AS n RETURN sum(n * k). Three binaries: main, the fix without the no-null shortcut, and this PR. Instruction counts repeat to <0.01% between runs, which is why they are the headline numbers; the +1-3% is CPU cycles on the hash-join queries and the ±0.5% is the wall-clock spread between two runs of the same binary. The profile is perf record on main and on this PR, compared per symbol.

The grid is the issue's reproducer: for each layout (person 32, 64 or 0 without comments) a fresh database, then the chained OPTIONAL MATCH five times at 1, 2, 3, 4 and the default thread count, reporting the distinct count(*) values per cell.

… build

JoinHashTable::appendVectors discards null keys by compacting the key
state's selection vector in place, and HashJoinBuild never restored it.
Producers such as factorized table and aggregate scans write their next
batch through that selection vector after only resetting its size, so every
batch after one with a null key was read through the stale compacted
positions: one key was lost and another appended twice, while the never
written slot kept the null. Results depended on where the null fell, hence
on data layout and thread count.

When a key may contain nulls, build now appends through a copy of the key
selection vector and restores the child's afterwards, as HashJoinProbe
already does. Keys with a no-null guarantee skip the copy.

Since LadybugDB#1018 (selective correlated-optional unnest) a chained OPTIONAL MATCH
puts the null node of a leg that found nothing on a build side; correlated
EXISTS, non-equality correlated OPTIONAL MATCH and updates that swap the
join sides were already affected.

Fixes LadybugDB#1109

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adsharma

adsharma commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@zahariash there is some dangling text at the end of the PR description that I can't understand.

Also does this PR fix #1121?

@zahariash

Copy link
Copy Markdown
Contributor Author

@adsharma

The dangling text (removed now) was the tail of a “Not addressed” note about an unrelated COUNT { ... } failure seen while testing. I'm sorry for that.

This PR does not fix #1121.

@adsharma

adsharma commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Thanks. This looks good to go.

Potential follow-up:

  1. Multi-key with divergent states is still only half-fixed — same caveat HashJoinProbe already documents ("all keys' states should be restored" TODO). Both build and probe save/restore only keyState / keyVectors[0]->state, but discardNullFromKeys compacts every key vector's state. If composite keys ever land on different DataChunkStates (e.g. flat correlated param + unflat key), the non-keyState vectors stay compacted. Consider a follow-up that saves/restores all distinct key states, or better, moves the save/restore into JoinHashTable::appendVectors/probe so all callers benefit.

@adsharma
adsharma merged commit 4887e44 into LadybugDB:main Oct 7, 2026
4 checks passed
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.

Bug: a second OPTIONAL MATCH after a null-producing one duplicates rows and drops matches at low thread counts

2 participants