fix: price a covering projection scan from its own pages - #1155
linuxhikerpm wants to merge 1 commit into
Conversation
The covering path inherited seq_page_cost * rel->pages, the whole relation file. Charge I/O from the projection's own row groups. Co-authored-by: Cursor <cursoragent@cursor.com>
|
TDD excerpts from this session. Prior chat summaries were not used as evidence. Start SHA Shell
|
OffgridwithJD
left a comment
There was a problem hiding this comment.
Your test work is verified, not read. I ran your removal proof here and it reproduces
exactly the numbers your description claims:
CONTROL cover_run=19996.0 rel_pages=42.0 base_io=42000.0 ratio=0.476 .so 245cd2b659f1
MUTATED cover_run=41991.6 rel_pages=42.0 base_io=42000.0 ratio=1.000 .so 7fcfd0357aba
a covering projection is not priced from the base table's pages:
got 'base-pages' want 'proj-pages'
Different .so per cell, so each measured its own binary. Six ledger rows across all five
majors, both twins with matching names and neither reading the other, cluster_tests
436 → 437 which matches a collection here. Everything you were asked for last time is
present without being asked again.
One blocker, in the C rather than the tests, and it is four lines.
A projection the lookup cannot find is priced at one page
if (projSid == 0)
return 1;That is a fail-OPEN default. One page is essentially free, so ioProj collapses to
nothing, the covering path becomes the cheapest thing available, and the planner takes it
— on the strength of a lookup that just failed. The direction is exactly backwards: a
lookup failure should make the path look expensive, not free.
It should not happen, I agree — the path is only built when a covering projection was
found. But "should not happen" is the state that gets reached by a route nobody modelled,
and the cost of being wrong here is silent: the plan changes, nothing errors, and the
number that caused it is unreachable from SQL. rel->pages as the fallback keeps the old
behaviour for that case and cannot make the path look better than the base scan; an
elog(ERROR) would also be defensible since you believe it unreachable. Either is fine —
returning 1 is the one I would not ship.
Same shape one line down:
if (pages < 1)
pages = 1;That one I would keep: a real projection occupying less than a page is genuinely close to
free, and the clamp is arithmetic rather than an error path. Worth a word in the comment
saying the two 1s mean different things, because they read identically.
A question rather than a blocker: this scans every row group at plan time
pgcolumnar_projection_pages calls PgColumnarReadRowGroupList and sums every group, on
every planning of a relation with a covering projection. There is explicit precedent
against that in the tree, in pgcolumnar_written_stripe_row_limit:
The exact quantity is the real group count, and reading it is a scan proportional to the
number of groups on every plan, which is too much to spend refining a term that is
approximate by construction.
Your case is not identical — that comment is about refining an approximate term, while
this is the I/O estimate itself, so the trade may well be worth it. But the two decisions
now point opposite ways in the same planner for the same reason, and whoever reads them
next deserves to know that was considered rather than missed. I would put a sentence in
your function's header saying why this one earns the scan, and I would want @jdatcmd's
view on the planning cost on a table with many row groups, since that is an owner call
rather than mine.
Smaller
Your description says cluster_tests 437 "by collection on current main (18821ce)". 437
is on your branch; main is 436. The number is right and the derivation is right — only the
sentence attributes it to the wrong tree, and I mention it because a reader checking your
work against main will find 436 and wonder which of you is wrong.
CI is UNSTABLE, 2 of 14 outstanding, so I would not have approved this round regardless.
Fix the fallback and I will re-run the proof against the new head.
|
Your two red legs are one defect, and the fix will change under you unless you rebase first. Both, plus the numbers you will need, below. The two failures are the same assertionThe second leg runs the guard half in a subprocess and asserts it exits 0. It did not, because of the first. So there is one thing to fix, not two — the cluster leg goes green on its own when the guard leg does. Worth saying because two red legs naturally read as two problems, and chasing the second one leads nowhere. The defect itself: But do not fix the number yet — rebase first
So on current Your branch carries cd test/pytest
F="$(python3 -c 'import sys, pathlib; sys.path.insert(0, "."); from test_harness_deps import NO_CLUSTER;
print(" ".join(p.name for p in sorted(pathlib.Path(".").glob("test_*.py")) if p.name not in NO_CLUSTER))')"
PYTHONPATH=. pytest --collect-only -q --pg-config <your pg_config> $F | tail -1Re-derive Your new section will be 70, and it needs both the heading and the contents entry — that is the pair the guard checks. Running the guard corpus locally, which would have caught bothNeither leg is reachable from python3 -m venv /tmp/pgcvenv
/tmp/pgcvenv/bin/pip install -q $(grep -E '^(pytest|pytest-xdist)==' test/pytest/requirements-test.txt)
cd test/pytest
G="$(python3 -c 'import sys; sys.path.insert(0, "."); from test_harness_deps import NO_CLUSTER; print(" ".join(NO_CLUSTER))')"
PYTHONPATH=. /tmp/pgcvenv/bin/pytest -q --pgc-expect-tests "$(awk '$1=="guard_tests"{print $2}' expected_tests.txt)" $GThat needs no cluster and no driver, and it runs in about twelve seconds. It reproduces the first failure exactly. The C blocker is unchanged@OffgridwithJD's Your test work was verified rather than read — they reproduced your removal proof with a different 🤖 Generated with Claude Code |
|
Updating the numbers in my previous comment —
So your new section is 71, not 70, and the The diagnosis itself is unchanged and is still the whole of what is wrong: Fix the contents entry and both legs go green. Derive the counts with the recipe rather than from my figures — I have now had two sets go stale inside an hour, and that is exactly why the file says re-derive rather than carry. 🤖 Generated with Claude Code |
Summary
seq_page_cost * rel->pages, the whole relation file (base plus every projection). The covering path now charges I/O from that projection's own page-rounded row groups, times the already-floored sort-key selectivity.test/projection_scan_io.shandtest/pytest/test_projection_scan_io.py) pin the planner ratio on the public EXPLAIN-cost seam. Own fixtures, matching assertion names. Neither file reads the other.rel->pagesreddens the load-bearing arm the same way the unfixed tree did.suites_not_coveredstays 249.cluster_tests437 by collection on current main (18821ce).Not merged. Not self-approved.
Test plan
test/projection_scan_io.shon PG18: covering run 19996 against base-page I/O 42000 (ratio 0.476), not 41991.6 / 1.000test/pytest/test_projection_scan_io.pyon PG18, same assertion names, own tableioProjback torel->pagesfails both twinsgot [base-pages] want [proj-pages]15;16;17;18;19; census counted, not added