Skip to content

Milestone 22: reordering across an outer join - #33

Merged
YheChen merged 3 commits into
masterfrom
reorder-across-outer-joins
Jul 31, 2026
Merged

Milestone 22: reordering across an outer join#33
YheChen merged 3 commits into
masterfrom
reorder-across-outer-joins

Conversation

@YheChen

@YheChen YheChen commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Milestone 18 gave the join-order search a rule it could not argue with: an
outer join runs where it was written.
Correct, and more than was needed.

SELECT a.id FROM a
     JOIN big ON a.k  = big.k
LEFT JOIN tag ON a.id = tag.a_id;

a and tag hold twenty rows; big holds twenty thousand. The LEFT join
reads only a, so it does not need big on its left however the query was
written.

order time
Milestone 18 a x big LEFT tag 99.6 ms
Milestone 22 a LEFT tag x big 49.2 ms

Same 20,000 rows, same as SQLite's.

What an outer join actually requires

_OuterJoin carries min_left: the tables its own ON reads, in place of
"everything written to its left". That is PostgreSQL's min_lefthand with the
parts a flat FROM needs — there is no min_righthand, because the right of
every join here is one table.

_plan_chain and _plan_segment are gone. One System R search runs over every
table with two rules on it:

  • Validity. A subset is only one a plan could hold if every outer join whose
    table it contains has its min_left inside it too. Half an outer join is not
    a relation: {b, c} is impossible when b exists only because a ⟕ b ran.
  • Eligibility. An outer join may run when its min_left is present and
    every outer join written before it has run. Outer joins keep their order
    relative to each other; the inner ones are what gained freedom.

Past MAX_TABLES_TO_ENUMERATE the fallback is now written order rather than
greedy: greedy would need the legality test in its inner loop and could still
strand itself with no legal pair left.

The bug this milestone shipped, and then caught

The containment is one-sided, and the first version was not.

A LEFT join preserves everything accumulated on its left, so a table moved in
there arrives intact. A RIGHT join NULL-extends it, so a table moved in
there is destroyed and a condition reading it elsewhere sees a NULL the written
query never produced.

All 1,699 tests passed. The differential tester found it on the next campaign,
at seed 634, on exactly the three-table shape Milestone 21 built:

SELECTFROM parent
RIGHT JOIN child ON parent.pid = child.parent_id AND child.parent_id > 0
     JOIN grandchild ON child.cid = grandchild.child_id
WHERE parent.p_tex IS NULL;

planned as (grandchild x parent) ⟖ child. No rows, where there was one.

min_left ⊆ rest for a LEFT join; rest == syntactic_left for the other two,
which is Milestone 18's rule kept where it is still the right one.

The regression test needed a second try too. Its first version used a
three-row a, and the correct order was cheaper anyway, so it passed with the
bug in place. An illegal plan is only a test if the search would actually
choose it. a is empty now, and planting the one-sided rule back turns it red.

Verification

  • Six queries planned twice, once with min_left forced back to the
    syntactic set. Every answer must be identical: a reordering that changes a row
    is not an optimisation.
  • 320,000 generated query pairs agree with SQLite, chains at 13% of them.
  • The fixture is 4,000 rows rather than the 20,000 measured above, because
    execute_script stops at DEFAULT_MAX_ROWS and two plans returning different
    ten-thousand-row prefixes look exactly like a reordering that changed the
    answer. That trap has now caught this project twice.

make ci green. 1,700 Python tests, 160 frontend.

🤖 Generated with Claude Code

YheChen and others added 3 commits July 31, 2026 02:16
Milestone 18 gave the search a rule it could not argue with: an outer
join runs where it was written. Correct, and more than was needed.

    SELECT a.id FROM a
         JOIN big ON a.k  = big.k
    LEFT JOIN tag ON a.id = tag.a_id;

The LEFT join reads only `a`, so it does not need `big` on its left
however the query was written. With 20, 20,000 and 20 rows the written
order takes 99.6 ms and `a LEFT tag x big` takes 49.2 ms, for the same
20,000 rows.

_OuterJoin carries min_left, the tables its own ON reads, which is
PostgreSQL's min_lefthand with the parts a flat FROM needs (there is no
min_righthand: the right of every join here is one table). _plan_chain
and _plan_segment are gone. One System R search runs over every table
with two rules layered on it:

- a subset is valid only if every outer join whose table it contains has
  its min_left inside it too, because half an outer join is not a
  relation and the search must not cost one
- an outer join may run when its min_left is present and every outer
  join written before it has run

RIGHT and FULL get no freedom, and that is the identity failing rather
than caution: they NULL-extend the accumulated left, so an inner join
moved below one is handed NULLs the written query never showed it.

Past the enumeration limit the fallback is written order rather than
greedy. Greedy would need the legality test in its inner loop and could
still strand itself with no legal pair left; written order is legal by
construction and EXPLAIN says it gave up.

Six queries are planned twice, once with min_left forced back to the
syntactic set, and every answer must match. That is the contract as a
test rather than a paragraph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The containment is one-sided, and this milestone shipped without
knowing it. A LEFT join preserves everything accumulated on its left,
so a table moved in there arrives with its values intact. A RIGHT join
NULL-extends that same accumulated left, so a table moved in there is
destroyed, and a condition that reads it elsewhere then sees a NULL the
written query never produced.

All 1,699 tests passed. The differential tester found it on the next
campaign, at seed 634:

    SELECT … FROM parent
    RIGHT JOIN child ON parent.pid = child.parent_id AND child.parent_id > 0
         JOIN grandchild ON child.cid = grandchild.child_id
    WHERE parent.p_tex IS NULL;

planned as (grandchild x parent) RIGHT child, which NULL-extends
grandchild on the way past and then compares it. No rows, where there
was one. Exactly the shape Milestone 21's third table was built for.

min_left ⊆ rest for a LEFT join; rest == syntactic_left for the other
two, which is Milestone 18's rule kept where it is still the right one.

The regression test needed a second try as well. Its first version used
a three-row `a`, and the correct order was cheaper anyway, so it passed
with the bug in place: an illegal plan is only a test if the search
would actually choose it. `a` is empty now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chen-db Ready Ready Preview Jul 31, 2026 6:45am

@YheChen
YheChen merged commit ac23b6d into master Jul 31, 2026
6 checks passed
@YheChen
YheChen deleted the reorder-across-outer-joins branch July 31, 2026 06:49
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.

1 participant