fix: do not price a base scan from sibling projection pages - #1180
Conversation
OffgridwithJD
left a comment
There was a problem hiding this comment.
Adversarial review. The change itself I could not break: the premise holds, the arithmetic
is right, and the TDD sequence is real. One finding, and it is about the guard rather than
the fix: the arm cannot fail in the direction this change newly makes reachable.
The guard is one-sided, and an 11x under-pricing passes it
print (r+0 > 1.25) ? "inflated ratio=" r : "stable"Only r > 1.25 is refused. Under-subtraction — the bug being fixed — makes r large and is
caught. Over-subtraction makes r small, and small is "stable".
Measured on your branch, PG 18, with one mutation and nothing else changed:
control before_run=22000 after_run=22000 ratio=1.000 PASS 9 passed + 0 failed
projPages * 2 before_run=22000 after_run=2000 ratio=0.091 PASS 9 passed + 0 failed
.so 8189ae811a91 -> 1996f6e83002 -> restored
The base scan is priced at one eleventh of its true cost and the suite reports nine
green checks. Your causation run (projPages = 0) proves the arm sees too LITTLE
subtraction; nothing proves it sees too much, and too much is the failure mode this code
introduces, because before it there was no subtraction to get wrong.
The property you want is "adding a projection does not move the base scan's price", so the
assertion is a band, not a ceiling:
print (r+0 > 1.25 || r+0 < 0.8) ? "moved ratio=" r : "stable"With that, my mutation reddens. It is the same shape as the io-kept/halved arms in
#1182: a one-sided bound whose other side only became reachable when the code changed.
A silent clamp worth one line
if (nblocks > projPages)
nblocks -= projPages;
else
nblocks = 1;If the sibling footprints ever meet or exceed the file, the estimate collapses to one page
and nothing says so. In principle it cannot happen, since nblocks counts the same file the
projections live in. In practice it is reachable through stale or orphaned projection row
groups, a rewrite, or a rounding error in COLUMNAR_PAGE_ROUND_UP — and a one-page estimate
for a large table is the kind of wrong answer that reads as a planner bug somewhere else.
It costs nothing to make it observable.
What I tried to break and could not
I expected a planning-time regression and there is none. pgcolumnar_relation_estimate_size
now walks every projection's row-group list on every plan, in a function whose own comments
defend against exactly that ("This runs on every plan of a columnar relation, so a per-group
fold is paid per plan"). Measured with 200,000 rows at stripe_row_limit=1000:
base row groups projection row groups planning ms (21 samples)
main 7397731 400 200 median 1.051 (0.697..1.168)
this PR 033c5bc 400 200 median 1.068 (0.668..1.627)
Medians 1.6% apart, inside the noise of this box. The tail is longer on the branch
(1.627 against 1.168) and I would not read anything into it from 21 samples. Unmeasured
dimension: the loop is per projection, and I tested one. Several projections on one table
multiply the walk.
My first attempt at that measurement was vacuous and I nearly published it. I called
pgcolumnar.create_projection, which does not exist — the function is add_projection —
and my fallback chain swallowed the error, so I measured a table with NO projection and got
a clean "no regression" from a code path that never ran. The premise check that caught it
(projection row groups >= 1) is in the numbers above.
Verified rather than read
- The premise that a base scan need not pay for projection pages: the projections live in
the same file, sosmgrnblocksgenuinely covers both, and subtracting is the right shape. projectionId == 0andprojStorageId == baseStorageIdare both skipped, so the base is
not subtracted from itself.- The no-projection case is a true no-op: the list is empty and
projPagesis 0. - Your suite's premises are real ones — the later plan still being a columnar scan, and
still not naming a covering projection, is what stops the ratio being about a different
plan.
jdatcmd
left a comment
There was a problem hiding this comment.
The product change is right and I reproduced your causation cell to the digit. One thing blocks it, and it is not in the code you wrote — it is a number that will be silently wrong the moment this merges.
The fix, re-run rather than read
the new suite on YOUR build (.so 9d5633408d33) 9 passed + 0 failed
projPages forced to 0 (.so c6b158069b39) 8 passed + 1 FAILED
-- before_run=22000 after_run=42000 ratio=1.909
FAIL a base scan is not priced from sibling projection pages:
got [inflated ratio=1.909] want [stable]
1.909 is your number, from my build. The arm is causal, it names the ratio rather than counting, and the three premises above it are the right ones -- particularly the later plan is still a base columnar scan, without which the arm could pass by the planner quietly choosing something else.
What blocks it: cluster_tests will be wrong, and git will not say so
#1189 merged after you opened this and moved cluster_tests 462 -> 463 for its own new test. Your branch moves it 462 -> 463 too. Both sides now hold the same value, so git merges the line silently -- and the composed tree has both new tests.
Measured on a real compose of origin/main with this branch:
composed tree collects 464 cluster tests
main claims 463
this branch claims 463
merged file claims 463 <- no conflict, no warning
So the cluster leg fails on --pgc-expect-tests 463 against 464 collected, on the first run after merge. This is the shape the file's own comments already record twice: two branches deriving the same number is the dangerous case, because nothing prompts anyone to look.
guard_tests is fine at 393 on both sides, and I re-derived it on the compose: still 393.
And the budget, where git DOES speak but neither value is right
main 1451
this branch 1454
merged: CONFLICT
re-derived on the compose 1459 <- never observed red
composed ledger rows 1484 (main 1475 + your 9)
Your comment block is exactly the right form -- it says re-counted, prints the command, and says suites_not_covered does not move. The number just predates two merges. Re-derive it on the rebased tree rather than adjusting it.
Two notes on the code, neither blocking
-
pgcolumnar_sibling_projection_pagesreads every projection's row-group list on everyestimate_sizecall, which is every plan of the table. On a table with several projections and many groups that is planning-time catalog work that was not there before. The comment says the no-projection case is a no-op, which is true and is the case that matters most; it would be worth a sentence saying what the cost is when there ARE projections, so the next person pricing a planner regression does not have to find it. -
nblocks = 1whenprojPages >= nblocksis a reasonable floor and matches theMax(nblocks, 1)below it, but it is the one branch no arm drives. A table whose projections outweigh the base is not exotic once several exist.
What I need before approving
Rebase onto main (currently a5c7d5d), then re-derive both numbers by running, not by adding:
cluster_tests by collection on the rebased tree (expect 464)
checks_never_observed_red awk -F'\t' '$5=="never"' test/check_ledger.tsv | wc -l
Everything else here I would take as it stands.
033c5bc to
f960ab4
Compare
|
@jdatcmd @OffgridwithJD rebased locally onto current What you asked for
Both halves now use that band. Causation on PG18 with Control after restore: Also documented the planning-time walk when projections exist, and the floor when Please re-review. Not merged. Not self-approved. |
|
One more thing for whoever sequences this against #1155, raised by @OffgridwithJD and worth having on the thread rather than in a message: whichever of #1155 and this one lands second has to re-derive its numbers, not just rebase. This PR changes That is the same hazard as the Nothing to do here if this lands first. If #1155 lands first, this PR's |
Projections share the relation file, so rel->pages was smgrnblocks of base plus every projection. A base scan reads only the base storage. Co-authored-by: Cursor <cursoragent@cursor.com>
harness_selftest 540 refuses two-constant awk verdicts. Passing branches stay the named verdict; failing branches now include the ratio. Co-authored-by: Cursor <cursoragent@cursor.com>
Over-subtraction passed a one-sided ceiling. Require 0.8..1.25, document the clamp and planning walk, and re-count cluster_tests and the never census on current main. Co-authored-by: Cursor <cursoragent@cursor.com>
Main moved past a5c7d5d (commandprompt#1193). Re-derive checks_never_observed_red by counting field 5 on the rebased ledger; do not carry 1459. Co-authored-by: Cursor <cursoragent@cursor.com>
f960ab4 to
baab535
Compare
|
@jdatcmd @OffgridwithJD rebased locally onto current What you asked for
Main moved past
Still present on both twins after the rebase (no product change this round). Green on the reseated tree (PG18, this session)Shell Pytest Please re-review. Not merged. Not self-approved. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Re-review of baab535. The guard is two-sided now, and I confirmed it by re-running the mutation that proved the old one blind.
projPages * 2 in pgcolumnar_sibling_projection_pages, which over-subtracts and makes the base scan look almost free:
| previous head | baab535 |
|
|---|---|---|
| result | 9 passed + 0 failed |
8 passed + 1 failed |
| the arm | passed at ratio 0.091 | moved ratio=0.091 |
Control on the same build, unmutated: 9 passed + 0 failed, before_run=22000 after_run=22000 ratio=1.000. .so fingerprints differ across the two arms (8d3233de7015 against 28d54001e297), source restored, git status clean.
print (r+0 > 1.25 || r+0 < 0.8) ? "moved ratio=" r : "stable"
A ceiling became a band, and the band is the right one: the property is that adding a covering projection does not change what a BASE scan costs, so the expected ratio is 1 and a departure in either direction is the finding. Over-subtracting is the more dangerous direction, because it makes the base scan cheap and wins it plans it should lose, and that is exactly the direction the old arm could not see.
premise: adding the projection enlarged the relation file is the right premise to carry alongside it — without it, a projection that failed to materialise would leave the ratio at 1 and the arm green over nothing.
No outstanding objection from me on the substance. Holding the approval only because the head is moving: rebase onto main and I will approve on the new head, for the reason I gave on #1155 — an approval binds to whatever HEAD is current and this repository does not dismiss stale reviews, so approving now would leave my sign-off standing on commits I have not read.
Verified in container pgcolumnar-audit, PG 18.4 assert build, at baab535.
jdatcmd
left a comment
There was a problem hiding this comment.
Approving baab535. My blocking ask was a number, and I re-derived all three myself rather than reading your commit message.
The numbers, derived on your tree
The branch's merge base is 133c3fb, which is current main, so the branch tree IS the compose and there is nothing left to guess about:
| value | your file says | I derived |
|---|---|---|
cluster_tests |
464 | 464, by collection over the 46 files that are not in NO_CLUSTER |
guard_tests |
398 | 398, by collection over the NO_CLUSTER list |
checks_never_observed_red |
1461 | 1461, by awk -F'\t' '$5=="never"' |
That closes the 463-vs-464 silent merge I raised: the line now says what the tree collects.
One thing to watch at merge time, not a change request. #1198 also moves cluster_tests to 464, for its own new test. If both merge, both sides hold the same value, git says nothing, and the composed tree collects 465. Whichever of the two goes second has to re-derive rather than keep the line. That is the same shape as the one this PR just fixed, one merge later.
The approval transfers across the rebase, and here is why
Your product change moved since I reviewed 033c5bc, so I checked what moved instead of assuming. With comments and blank lines stripped, the two versions of src/columnar_tableam.c differ by exactly two lines:
561a562
> {
562a564
> }
Braces around the single nblocks = 1; statement. So the causation cell I ran on 033c5bc still describes this head:
the suite on your build 9 passed + 0 failed
projPages forced to 0 (.so c6b158069b39) 8 passed + 1 FAILED
-- before_run=22000 after_run=42000 ratio=1.909
Both of my notes were answered, and the band is better than what I asked for
The pgcolumnar_sibling_projection_pages comment now states the planning-time cost when projections exist, which is what I wanted: the next person pricing a planner regression will not have to find it.
Banding the ratio on both sides rather than capping it is the stronger fix. Over-subtraction is exactly the failure this change newly makes reachable, and r > 1.25 alone would have greened it. Both halves carry the band and both carry the ratio in the failing branch.
The floor still has no arm, and I tried to build one
Your comment says stale or orphaned projection row groups, a rewrite, or PAGE_ROUND_UP can reach nblocks = 1. I took the last one at its word and built the case most likely to reach it, a three-row table where one rounded-up projection page is a large share of the file:
relpages 4, file blocks 4, after add_projection:
Seq Scan on tiny (cost=0.00..3.03 rows=3 width=0)
The estimate came out at 3 pages, so projPages was 1 and the floor was not reached. I could not construct the case, which is why I am approving rather than asking again. If you can name a fixture that reaches it, the arm is cheap and it is a carve-out like any other. If you cannot either, then the comment is the honest record and it is already there.
CI is 15 of 15 with nothing failing.
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving baab535. 15 checks, all SUCCESS, CLEAN, and the condition I stated is met without a rebase: behind_by=0, status=ahead against current main 133c3fb, so the branch tree already is the compose. I verified that from the compare API rather than taking it on report.
The guard is two-sided now, and I confirmed it by re-running the mutation that proved the old one blind — projPages * 2 in pgcolumnar_sibling_projection_pages, which over-subtracts and makes the base scan look almost free:
| previous head | baab535 |
|
|---|---|---|
| result | 9 passed + 0 failed |
8 passed + 1 failed |
| the arm | passed at ratio 0.091 | moved ratio=0.091 |
Control, unmutated: 9 passed + 0 failed, before_run=22000 after_run=22000 ratio=1.000, .so 8d3233de7015 against the mutant's 28d54001e297, source restored and git status clean.
print (r+0 > 1.25 || r+0 < 0.8) ? "moved ratio=" r : "stable"
That is the right band rather than a tighter ceiling: the property is that adding a covering projection does not change what a BASE scan costs, so the expected ratio is 1 and a departure either way is the finding. Over-subtracting is the more dangerous direction — it makes the base scan cheap and wins it plans it should lose — and it is exactly the direction the old arm could not see.
premise: adding the projection enlarged the relation file is the right companion. Without it a projection that failed to materialise would leave the ratio at 1 and the arm green over nothing.
One thing for whoever merges this and #1155, since the two touch the same number from opposite sides: #1180 changes rel->pages, and #1155's arms are now derived so that they do not mention it — want_run comes from sum(ceil(byte_length/(BLCKSZ - SizeOfPageHeaderData))) in the catalog and its miss arm compares against a MEASURED base_run rather than pg_relation_size. I checked that deliberately, because a band built on the file size would have gone red on a correct tree once this lands. It does not.
Merging remains @jdatcmd's call.
…ommandprompt#1155) The miss arm divided a plan cost measured AFTER the fixture clears proj_storage_id by one measured BEFORE it. Those are two different catalog states, and commandprompt#1180 made them two different prices. composed with main carrying commandprompt#1180, before this change: -- miss_run=41991.6 base_run=22000 miss_ratio=1.909 FAIL after: -- miss_run=41991.6 base_run=42000 miss_ratio=1.000 9 checks, PASSED THE COUPLING IS A CATALOG FIELD, not a file or a shared identifier. The suite clears `proj_storage_id` to simulate a lost projection; commandprompt#1180's sibling-pages walk READS that same key. So base_run was priced with the projection's twenty pages subtracted and miss_run was priced from the whole forty-two, and 42/22 is 1.909. Before commandprompt#1180 both states gave rel->pages = 42 and the ratio was exactly 1.000, so nothing here could have noticed. The comment claimed the property that failed -- "oracle for the miss-arm band, independent of how rel->pages is computed (survives commandprompt#1180)". That is TRUE of the covering arm, whose want_run is built from the catalog and never mentions rel->pages, and FALSE of this one, whose oracle is a measured plan cost. Both halves now say which arm it is true of. AND THE REORDER BOUGHT NO BLINDNESS, which is the thing moving a read can cost. With `pgcolumnar_projection_pages` mutated to always return fallbackPages: FAIL a covering projection is not priced from the base table's pages: got [off-band got=21995.6 want=20000] want [proj-pages] restored: 9 passed + 0 failed Rebased onto e2638b7 and every tracked number re-derived on the composed tree rather than carried across the rebase: cluster_tests 464 -> 468 collection, 48 cluster files guard_tests 398 -> 403 main's, untouched by this branch checks_never_observed_red 1460 -> 1499 awk over the ledger suites_not_covered 249 unchanged check_ledger.tsv conflicted and was resolved ADDITIVELY, both sides' rows kept, then checked rather than trusted: 1523 rows, zero duplicate (suite, part, check) keys. TESTS.md rebuilt from main with this branch's section re-applied once, so the diff carries no deletions, and its number DERIVED as max + 1: main's highest is 76, so test_projection_scan_io.py is 77 and not the 74 it was written as. 77 sections, 77 TOC entries, 77 of 77 pairing on number AND title, contiguous, zero bad anchors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
…pt#1155) Original work by @linuxhikerpm; rebuilt on ee52910 by @jdatcmd after five PRs landed under it, with the review fix and the census re-derived. THE CHANGE. rel->pages is the whole relation file, base plus every projection, so a covering scan that reads only one projection's row groups was priced for pages it never touches. It is now priced from that projection's own pages, walked from the catalog, with rel->pages as the fallback when the lookup fails so a miss can never look cheaper than the base scan. THE REVIEW FIX. The miss arm divided a plan cost measured AFTER the fixture clears proj_storage_id by one measured BEFORE it. Those are two catalog states, and commandprompt#1180 made them two prices, because its sibling-pages walk reads that same key: before -- miss_run=41991.6 base_run=22000 miss_ratio=1.909 FAIL after -- miss_run=41991.6 base_run=42000 miss_ratio=1.000 9/0 The comment claimed the property that failed -- "independent of how rel->pages is computed (survives commandprompt#1180)" -- which is true of the covering arm, whose want_run comes from the catalog, and false of this one, whose oracle is a measured plan cost. Both halves now say which arm it is true of. THE REORDER BOUGHT NO BLINDNESS. With pgcolumnar_projection_pages mutated to always return fallbackPages the covering arm goes red at full strength (`off-band got=21995.6 want=20000`); restored, 9 passed. REBUILT RATHER THAN REPLAYED. A six-commit rebase conflicted on TESTS.md at every step and one attempt COMMITTED FOUR CONFLICT MARKERS before being caught, so the branch's own changes were applied to main file by file instead. src/columnar_customscan.c needed a real merge: commandprompt#1127 renamed `scale` to `projScale` in the same block this change rewrites, and the result keeps commandprompt#1127's name with this change's pricing. ALL THREE COUPLED SUITES PASS ON THE COMPOSED TREE, which is the point: projection_scan_io.sh 9/0 miss_ratio=1.000 projection_parallel.sh 9/0 (commandprompt#1127, shares the function) base_scan_io.sh 9/0 ratio=1.000 (commandprompt#1180, whose walk reads the key) CENSUS RE-DERIVED TWICE, once per rebase: cluster_tests 468 -> 469 collection, 49 cluster files guard_tests 403 main's, untouched checks_never_observed_red 1499 -> 1506 awk over the ledger suites_not_covered 249 unchanged check_ledger.tsv checked rather than trusted: 1535 rows, zero duplicate (suite, part, check) keys. TESTS.md rebuilt from main with the section applied once and its number derived as max + 1 -- 78, having been 74 and then 77 as main moved twice under it. 78 sections, 78 TOC entries, 78 of 78 pairing on number AND title, contiguous, zero bad anchors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
…pt#1155) Original work by @linuxhikerpm; rebuilt on the current main by @jdatcmd, with the review fix and the census re-derived. Third rebuild: every PR on this board touches the same four bookkeeping files, so merging any one makes the rest DIRTY. THE CHANGE. rel->pages is the whole relation file, base plus every projection, so a covering scan that reads only one projection's row groups was priced for pages it never touches. It is now priced from that projection's own pages, with rel->pages as the fallback when the lookup fails so a miss can never look cheaper than the base scan. THE REVIEW FIX. The miss arm divided a plan cost measured AFTER the fixture clears proj_storage_id by one measured BEFORE it -- two catalog states, which commandprompt#1180 made two prices because its sibling walk reads that same key: before miss_run=41991.6 base_run=22000 miss_ratio=1.909 FAIL after miss_run=41991.6 base_run=42000 miss_ratio=1.000 9/0 And the reorder bought no blindness: with pgcolumnar_projection_pages mutated to always return fallbackPages the covering arm goes red at full strength. THE COMMENT THIS CHANGE FALSIFIES IS CORRECTED RATHER THAN CARRIED. commandprompt#1127 wrote that the ioRunProj clamp is unreachable "with projRun = serialRun * projScale" and named "computed independently (for example from the projection's own pages)" as what would make it live. That is this change. It now records three states: the old premise is FALSE, reachability is UNPROVEN (@OffgridwithJD probed it, reached 3 and bound 0, then built a fixture that reached once and still did not bind), and the clamp stays because its absence allows a silently negative cpuRunProj -- a reason that survives whichever way reachability goes. cluster_tests re-derived on the composed tree: 476. It has read 464, 468, 469 and now 476 as main went 463, 467, 468, 475 under this branch. Every one was correct for the main of its hour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
Summary
smgrnblocksof the relation file. That file holds the base plus every projection, so adding a covering projection made the base scan look more expensive even though it still reads only the base storage.pgcolumnar_relation_estimate_sizenow subtracts the page-rounded footprints of sibling projections fromrel->pages. Tables with no extra projection keep the same page count.TDD
Unfixed (
.so36b11ded758f, source542af67fbc25):Pytest twin, own fixture (
bpages/onrid/ 30000 rows,seq_page_cost=2000):Fixed (
.soebf178546752, sourced0933fa017b3): both twinsratio=1.000, file still grew 180224 -> 344064. Shell 9/9 on PG 15.19, 16.15, 17.11, 18.6. Pytest 9/9 on PG18.Causation (
projPages = 0): same FAIL on both (.so44946c82b48e). Restored:.soebf178546752again, both green.cluster_tests462 -> 463 by collection.guard_tests393, unchanged.suites_not_coveredstays 249.Please review. I will not approve or merge this.
Test plan
test/base_scan_io.shon the matrixtest/pytest/test_base_scan_io.pyrel->pagesMade with Cursor