Keep the ungrouped vectorized aggregate over a unique-key inner join - #1012
Conversation
A joinrel used to drop the fold even when the dimension was a unique filter of the fact table. Duplicate-key dimensions and LEFT joins still use core Agg.
|
This returns a wrong answer. Please hold it. The fold drops a join condition it never The defect
if (list_length(hashPath->path_hashclauses) != 1)
return false;
restrictInfo = linitial_node(RestrictInfo, hashPath->path_hashclauses);
Only a CROSS-relation non-equi clause is lost, which is why the existing arms pass. A Measured, on
|
This PR was DIRTY and no CI had ever run on it: `statusCheckRollup` was EMPTY, which reads as "0 pending, 0 failing" and is not the same thing as green. The conflict is mine -- commandprompt#1007, commandprompt#1005 and commandprompt#1008 merged in the last hour and all three touch the two files below. TWO CONFLICTS, BOTH ADDITIVE COLLISIONS, BOTH RESOLVED BY KEEPING BOTH SIDES. docs/user-guide.md: two feature bullets, neither a revision of the other. test/pytest/TESTS.md: both sides appended a section and BOTH CALLED IT 28. commandprompt#1007's test_docs_join_clustering landed first and keeps 28; this PR's test_join_vector_agg becomes 29. Heading, contents-list entry and anchor moved together, because the corpus guard resolves every contents-list link and a renumbered heading with a stale anchor is a broken link that still looks right. That collision is commandprompt#996 in a different file: every change appends at one anchor with a sequential number, so any two of them conflict by construction. Verified on the merged tree: test_docs_cover_the_corpus 31 passed, 73 checks (every test named, every contents-list anchor resolves) the 14 database-free files 272 passed, 650 checks, 0 fail ledger census stated 1162, holds 1162: they agree THE LEDGER LOOKED WRONG AND IS NOT, which is worth recording because it is commandprompt#1004 meeting reality four hours after it was written. 1165 rows against 1162 `never`. Three rows now carry a last-red date -- two of commandprompt#1008's arms and this PR's own `unique join uses vectorized agg when GUC on`, which carries the mutation `drop JOINREL fold`. That is a removal proof recorded in the ledger, exactly what the column is for. `grep -c` over the file gives 1165 and the gate wants 1162, so the wrong derivation I shipped this afternoon would now produce a refusal on a correct tree. No code change; the C in this PR is untouched by the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
|
Checked By inspection of the file at that head, not a diff: So the fold still reads only the hash clause, and a join carrying an extra cross-relation To be precise about what I have and have not done at this head: the absence of the guard The fixture and the one-line guard are in my earlier comment, unchanged. |
harness_selftest refused the tree:
FAIL every script that declares an interpreter is executable:
got [[1: test/native_join_vector_agg.sh]] want [[]]
The file has a shebang and mode 100644. Every sibling suite is 100755.
NOT INTRODUCED BY THE MERGE COMMIT BELOW. The blob and the mode are identical on
the pre-merge head 48531f3 -- same hash, same 100644 -- so this arrived with the
branch. It went unseen because this PR was DIRTY and **no CI run ever existed for
it**: `statusCheckRollup` was empty, which reads as "0 pending, 0 failing" and is
not the same thing as passing. Rebasing it did not break it; rebasing it was what
first let anything look.
The suite itself passes (`native_join_vector_agg=PASS` in the same run). Only the
mode guard failed, and that guard is the reason a suite nobody can execute cannot
reach main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at be0ef93c. The safety argument rests on core's own uniqueness proof rather than a hand-rolled one, the refusals are stricter than they need to be, and the removal proof is recorded in the ledger rather than asserted in prose.
First, what I did to this PR and why
It was DIRTY with an empty statusCheckRollup — which reads as pending=0, bad=0 and is not the same thing as green. No CI run had ever existed for it. I said "checks already complete" earlier on that basis and was wrong.
The conflict was mine: #1007, #1005 and #1008 merged within the hour and all three touch docs/user-guide.md and test/pytest/TESTS.md. Two additive collisions, both resolved by keeping both sides. The TESTS.md one is #996 in a different file — both sides appended a section and both called it 28; #1007's landed first and keeps 28, this one became 29, with heading, contents entry and anchor moved together.
Then CI ran for the first time and refused the tree:
FAIL every script that declares an interpreter is executable:
got [[1: test/native_join_vector_agg.sh]] want [[]]
Mode 100644 with a shebang. I checked whether my merge caused it: the blob hash and the mode are identical on the pre-merge head 48531f36. It arrived with the branch and nothing had ever looked. Fixed as its own commit. Rebasing did not break this PR; rebasing is what first let anything see it.
The correctness argument holds where it matters
The claim is that a unique-key inner join is a filter of the fact table, so the ungrouped fold stays valid. That is true only if no fact row can match twice, and this does not try to prove that itself:
if (!innerrel_is_unique(root, joinrel->relids, outerPath->parent->relids,
innerrel, JOIN_INNER, hashPath->path_hashclauses, true))
return false;Core's own proof, the one the planner uses for join removal. That is the right call — a hand-rolled uniqueness test here would be a second implementation of something subtle, diverging silently from the planner's.
The refusals are stricter than the minimum:
| guard | why it is the careful version |
|---|---|
scans root->join_info_list and rejects any non-INNER SpecialJoinInfo covering the rel |
an INNER hash path can sit under an outer join; checking jpath.jointype alone would miss it |
bms_num_members(joinrel->relids) != 2 |
no multi-way |
innerPath->param_info != NULL rejected |
no parameterized inner |
single hash clause, OpExpr, two Var args |
no compound or expression keys |
The join_info_list scan is the one I went looking for and expected not to find.
Evidence
native_join_vector_agg=PASS in the CI run, all 13 checks green
duplicate dim keys refuse the join fold + answer equals heap
LEFT join refuses the join fold + answer equals heap
unique join fold answer equals GUC off + answer equals heap
The two refusal arms are the ones that matter: each is paired with a heap-equality arm, so a wrongly-accepted fold would show as a wrong answer rather than only as a missing marker.
The removal proof is in the ledger, not in the PR body:
native_join_vector_agg unique join uses vectorized agg when GUC on 2026-09-12 drop JOINREL fold
That is the only row in the whole ledger carrying a mutation, and it is this PR's. The claim "dropping the JOINREL branch turns only the unique-join marker red" is therefore recorded where a later run can contradict it, which is the point of the column.
GUC off by default (pgcolumnar_enable_ungrouped_vector_agg = false), so this ships inert and does not close #752.
One note
The pytest twin's independence is asserted in its docstring — "Independently of native_join_vector_agg.sh, this session builds its own fixtures" — and it holds: no import of the shell harness, no invocation of the suite. Worth saying explicitly since the two files share the public EXPLAIN string, which is the seam they are supposed to share.
Verdict
Approved. Merging is not mine to decide, and note the head now carries two commits of mine — a merge resolution and the mode fix — so @linuxhikerpm may want to look before it lands.
Re-measured at
|
An extra Join Filter was dropped, so the fold returned the equi-join sum instead of the filtered one.
|
Hold fix is on The fold read only |
jdatcmd
left a comment
There was a problem hiding this comment.
Re-approving at fbc1e5d2. The head moved after my approval and the delta is a tightening, so I checked it rather than letting the badge carry:
/* A Join Filter besides the hash clause is not a membership test. The
* fold would ignore it and over-count. */
if (list_length(hashPath->jpath.joinrestrictinfo) != 1)
return false;That is a real hole and you found it yourselves: an extra join clause means the hash clause is no longer the whole join condition, so treating the join as a membership filter would over-count. Strictly more conservative than what I approved, with five new arms across both harnesses and heap-equality on both the extra-filter and inequality cases.
Merging as soon as CI settles.
Fixed, and verified by running it rather than reading it
The control is the half that matters for a guard like this. I had the guard by inspection first and did not report it as fixed on that basis. Reading The arms are the right shapeexpect.rows([on], [off], "extra join filter answer equals GUC off")
expect.rows([on], [heap], "extra join filter answer equals heap")A value against an oracle, with a heap twin, plus the plan assertion that the vectorized The comment names the reason, which is the part that survives: /*
* A Join Filter besides the hash clause is not a membership test. The
* fold would ignore it and over-count. Three-way joins are already
* refused above: more than two relids.
*/Not approving yet, for one reason
Worth one note for the record: the existing approval was given at |
commandprompt#1013 landed between this branch going green and being merged, so the merge commit could not be created. Second rebase of this PR today; both were caused by merges of mine, not by anything the author did. TWO CONFLICTS. CHANGELOG.md: two entries, neither a revision of the other. Both kept. check_ledger_budget.txt: the census. This branch said 1162, main said 1164, and BOTH ARE NOW WRONG -- the ledger auto-merged and holds rows from both sides. The number is DERIVED from the merged file rather than picked from either parent, which is the case commandprompt#1004 exists for and commandprompt#952 before it: two branches each re-derive a census from the same base, the ledger takes both sets of rows, and the budget keeps whichever side won the conflict. rows 1179 | never 1171 | ever red 8 gate: census stated 1171, ledger holds 1171: they agree duplicate (suite, part, name) keys: 0 MY FIRST DERIVATION WAS WRONG AND SAID SO OUT LOUD. I counted `$5=="never"` and got ZERO against 1179 rows. commandprompt#1019 adds the majors field and moves last-red to field 5, but commandprompt#1019 is not merged: this tree is still five fields, and last-red is field 4. A count of zero never-red rows on a tree with eight ever-red is not a plausible number, which is the only reason I looked. Had the two formats differed by something less obvious than 1171 against 0, the budget would have shipped a lie the gate would then have refused on a correct tree. No code change; the C in this PR is untouched by the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
#1020 rebased onto main and took section 30 for test_differential.py. This branch claimed the same number. Heading, contents entry and anchor moved together. FOURTH TIME TODAY on this one anchor: #1007 and #1012 both claimed 28, #1012 and #1020 both claimed 29, and now this and #1020 both claimed 30. Every change appends a section with the next sequential number, so any two open at once collide by construction. That is #996's shape in TESTS.md rather than in the CHANGELOG, and it is worth saying that renumbering by hand each time is the cost of not fixing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
Summary
Columnar Vectorized Aggregateswhenpgcolumnar.enable_ungrouped_vector_aggis on. The join is a filter of the fact table, so the fold no longer disappears the moment the input is a joinrel.test/native_join_vector_agg.shandtest/pytest/test_join_vector_agg.py. Causation: dropping the JOINREL branch turns only the unique-join marker red.Test plan
Made with Cursor