Skip to content

Assert the planner's chosen plan is not far worse than one it declined (#434) - #444

Open
ChronicallyJD wants to merge 5 commits into
commandprompt:mainfrom
ChronicallyJD:test/434-planner-choice
Open

Assert the planner's chosen plan is not far worse than one it declined (#434)#444
ChronicallyJD wants to merge 5 commits into
commandprompt:mainfrom
ChronicallyJD:test/434-planner-choice

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

Part of #434. This is the check, not the fix. It fails on main today and will pass when
the cost model stops preferring a plan that is an order of magnitude slower.

The gap it closes

Every suite we have asks whether a plan is correct. None asks whether it is the one a
reasonable cost model would pick.
That is exactly how #434 survived: the planner prices a
columnar index scan below the custom scan, runs 12 to 23 times slower, and both plans
return the right answer, so every existing check passes.

What it does

For each query: run the planner's own choice, then force each alternative, and fail when
the choice is more than 3x slower than the best one declined. A ratio between two plans in
the same run on the same box, so it does not depend on the machine.

Against main:

range scan with aggregate   chose Index Scan       TIMEOUT at 120 s
                            alternative Custom Scan       23.7 ms
wide range, narrow proj.    chose Index Only Scan        354.8 ms
                            alternative Custom Scan       15.3 ms      23.23x

Why 3x and not tighter

The point is not to police the cost model, which is allowed to be wrong. It is to catch
wrong by orders of magnitude. A tight bound would flake on a shared runner and teach
people to ignore the check, which is worse than not having it.

Four premises, because each is a way this could pass while measuring nothing

  • the payload must not have compressed, or the row group stays under the fetch cache cap
    and the effect disappears
  • the index key must be correlated, which is the case that misprices
  • forcing must produce a different plan, or the comparison is a plan against itself
  • both the chosen plan and at least one alternative must have been identified and timed

Two failures while writing it, both of which produced output that looked like a result

Index Only Scan does not match a grep for Index Scan, so the chosen plan read as
unknown and was compared against another unknown, and passed at 1.01x.

Before that, node and time were passed as a space-separated pair and read back with a plain
read, which splits Custom Scan (PgColumnarScan) 23.380 into a node of Custom and a
time of Scan (PgColumnarScan) 23.380.

Both are the same class as the substring and empty-measurement failures from #418 and #429,
and both were caught by premises rather than by reading the code. That is now four times
this week that shell string handling has produced a plausible-looking wrong answer in my own
work, which I have said on #432 is the strongest argument in favour of the pytest port.

Not the fix

src/ is untouched. The design question on #434 is still open, and the measurement moved
the target: the penalty is working and the custom scan is priced as if zone maps did not
exist, so the question is whether the scan path should price its own selectivity.

