Skip to content

fix: price a covering projection by what it can prune, not what it mentions - #1128

Merged
jdatcmd merged 1 commit into
commandprompt:mainfrom
OffgridwithJD:fix/1126-price-what-it-can-prune
Sep 18, 2026
Merged

jdatcmd merged 1 commit into
commandprompt:mainfrom
OffgridwithJD:fix/1126-price-what-it-can-prune

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Closes #1126. The remainder of #1107, found while reviewing it.

The defect

#1107 replaced a constant 0.5 with the selectivity of the clauses referencing
sortKey[0]. That fixed the case where the selectivity came from a different column
entirely. It left a narrower one: a single RestrictInfo that ORs a sort-key range with
a predicate on another column references the sort key
, so the membership test counted it
whole and credited the projection with a selectivity its sort order cannot deliver.

WHERE sk BETWEEN 1 AND 2000                  priced 0.100   earns it
WHERE sk BETWEEN 1 AND 2000 OR kind = 'odd'  priced 0.101   earns nothing

The second query prunes nothing and is slower than the base scan it undercuts tenfold:

                                   projection ON    projection OFF
Columnar Usable Skip Predicates:         0                0
Columnar Vectors Skipped:                0                0
Columnar Chunk Groups Read:             20               20   (of 20)
Columnar Vector Decodes:               120               80
Execution Time:                      2.653 ms         1.996 ms

20,000 rows, stripe_row_limit => 1000, scrambled physical order, projection sorted on
sk, kind = 'odd' where sk % 1000 = 0 so its rows sit in every stripe.

Not a regression from #1107. The old 0.5 also beat the base for this query and the
planner also chose the projection. What changed is how confidently.

The fix: ask the function that decides skipping

pgcolumnar_clause_to_scankey already answers "can this clause prune, and on which
column". It returns 0 for a BoolExpr — a BoolExpr is not an OpExpr and never becomes
a scan key — and it records sk_attno per key. Pricing now keeps a clause only when it
yields at least one key and every key it yields is on the sort key.

One definition of "can skip", shared by the price and the executor, rather than a second
one restated in the cost path. That is selftest 320's rule: a check that recomputes a rule
tests the world instead of the code. PgColumnarQualsExactlyKeyed already uses this exact
loop with a ScanKeyData scratch[2], so this is the established shape and not a new one.

