Milestone 22: reordering across an outer join - #33
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
aandtaghold twenty rows;bigholds twenty thousand. TheLEFTjoinreads only
a, so it does not needbigon its left however the query waswritten.
a x big LEFT taga LEFT tag x bigSame 20,000 rows, same as SQLite's.
What an outer join actually requires
_OuterJoincarriesmin_left: the tables its ownONreads, in place of"everything written to its left". That is PostgreSQL's
min_lefthandwith theparts a flat
FROMneeds — there is nomin_righthand, because the right ofevery join here is one table.
_plan_chainand_plan_segmentare gone. One System R search runs over everytable with two rules on it:
table it contains has its
min_leftinside it too. Half an outer join is nota relation:
{b, c}is impossible whenbexists only becausea ⟕ bran.min_leftis present andevery 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_ENUMERATEthe fallback is now written order rather thangreedy: 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
LEFTjoin preserves everything accumulated on its left, so a table moved inthere arrives intact. A
RIGHTjoin NULL-extends it, so a table moved inthere 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:
planned as
(grandchild x parent) ⟖ child. No rows, where there was one.min_left ⊆ restfor aLEFTjoin;rest == syntactic_leftfor 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 thebug in place. An illegal plan is only a test if the search would actually
choose it.
ais empty now, and planting the one-sided rule back turns it red.Verification
min_leftforced back to thesyntactic set. Every answer must be identical: a reordering that changes a row
is not an optimisation.
execute_scriptstops atDEFAULT_MAX_ROWSand two plans returning differentten-thousand-row prefixes look exactly like a reordering that changed the
answer. That trap has now caught this project twice.
make cigreen. 1,700 Python tests, 160 frontend.🤖 Generated with Claude Code