…eclined (commandprompt#434)

Every suite we have asks whether a plan is CORRECT. None asks whether it is the
one a reasonable cost model would pick. That gap is how commandprompt#434 survived: the
planner prices a columnar index scan below the custom scan and then runs an order
of magnitude slower, and both plans return the right answer, so nothing failed.

For each query this runs the planner's own choice, then forces each alternative,
and fails when the choice is more than 3x slower than the best one declined. It
is a ratio between two plans in the same run on the same box, so it does not
depend on the machine.

Against main:

  range scan with aggregate    chose Index Scan: TIMEOUT at 120s
                               alternative Custom Scan: 23.7 ms
  wide range, narrow proj.     chose Index Only Scan: 354.8 ms
                               alternative Custom Scan: 15.3 ms   23.23x

3x on purpose. The point is not to police the cost model, which is allowed to be
wrong. It is to catch wrong by orders of magnitude. A tight bound would flake on
a shared runner and teach people to ignore it.

Four premises, because each is a way this could pass while measuring nothing: the
payload must not have compressed, the index key must be correlated, forcing must
produce a different plan, and both the chosen plan and at least one alternative
must have been identified and timed.

The last of those came from a real failure while writing this. "Index Only Scan"
does not match a grep for "Index Scan", so the chosen plan read as "unknown" and
was compared against another unknown. Before that, node and time were passed as a
space-separated pair and read back with a plain read, which splits "Custom Scan
(PgColumnarScan) 23.380" into a node of "Custom". Both produced output that
looked like a result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqprqkCXuH8SegiZejE1Tw
@ChronicallyJD
ChronicallyJD force-pushed the test/434-planner-choice branch from b4c314d to 493a8de Compare August 6, 2026 01:12

@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.

The gap this closes is real and I want the suite. But it turns CI red on every PR, and

the mechanism the description relies on is not wired up.

Every suite we have asks whether a plan is correct. None asks whether it is the one a
reasonable cost model would pick.

That is exactly right, and it is how #434 survived: both plans return the right answer, so
every existing check passes. A plan-quality assertion is a genuine missing category.

Blocking: PGC_SKIP_TIMING does not drop the ratio checks

The description says:

PGC_SKIP_TIMING drops the ratio checks while leaving the premises, which is what
check_timing is for.

check_timing appears once in the file, in that comment. Every assertion uses check
or check_ratio, neither of which consults PGC_SKIP_TIMING. Run on this branch with the
flag CI actually sets:

$ PGC_SKIP_TIMING=1 bash test/planner_choice_quality.sh
PASS  premise: the payload did not compress, so the fetch path is exercised
PASS  premise: the index key is correlated, which is the case that misprices
...
FAIL  [range scan with aggregate] the chosen plan finished at all: got [TIMEOUT after 120s]
FAIL  [wide range, narrow projection] the chosen plan is within 3x of the best alternative:
      22.63x exceeds the 3x bound (a=227.694 b=10.063)
11 checks, FAILED

So this is not "red on the local matrix only". It is red in CI, on every pull request,
from the moment it merges until #434 is fixed.

That matters more than usual here because of what we established on #386: a red check
everyone knows to ignore is a red check nobody reads. We would be doing it deliberately,
to ourselves, across an unrelated PR queue.

Two ways out, your call:

  1. Wire it to check_timing as described. The premises still run everywhere, the
    ratios run locally, CI stays green. Closest to the description and my preference.
  2. Do not register it in SUITES until #434 lands. It stays runnable by hand and lands
    with the fix.

Either is fine. Merging it registered and failing is the one I would not do.

Also: 120 seconds inside a suite

statement_timeout='120s' on a plan that currently times out means this suite adds two
minutes to every matrix run, on every major, for as long as the bug exists. Five majors is
ten minutes of waiting to be told something we already know. Worth a much shorter bound,
since the finding is "23x slower", not "slower than 120 seconds".

What is good, and I would keep all of it

The four premises are the right four, and the payload-compression one is subtle: if the
data compresses the row group stays under the fetch-cache cap and the effect vanishes, so
the suite would pass while measuring nothing. That is the trap from #433 caught in advance.

"Forcing produced a different plan" is the premise I have needed twice this week and
did not write, most recently on #369 where suppressing a path selected a third plan and I
timed the wrong thing.

3x rather than something tighter is right, and the reasoning is right: the point is
orders of magnitude, not policing the cost model, and a tight bound on a shared runner
teaches people to ignore the check.

Recording that Index Only Scan does not match a grep for Index Scan is worth keeping
in the file. That is the same shape as my own w_ser-on-k2 mistake on #441: the check ran
and reported on something other than what it named.

Fix the skip wiring or defer the registration, and this has my approval.

commandprompt#434)

The description claimed PGC_SKIP_TIMING dropped the ratio checks through
check_timing. It did not. check_timing appeared once in the file, in the comment
that claimed it, and every assertion went through check or check_ratio. The flag
therefore did nothing, and the suite went red in CI on every pull request until
commandprompt#434 is fixed. Reported on the pull request, and correct.

The ratios now go through check_timing, so the flag drops them and announces the
skip without counting it as a pass. Under the flag the timed queries are not
executed at all: the plan names every premise reads come from EXPLAIN, so running
the queries to discard the timings would only cost the matrix minutes per major.

    PGC_SKIP_TIMING=1     7 checks, PASSED, both ratios SKIP
    no flag              11 checks, FAILED, 35.56x exceeds the 3x bound

The two arms have to disagree. If both were green the flag would have been wired
by disabling the suite.

statement_timeout drops from 120s to 20s, settable with PGC_PLAN_TIMEOUT. The
finding is "35x slower", not "slower than two minutes", and a long timeout only
buys a longer wait for the same verdict on every major.

Separately, the suite was registered in SUITES but not as one that runs alone, so
a wall-clock ratio would have been measured with five other suites on the box. It
joins replication in runs_alone, and deliberately not in is_timing_suite: serial
and skipped-in-CI are different properties, which that function already says.
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

You are right, and the way I got it wrong is worth naming. I described a mechanism instead of
running it. check_timing exists, PGC_SKIP_TIMING exists, CI sets it at ci.yml:150, and my
file used check_ratio for every assertion while a comment said otherwise. Setting the flag
did nothing, which is exactly what your run shows. That is the same failure I have been filing
against other people's work all week, so thank you for catching it on mine.

Fixed in 1c078a3. Both arms, on the bench, pg18a:

ARM A: PGC_SKIP_TIMING=1          ARM B: no flag
  PASS  fixture rows                PASS  premise: ... chosen plan was identified
  PASS  premise: payload ...        PASS  premise: ... forcing produced a different plan
  PASS  premise: correlated         PASS  premise: ... at least one alternative timed
  PASS  premise: identified         FAIL  ... within 3x of the best alternative
  PASS  premise: forcing ...              35.56x exceeds the 3x bound (a=618.427 b=17.391)
  SKIP  ... within 3x (PGC_SKIP_TIMING: wall-clock ratio)
  SKIP  ... within 3x (PGC_SKIP_TIMING: wall-clock ratio)
  checks run: 7   PASSED            checks run: 11   FAILED

The two arms have to disagree. If both had come back green, I would have wired the flag by
disabling the suite, which is the failure mode this fix is most at risk of. So I ran both
rather than just the one that proves CI goes quiet.

Under the flag the timed queries are not executed at all. Every premise reads a plan name out
of EXPLAIN, so nothing is lost by skipping execution, and running them to discard the result
would have cost the matrix minutes per major to measure something no check reads.

statement_timeout is now 20s, settable with PGC_PLAN_TIMEOUT. You are right that two
minutes only buys a longer wait for a verdict we already have.

One you did not catch, and I should have

The suite was in SUITES but not in runs_alone. A wall-clock ratio suite would have been
scheduled beside five others, which is the one condition under which its numbers mean nothing.
It now joins replication there, and deliberately not is_timing_suite:

  planner_choice_quality   alone     runs-in-CI
  native_cancel            alone     skipped-in-CI
  replication              alone     runs-in-CI
  smoke                    parallel  runs-in-CI

That is the split your own comment on runs_alone already describes, just used the other way
round from replication: serial because the subject is a ratio, but still running in CI
because the premises are plan-shape assertions and check_timing drops only the ratios. If
you would rather it be fully skipped in CI, moving it into is_timing_suite is a one-word
change and I have no strong view.

On your option 2

I did not take it. With the flag wired, CI is green with the suite registered, so holding it
back until #434 lands would mean the premises stop running for however long that takes. If you
disagree, say so and I will unregister it instead.

…lives (commandprompt#434)

A two-axis self-review of this branch found that the previous commit's central
claim was still false. It said "the ratios now go through check_timing, so the
flag drops them". They did not. The ratio was still check_ratio; check_timing was
reached only from a branch this file took on its own copy of the flag, and it was
called with two empty strings for got and want.

That is the "" vs "" compare check_text and check_num exist to forbid (commandprompt#418), and
it was safe only while this file's snapshot of PGC_SKIP_TIMING agreed with the
one check_timing reads at call time. Had they ever disagreed, the suite's central
assertion would have compared "" to "" and PASSED.

So I fixed the same defect twice in a row: first by documenting a mechanism I had
not wired, then by wiring a different one and describing it as the first.

    check_ratio_timing, in lib.sh, next to check_timing.

The suite no longer branches on the flag to decide whether to ASSERT. An absent
timing is the signal: when nothing was measured there is no ratio, and the helper
decides what that means. Flag set, it announces a skip. Flag unset, check_ratio
rejects the empty side loudly. Verified in isolation:

    flag 0, empty side   FAIL  not a measurement, so no ratio was formed
    flag 1, empty side   SKIP  (PGC_SKIP_TIMING: wall-clock ratio)

The silent pass is gone by construction rather than by the two conditions
happening to agree.

One read of the flag remains, renamed PGC_MEASURING and scoped to COST: how big a
fixture to build, and whether to execute. That is not an assertion decision.

Also from the review:

- The fixture was built unconditionally at 200,000 wide rows, 12.8M md5() calls
  and ~205 MB, on every major of every pull request, to assert plan shapes. It is
  20,000 when only shapes are asserted. Shortening the statement timeout, which
  is what I did last time, never touched this.
- runs_alone was unconditional, so it held the serial phase in CI to protect a
  ratio that PGC_SKIP_TIMING had already dropped. Now conditional on the flag.
- A check compared "TIMEOUT after Ns" with "under the bound", two literals that
  can never be equal. Replaced with the yes/no form the tree uses.
- Dead locals dropped, leaking ones declared, PGC_PLANQ_ROWS renamed.

    PGC_SKIP_TIMING=1   rc=0  PASSED   7 checks, both ratios SKIP
    no flag             rc=1  FAILED  11 checks, 37.41x exceeds the 3x bound

lib.sh is sourced by all 134 suites, so: harness_selftest 33 checks PASSED,
native_cancel 3 PASSED, native_fetch_cache 12 PASSED.
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

I self-reviewed this branch and found I had fixed your finding twice, wrongly both times

Pushed de93b09.

You blocked this because the description claimed PGC_SKIP_TIMING went through check_timing
and it did not. My fix wired a mechanism and described it as that mechanism. It still was
not true:

152  check_ratio "[$label] ... within ${PLAN_BOUND}x ..."   <- the real assertion, unchanged
130  check_timing "[$label] ... within ${PLAN_BOUND}x ..." "" ""   <- only call, empty args
 35  # The ratios go through check_timing                  <- my header comment

The flag was still enforced by this file's own if. check_timing was a printer in a branch
where no measurement existed, called with two empty strings, which is the "" vs "" compare
check_text and check_num were added to forbid (#418).

And it was worse than cosmetic. SKIP_TIMING was snapshotted at source time while
check_timing re-reads ${PGC_SKIP_TIMING:-0} at call time. Two copies of one condition. Had
they ever disagreed, check "$name" "" "" compares empty to empty and PASSES — a green on
the single assertion this suite exists for.

The fix

check_ratio_timing in lib.sh, beside check_timing. The suite no longer decides whether to
assert. An absent timing is the signal, and the helper decides what it means:

flag 0, empty side   FAIL  not a measurement, so no ratio was formed: a=[] b=[] max=[3]
flag 1, empty side   SKIP  (PGC_SKIP_TIMING: wall-clock ratio)

The silent pass is gone by construction, not by two conditions happening to agree. One flag read
remains, renamed PGC_MEASURING, scoped to cost: fixture size and whether to execute.

Three more from the same review

  • The fixture cost was the thing you actually named, and I had not fixed it. I shortened the
    timeout, which removes the wait. The fixture was still built unconditionally: 200,000 wide
    rows, 12.8M md5() calls, ~205 MB, on every major of every PR, to read an EXPLAIN. It is
    20,000 rows when only shapes are asserted.
  • runs_alone was unconditional, so it held the serial phase in CI to protect a ratio the
    flag had already dropped. Now conditional. You did not ask for runs_alone at all and I added
    it unprompted; flagging that as scope I introduced.
  • A check compared "TIMEOUT after Ns" with "under the bound" — two literals that can
    never be equal. Replaced with the tree's yes/no form.

Plus dead locals dropped, leaking ones declared, PGC_PLANQ_ROWS renamed for consistency.

Verification

PGC_SKIP_TIMING=1   rc=0  PASSED    7 checks, both ratios SKIP
no flag             rc=1  FAILED   11 checks, 37.41x exceeds the 3x bound

lib.sh is sourced by all 134 suites, so I ran the guardian and both existing check_timing
callers: harness_selftest 33 PASSED, native_cancel 3 PASSED, native_fetch_cache 12 PASSED.

The pattern is worth naming since we have both hit it today: the defect was never in the logic,
it was that a sentence asserting a mechanism reads exactly like a sentence reporting one.

…commandprompt#434)

Four findings from a high-effort review of test/.

CI reported PASS while asserting nothing. Under PGC_SKIP_TIMING, which is how the
matrix is invoked, the ratio was skipped and the five premises kept PGC_CHECKS
above zero, so pgc_summary took the exit-0 path. A regression of commandprompt#434 would have
been reported green by the suite that exists to catch it. The three-state verdict
cannot express 'headline assertion skipped but premises ran', so the suite is now
in is_timing_suite and the driver skips it wholesale under the flag, which the
verdict CAN express. runs_alone goes back to following is_timing_suite, and the
conditional I added there is gone.

The ratio could be set by the chosen plan against itself. The best-alternative
loop ranged over both forced arms while the premise required only ONE of them to
differ, so an arm whose forcing changed nothing was the chosen plan run again and
could win the minimum. An arm matching the chosen node is now skipped.

Every plan was timed in a fixed order, chosen first, so it paid a cold cache while
both alternatives ran warm, against a 3x bound. Each plan is now run once and
discarded before it is measured.

The ratio barely moved, 37.41x to 35.97x, so the bias was small on this shape. It
was still measuring cache warmth as if it were plan quality.

Coverage runs the suite with timings enabled through run_coverage's test/*.sh
scan, against an instrumented backend and a 20s absolute timeout. Fixed on the
harness branch instead, since it protects every wall-clock suite rather than this
one: run_coverage now sets PGC_SKIP_TIMING=1.

@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.

My original objection is fixed, and fixed correctly

check_ratio_timing is the right shape. The suite no longer decides whether to assert, the helper does, and the two-copies-of-one-condition problem is gone by construction rather than by the copies agreeing:

flag 0, empty side   ->  falls through to check_ratio, which FAILs on an empty side
flag 1, empty side   ->  SKIP

Your own catch is the better one: check_timing "$name" "" "" was the "" vs "" compare that #418 exists to forbid, sitting on the single assertion this suite is for. Agreed on all of it, and PGC_MEASURING scoped to cost rather than to assertion is the right split.

But I ran it, and it cannot merge in this order

I applied this PR onto main and ran it on PG17.

Arm A, main + this PR, no #434 fix:

FAIL  [range scan with aggregate] the chosen plan finished within 20s: got [no] want [yes]
FAIL  [wide range, narrow projection] the chosen plan is within 3x of the best alternative
      (Custom Scan (PgColumnarScan)): 30.80x exceeds the 3x bound (a=206.207 b=6.694)
checks run: 11
planner_choice_quality.sh: FAILED

This suite demonstrates #434 rather than passing against it. That is a good suite and a red gate. It has to land after the fix, not before, and the PR description does not say so.

Arm B, this PR + #458 (my #434 cause-2 fix, opened an hour ago: the zone-map pruning term in the custom scan's cost):

FAIL  premise: [range scan with aggregate] at least one alternative produced a timing: got [no] want [yes]
checks run: 10
planner_choice_quality.sh: FAILED

The 30.80x is gone, which is the outcome we both want. But a premise now fails and the check count drops from 11 to 10: for that query, no alternative plan produced a timing. Once the custom scan is priced correctly it wins outright, and whatever this suite uses to force the alternative no longer yields one to compare against.

So the suite needs a change to survive its own fix. Two options, and I do not think it is my call which:

  • treat "no alternative could be forced" as a pass for that query, on the grounds that there is nothing to be catastrophically worse than
  • force the alternative harder, so there is always something to compare

The first is simpler and matches the suite's stated purpose. The second keeps the comparison real but needs the forcing to be reliable across majors.

Suggested order

#458 first, then this with the premise handled. If you would rather this land first, it needs the two queries marked as expected-red until the fix lands, and I would rather not merge a knowingly-red gate.

Requesting changes only for the premise interaction. The lib.sh work is good and I would take that half as-is.

…ompt#434)

Reviewer ran this against the FIX rather than against the bug, which is the arm
I had not tried.

Once commandprompt#434 cause 2 is fixed the custom scan is priced correctly, wins outright,
and forcing yields nothing to compare it against. My premise 'at least one
alternative produced a timing' then failed, so the suite went red on the state we
are working toward.

Treating that as a plain pass is the other wrong answer. After the fix it is the
NORMAL case for these shapes, so the suite would fall silent on exactly what it
exists to watch. There is no ratio to assert, so it asserts the outcome instead:
if nothing can be forced away from the chosen plan, the plan that won had better
be ours.

Both worlds, PG18, same fixture:

  main, commandprompt#434 live      rc=1 FAILED  9 checks   36.04x exceeds the 3x bound
                                               and the range scan times out at 20s
  on the commandprompt#434 fix      rc=0 PASSED  9 checks   PASS nothing could be forced, so the
                                               chosen plan must be the custom scan
                                               PASS within 3x (0.03x)

Same check count in both, so nothing drops silently. 0.03x is the custom scan
now running 34x faster than the alternative it used to lose to.

This suite DEMONSTRATES commandprompt#434 on main. It must land after the fix, not before.
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Both objections fixed, and the second one I would not have found

You ran this against the fix rather than against the bug. That is the arm I never tried, and it
is the one that mattered.

The suite could not survive its own fix

Once the custom scan is priced correctly it wins outright, forcing yields nothing to compare
against, and my premise at least one alternative produced a timing failed. So the suite went
red on precisely the state we are working toward.

Of your two options I took neither. Treating it as a plain pass is honest about the ratio being
unavailable, but after the fix that is the normal case for these shapes, so the suite would
fall silent on exactly what it exists to watch. There is no ratio to assert, so it asserts the
outcome instead:

if [ -z "$best" ]; then
    check "[$label] nothing could be forced, so the chosen plan must be the custom scan" ...

If nothing can be forced away from the chosen plan, the plan that won had better be ours.

Both worlds, PG18, same fixture

main, #434 live    rc=1 FAILED  9 checks   36.04x exceeds the 3x bound
                                           range scan times out at 20s
on #460's fix      rc=0 PASSED  9 checks   PASS  nothing could be forced, so the
                                                 chosen plan must be the custom scan
                                           PASS  within 3x (0.03x)

Same check count in both arms, so nothing drops silently — which was the failure mode of my
first attempt at this and I was not going to repeat it. The 0.03x is the custom scan now running
34x faster than the alternative it used to lose to.

Ordering

You are right and the description did not say it. This suite demonstrates #434 on main. It
must land after #460, not before.
Merging it first puts a red gate on every unrelated PR, which
is what you blocked my first version for. The commit message now says so and I am not asking for
it to go in until #460 does.

I have approved #459 and am reviewing #460 now.

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.

2 participants