Not gated on exact. The batch fold needs exactness because scan keys are its whole
row filter (#715); pruning does not. An anchored LIKE (#426) and an IN-list range (#704)
prune honestly, and gating on exactness would decline a projection that genuinely wins.

Red, then green, with both controls

before   -- unprunable OR ratio=0.101   prunable range ratio=0.100   IN-list ratio=0.125
         FAIL  a clause that mentions the sort key but cannot prune on it ...
         16 passed + 1 failed

after    -- unprunable OR ratio=1.000   prunable range ratio=0.100   IN-list ratio=0.125
         17 passed + 0 failed

The two controls are the point. Declining a projection that would have won costs a plan
and reddens nothing, so the silent direction ships beside the defect.

The arm was vacuous first, and that is why there is a floor premise. My initial fixture
used a 100-row range. At 20 stripes the one-stripe floor is 0.05, and both the fabricated
discount and the honest one price there — so a broken guard and a working one were
indistinguishable and the arm passed against the defect. The range is 10% now and
premise: the prunable range is priced above the one-stripe floor asserts it, so the arm
cannot quietly go vacuous again.

Mutation testing, including the part that found nothing

gate on `exact`                 -> IN-list arm reddens, ratio 0.125 -> 1.000   (control is load-bearing)
check only the first scan key   -> nothing reddens                             (untested insurance)

The second is recorded in the code comment as exactly that rather than claimed as a
property: no current clause shape writes keys on two different columns, so requiring every
key to be on the sort key is cheap insurance I cannot demonstrate the need for.

Verification

projection_scan_cost.sh   pg15a pg16a pg17a pg18a pg19a   rc=0, 17 records each
full matrix PG18          257 ran, 2 skipped, 0 incomplete   ALL VERSIONS PASSED  rc=0
full matrix PG19          259 ran, 0 skipped, 0 incomplete   ALL VERSIONS PASSED  rc=0
pytest twin               17 checks, 0 fail
docs_style.sh             47 checks, PASSED
guard_tests    374 collected / 374 stated
cluster_tests  418 collected / 418 stated

The two matrices ran on 0603216, the pre-reseat tree; the reseat onto 3a741ad carries
only #1124's test changes, which were themselves gated. The five-major preflight above ran
on the shipped tree.

Five ledger rows, seeded from one run per major merged in a single call, so every row
carries 15;16;17;18;19 and #1071's short-major warning stayed silent.

RESEATED onto main carrying #1124. Main states 1391, this branch stated 1382, and the
merged tree is neither. The ledger auto-merged silently while the budget conflicted
loudly, so the union was checked by KEY: 0 keys lost either side, 0 duplicates, 1407 rows.
checks_never_observed_red 1396, re-derived by COUNTING.

One note recorded in the budget file because it cost me a wrong number today:
check_ledger.tsv has no header row, so tail -n +2 drops a real row and reports one
short. The census command is unaffected because it skips nothing.

Interaction with #1127

#1127 reuses projScale rather than recomputing sel, so this fix reaches its parallel
covering path with no second edit. Had the cost model been copied, one of us would have
fixed one copy. Whichever of these lands second needs its census re-derived: each states
1396 against the current main, and both landing makes that 1401.

🤖 Generated with Claude Code

https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs

…ntions (commandprompt#1126)

commandprompt#1107 replaced a constant 0.5 with the selectivity of clauses referencing
sortKey[0]. That fixed the case where the selectivity came from a different
column. It left a narrower one: a single RestrictInfo that ORs a sort-key range
with a predicate on another column REFERENCES the sort key, so the membership
test counted it whole and credited the projection with a selectivity its sort
order cannot deliver.

    WHERE sk BETWEEN 1 AND 2000                  priced 0.100   earns it
    WHERE sk BETWEEN 1 AND 2000 OR kind = 'odd'  priced 0.101   earns nothing

The second prunes nothing and is slower than the base scan it undercuts tenfold:
0 usable skip predicates, 0 vectors skipped, all 20 chunk groups read, 120 vector
decodes against 80, 2.653 ms against 1.996 ms.

ASK THE FUNCTION THAT DECIDES SKIPPING. pgcolumnar_clause_to_scankey already
answers "can this clause prune, and on which column": it returns 0 for a BoolExpr,
because a BoolExpr is not an OpExpr and never becomes a scan key, and it records
sk_attno per key. Pricing now keeps a clause only when it yields at least one key
and every key it yields is on the sort key. One definition of "can skip", shared
by the price and the executor, rather than a second one restated in the cost path.

NOT GATED ON exact. The batch fold needs exactness because scan keys are its whole
row filter (commandprompt#715); pruning does not. An anchored LIKE (commandprompt#426) and an IN-list range
(commandprompt#704) prune honestly, and gating on exactness would decline a projection that
genuinely wins. That is the silent direction, so its control ships beside the arm:
mutating the gate onto exact reddens the IN-list arm exactly as intended.

THE FIXTURE HAD TO CLEAR THE ONE-STRIPE FLOOR. The first version of the arm used a
100-row range; at 20 stripes the floor is 0.05 and both the fabricated discount and
the honest one price there, so a broken guard and a working one were
indistinguishable and the arm passed against the defect. The range is 10% now and a
premise asserts it is above the floor, so the arm cannot quietly go vacuous again.

Checking only the first scan key rather than every key reddens nothing, because no
current clause shape writes keys on two columns. That is recorded in the comment as
untested insurance rather than claimed as a property.

THE PARITY TOOL CAUGHT THE PORT BEFORE CI DID, on three of the five new names. This
suite is declared one-for-one with its pytest twin, so a property has to be asserted
under the SAME name on both sides, and I had written three of them differently:

    MISSING  premise: the prunable range is priced above the one-stripe floor, so the arms differ
    extra    premise: the prunable range is priced above the one-stripe floor

The bash names are canonical here because the ledger rows were seeded from a bash
run, so the port adopts them rather than the reverse. Renaming the ledger side would
have meant re-seeding five rows across five majors to fix a typo.

NOT A REGRESSION FROM commandprompt#1107: the old 0.5 also beat the base for this query and the
planner also chose the projection. What changed is how confidently.

Five ledger rows, seeded from one run per major merged in a single call, all
carrying 15;16;17;18;19. Census re-derived by counting on the merged tree: 1396.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
@OffgridwithJD
OffgridwithJD force-pushed the fix/1126-price-what-it-can-prune branch from ec2c719 to 08fc6ea Compare September 18, 2026 18:17
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Pushed 08fc6ea. CI caught a real defect in my first push and I am recording it rather than quietly fixing it.

pytest (harness guards, no database) was red on ec2c719:

FAILED test_compare_to_bash.py::test_the_ported_suites_in_this_tree_are_graded_one_for_one
  got  ... projection_scan_cost=1 ...
  want ... projection_scan_cost=0 ...

This suite is declared one-for-one with its pytest twin, so a property has to be asserted under the same name on both sides. I wrote three of the five new names differently:

MISSING  premise: the prunable range is priced above the one-stripe floor, so the arms differ
extra    premise: the prunable range is priced above the one-stripe floor

MISSING  a clause that mentions the sort key but cannot prune on it does not cheapen a covering projection
extra    a clause mentioning the sort key it cannot prune on earns no discount

MISSING  and an IN-list on the sort key keeps its discount, which gating on exactness would lose
extra    and an IN-list on the sort key keeps its discount

The bash names are canonical here, and for a reason worth stating: the ledger rows were seeded from a bash run across five majors, so the names on that side are already committed to check_ledger.tsv. Renaming the ledger side to match a nicer pytest name would mean re-seeding five rows on five majors to fix a wording preference. The port adopts the bash names.

After the alignment, on the committed tree rather than the working one:

compare_to_bash   literal matches: 17 | missing: 0 | VERDICT: every bash property is covered
guard half        380 passed, 1015 checks, 0 fail
pytest twin       17 checks, 0 fail
shell suite       17 passed, 0 failed, unprunable OR ratio=1.000

Why I did not catch it locally: I ran the guard half before the reseat onto 3a741ad and only re-derived the census afterwards. The parity arm was green on the pre-reseat tree and the reseat is what made it reachable. Running the half I had already run, after the rebase, would have found it in thirteen seconds.

The two full matrices and the five-major seed in the PR body were run on the pre-reseat tree and are unaffected by this: the change is three assertion names in the Python port, and the C, the shell suite and the ledger rows are byte-identical.

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I reproduced the red independently rather than reading the before/after block.

The removal proof, run here

I dropped this PR's test/projection_scan_cost.sh onto unfixed main (6ceb7dc,
.so md5 2da74f8d3630) under PG17 and ran it:

-- unprunable OR ratio=0.101   prunable range ratio=0.100   IN-list ratio=0.125
PASS  premise: every unprunable-clause scan has a positive run cost
PASS  premise: the prunable range is priced above the one-stripe floor, so the arms differ
FAIL  a clause that mentions the sort key but cannot prune on it does not cheapen a covering projection: got [cheap] want [not-cheap]
PASS  while a plain range on the sort key still earns its discount
PASS  and an IN-list on the sort key keeps its discount, which gating on exactness would lose
accounting: 16 passed + 1 failed + 0 unrunnable + 0 skipped = 17

Your three ratios reproduce to the digit, and exactly one arm fails. Both premises
pass in the red state, so neither is carrying the failure, and both controls stay green
where controls should.

What I checked in the C rather than took

  • pgcolumnar_clause_to_scankey really does return 0 for the OR: if (!IsA(clause, OpExpr)) return 0; at columnar_customscan.c:980, and a BoolExpr is neither that
    nor the ScalarArrayOpExpr handled just above it. The fix's central claim holds.
  • ScanKeyData scratch[2] is the documented maximum, not a guess. PgColumnarBuildScanKeys
    allocates 2 * list_length(qual) on the same reasoning (:1103-1105), the SAOP path
    returns 2 (:943), and PgColumnarQualsExactlyKeyed:1141 already uses the identical
    scratch[2] with the identical comment. This is the established shape, as you said.
  • The five ledger rows each carry 15;16;17;18;19.

The vacuity I went looking for and did not find

The OR query references kind. Had the projection simply not been offered for it — not
covering — the arm would read 1.000 and pass without the pricing fix ever running. Your
red run rules that out: at 0.101 the projection was demonstrably chosen and priced. Worth
noting because it is the one way this arm could have gone quiet, and it is the
floor-vacuity you already caught wearing a different hat.

Recording the parity failure and its cause in the thread, rather than folding it into a
quiet push, is the part that made this quick to review.

@jdatcmd
jdatcmd merged commit a95c360 into commandprompt:main Sep 18, 2026
14 checks passed
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.

A projection is priced by clauses that merely mention its sort key, not by clauses it can prune on

2 participants