Skip to content

feat: report how many pushed-down filters can actually prune (#479) - #484

Merged
jdatcmd merged 1 commit into
mainfrom
fix/479-usable-skip-predicates
Aug 7, 2026
Merged

feat: report how many pushed-down filters can actually prune (#479)#484
jdatcmd merged 1 commit into
mainfrom
fix/479-usable-skip-predicates

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #479.

Columnar Pushed-Down Filters counts the scan keys the scan was handed. The
reader then drops any it cannot evaluate against the stored min/max, and a
dropped key excludes no chunk group at all. Nothing in the plan showed the
difference, which is how #477 stayed invisible for a year and how
test/zonemap_cost.sh validated #460's cost discount against a fixture that
pruned zero groups.

EXPLAIN (ANALYZE) now also reports Columnar Usable Skip Predicates:

Columnar Pushed-Down Filters: 1
Columnar Usable Skip Predicates: 0
Columnar Chunk Groups Removed by Filter: 0

1 and 0 together say the thing that could not be said before: the key
reached the reader, and the reader could not use it.

The shape, and why

Option 2 from the issue, chosen by the owner, and all three emitters rather
than the one the issue names.

The existing line is unchanged. It carries the #191 signal that
pgcolumnar.enable_qual_pushdown took effect, which reporting only the usable
count would lose, and #191 is the evidence that both questions get asked.
Keeping it also means nothing that parses these lines changes:
test/pushdown_report.sh and test/audit.sh:200 both do.

Under FORMAT JSON the new line is an ordinary integer property, verified:

"Columnar Pushed-Down Filters": 1,
"Columnar Usable Skip Predicates": 0,

A 1 (0 usable) qualifier on the existing line, option 3, could not have been.

All three nodes that print the original line print the new one: the scalar
custom scan and both vectorized aggregate nodes, which fill npreds from
PgColumnarCountConvertibleQuals and have exactly the same gap under it.
Fixing one would leave a line of plan text meaning two different things
depending on which node ran. The aggregate nodes capture the count beside their
existing stats snapshot, because the read state is ended before EXPLAIN runs.

The new line needs ANALYZE, matching its neighbours: it describes the run, not
the plan. It carries no enable_qual_pushdown ternary, because with the setting
off the reader builds no predicates and the count is already 0.

The fixture, which the issue got wrong

The issue proposed a same-type predicate on a non-btree-comparable column. That
cannot show the gap: pgcolumnar_clause_to_scankey rejects such a column
outright, so no scan key is built and both numbers read 0.

The shape that survives #478 is a domain column. GetDefaultOpClass
resolves a domain to its base type, so the key is built; make_predicates then
sees the column as the domain and the constant as the base, calls it cross-type,
and finds no BTORDER proc for that pair. Measured on PG17, 200,000 rows in 20
groups, the same values in each column:

column predicate chunk groups removed
plain int > 190000 19 of 20
b bigint > 190000 19 of 20
d acct (DOMAIN AS int) > 190000 0 of 20

All three reported Columnar Pushed-Down Filters: 1.

A domain column ought to prune as its base type does, and that it does not is
a separate defect: #483.
This PR does not fix it, and uses it as the fixture.
test/pushdown_report.sh pins it as an assertion rather than an echo, so
fixing #483 turns that check red on purpose and forces whoever fixes it to
supply a new unusable fixture rather than delete the section.

Tests

Six checks per node shape, in test/pushdown_report.sh, which is already the
suite for this line and already registered, so this adds no SUITES entry and
cannot conflict with anyone else's PR.

Written one slice at a time: each node's check went red first, then green on its
own implementation. The red runs are the proof the line is load-bearing.

Proved by removal, not asserted. With all three lines rewired to report the
built-key count instead of the reader's, four checks fail:

FAIL  the unusable arm reports none: got [1] want [0]
FAIL  pushdown off reports no usable skip predicates: got [1] want [0]
FAIL  ungrouped: and the usable skip predicates tell them apart: got [1/1] want [1/0]
FAIL  grouped: and the usable skip predicates tell them apart: got [1/1] want [1/0]

They fail reporting 1 where 0 is required, which is the defect itself rather
than a missing line. The armed and restored .so were fingerprinted
(6b6fb6f9 fixed, 1cb1e9c8 armed, 6b6fb6f9 restored) so the proof could not
have run against the fixed binary, per the trap recorded in HANDOFF.

Every arm asserts its own node fired before reading a number out of its plan
(Columnar Vectorized Aggregates and Columnar Vectorized Group Keys are those
nodes' own markers, so a fallback to core Agg cannot satisfy them), and pairs
each reported number with the physical fact underneath it. That is the #479
lesson applied to #479's own test: a suite asserting a derived relation with no
physical fact under it can stay green for its whole life while measuring nothing.

Verification

  • pushdown_report 31/31, and 18 further suites that parse these EXPLAIN lines:
    audit, zonemap_cost, native_vecskip, ungrouped_vector_agg,
    native_groupagg, parallel_vector_agg, native_skip, native_agg,
    native_agg_addcolumn, phase5, write_minmax_fastpath, native_sort_by,
    native_cluster, native_bloom, native_recluster, column_projection,
    planner_choice_quality, harness_selftest. All pass.
    (audit and phase5 print their own summary format rather than the standard
    one; both were re-run individually for exit code and FAIL count rather than
    read as passing from a missing line.)
  • docs_style passes. The user-guide addition failed it first on three
    sentences over the 25-word STE limit and was rewritten.
  • Full PG15 through PG19 matrix: result posted below when it completes.

🤖 Generated with Claude Code

"Columnar Pushed-Down Filters" counts the scan keys the scan was HANDED.
The reader then converts each into a skip predicate and drops any it cannot
evaluate against the stored min/max, and a dropped key excludes no chunk
group at all. Nothing in the plan showed the difference.

That is how #477 stayed invisible for a year. A bigint column against a bare
integer literal reported

    Columnar Pushed-Down Filters: 1
    Columnar Chunk Groups Removed by Filter: 0

which reads as "pushdown works, this predicate is just not selective" and
actually meant "the predicate was never usable". test/zonemap_cost.sh sat in
exactly that state for its whole life, so #460's cost discount was validated
against a fixture where the effect it prices did not occur.

EXPLAIN (ANALYZE) now also reports "Columnar Usable Skip Predicates": how many
of those filters the reader built a predicate from. The pair distinguishes an
unselective filter from an unusable one; neither number alone can.

The existing line is UNCHANGED, deliberately. It carries the #191 signal that
pgcolumnar.enable_qual_pushdown took effect, which reporting only the usable
count would lose, and both questions get asked. Keeping it also means nothing
that parses these lines changes: test/pushdown_report.sh and test/audit.sh
both do. Under FORMAT JSON the new line is an ordinary integer property, which
a "1 (0 usable)" qualifier on the existing line could not have been.

All three nodes that print the original line print the new one: the scalar
custom scan and both vectorized aggregate nodes, which fill npreds from
PgColumnarCountConvertibleQuals and have the same gap. Fixing one would make a
line of plan text mean two different things depending on which node ran. The
aggregate nodes capture the count beside their existing stats snapshot, since
the read state is ended before EXPLAIN runs.

The new line needs ANALYZE, matching its neighbours: it describes the run, not
the plan. There is no enable_qual_pushdown ternary on it, because with the
setting off the reader builds no predicates and it is already 0.

FIXTURE. The issue proposed a same-type predicate on a non-btree-comparable
column, which cannot show this: clause_to_scankey rejects such a column
outright, so no scan key is built and both numbers read 0. The shape that
survives #478 is a DOMAIN column. GetDefaultOpClass resolves a domain to its
base type, so the key is built; make_predicates then sees the column type as
the domain and the constant as the base, calls it cross-type, and finds no
BTORDER proc for the pair. Measured on PG17, 200,000 rows in 20 groups, same
values in each column:

    plain int   > 190000     19 of 20 groups removed
    bigint      > 190000     19 of 20 groups removed
    domain int  > 190000      0 of 20 groups removed

A domain column ought to prune as its base type does. That it does not is a
separate defect, filed as #483. test/pushdown_report.sh pins it as an
assertion rather than an echo, so fixing #483 turns that check red and forces
whoever fixes it to supply a new unusable fixture instead of deleting the
section.

TESTS. Each of the three checks was written red and went green on its own
slice. Proved by removal rather than asserted: with all three lines rewired to
report the built-key count instead, four checks fail reporting 1 where 0 is
required, which is the defect itself rather than a missing line. The armed and
restored .so were fingerprinted (md5) so the proof could not run against the
fixed binary. Every arm asserts its own node fired first, and pairs each
reported number with the physical fact underneath it, since a suite that
asserts a derived relation with no physical fact under it can stay green for
its whole life while measuring nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jdatcmd

jdatcmd commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Verification result

CI: all green. Nine builds (PG15 through 19, x86_64 and aarch64), plus
suites (PG 17) and suites (PG 18).

Full PG15 through PG19 matrix, run locally at 78da946. Four majors clean:

major suites result
PG15 127 of 132 (5 version-gated skips) clean
PG16 127 of 132 clean
PG17 127 of 132 clean
PG18 130 of 132 clean
PG19 132 of 132 one failure, analyze_function, not this change

pushdown_report passes on all five.

The two reds, and why neither is this change

Both were resolved rather than discounted, because after two infrastructure reds
a real one reads as "the flake again".

CI PG18, native_agg. Resolved through the API first, not the board:
conclusion: failure, a genuine one rather than a cancellation. Cause is in the
log beside it:

native_agg.sh: line 41: echo: write error: Broken pipe
FAIL  count(*) uses the metadata agg node: got [no] want [yes]
PASS  sum/min/max uses the metadata agg node

has_aggnode() pipes into grep -q under pipefail; grep exits on its match,
the writer takes EPIPE, and the helper answers "no" while the line is present.
The very next check calls the same helper on the same node and passes. Filed as
#486.

Three independent reasons it is not this PR: the helper reads
EXPLAIN (COSTS OFF) with no ANALYZE, and the new line only emits under
ANALYZE (verified directly: a plain EXPLAIN prints three Columnar lines and
none of them is the new one); PG18 was clean in the local matrix at the same
commit; and the same CI job re-run on the identical commit passed.

Local matrix PG19, analyze_function. The suite declared itself vacuous:

FAIL  premise: core's sampled null_frac differs from the truth, so this suite
      can discriminate: got [no (sample landed exactly on truth; suite is vacuous)]
-- core sampled null_frac = 0.1, truth = 0.100000

The fixture is exactly one row in ten NULL and core samples 30,000 rows, so the
premise requires a binomial with n=30,000 and p=0.1 not to land on its own mode.
That has probability about 0.8%, one run in roughly 130. Six consecutive PG19
re-runs all passed, with core's sample at 0.0977 through 0.1041. Filed as
#487; #475 already removed this exact reasoning from the histogram check and
null_frac kept it.

Neither touches EXPLAIN output and analyze_function reads no plans at all.

One note on the re-runs

The first attempt to measure the analyze_function rate produced five empty
results, which I treated as a broken probe rather than a result. The cluster was
never starting:

FATAL: could not load library ".../pg19/lib/postgresql/pgcolumnar.so":
       undefined symbol: get_relation_info_hook

That was a stale-object build of mine in a reused tree, not anything in the
branch. Rebuilt from a clean tree with make clean PG_CONFIG=... first, the
.so loads and the suite runs. Recording it because an undefined symbol at
.so load looks like an ABI problem in the code and is not one. The matrix
itself was never affected: it builds each major in its own fresh builddir, which
is why its PG19 leg ran all 132 suites.

@jdatcmd
jdatcmd merged commit ef61716 into main Aug 7, 2026
21 of 22 checks passed
@jdatcmd
jdatcmd deleted the fix/479-usable-skip-predicates branch August 7, 2026 18:14

@ChronicallyJD ChronicallyJD 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. The shape is right, the fixture reasoning is right, and the test section anticipates the failure mode I would have gone looking for — asserting each node actually fired before reading a number out of its plan, so a node that quietly declines cannot pass the check written for it.

Three things I checked rather than took on trust, and two observations that are not blockers.

Checked

numPredicates is the right quantity. pgcolumnar_build_predicates sets it to pgcolumnar_make_predicates' return value, which is the count of keys actually built, so the accessor reports usable-and-nothing-else. The other make_predicates call site (columnar_reader.c:1001, in PgColumnarEstimatePruneSurvival) is planner-side and correctly not wired to the runtime counter.

All three emitters, and it is three. grep finds Columnar Pushed-Down Filters at columnar_vector.c:3527, columnar_vector.c:4206 and columnar_customscan.c:1879; the diff adds the companion at all three. Nothing is left printing one number without the other where it matters.

The agg-node gap is not reachable. The new line goes inside if (state->haveStats) while the old one prints outside it, so I went looking for a path with predicates and no stats. There isn't one: haveStats = true is set only under if (state->scanFold) (columnar_vector.c:3386), the metadata path forces it false, and a filter always sets scanFoldpgcolumnar_fill_native_metadata_agg's contract is "no filter". So npreds > 0 implies scanFold implies haveStats, and the pair always travels together on those nodes.

That is an invariant the correctness of this PR rests on, and it is currently unwritten. If someone later teaches the metadata path a zone-map-answerable filter, the two lines decouple silently and nothing fails. One sentence in the comment next to usablePreds, or an Assert(!state->haveStats || ...), would pin it.

Observation 1: on the scalar node the gap IS reachable, via plain EXPLAIN

Same structure, different outcome. Columnar Pushed-Down Filters prints outside if (cstate->readState != NULL) and the new line goes inside it. Columnar Chunk Groups Total is already inside that same block, so it stands in exactly for the new line — whatever plain EXPLAIN does to one it does to the other. Measured on merged main, 200,000 rows:

EXPLAIN (COSTS OFF) SELECT count(*) FROM pr484 WHERE plain > 190000;

   ->  Custom Scan (PgColumnarScan) on pr484
         Filter: (plain > 190000)
         Columnar Projected Columns: 1
         Columnar Total Columns: 1
         Columnar Pushed-Down Filters: 1     <-- present
                                             <-- nothing from the guarded block

With ANALYZE the guarded lines appear. So after this PR, a plain EXPLAIN still shows Columnar Pushed-Down Filters: 1 alone, which is precisely the reading that hid #477 — and the line that corrects it is absent.

I do not think this blocks the PR. It is consistent with the neighbours, the body says so plainly, and it is a smaller gap than the one being closed. But it is worth deciding deliberately rather than by inheritance, because plain EXPLAIN is where somebody asking "will this prune?" looks first, and it is the one context where they get the old answer with none of the new information.

Observation 2: the usable count does not actually need a run

The reason given for requiring ANALYZE is that the line describes the run. That is true of how it is currently sourced, but the underlying fact is planner-determinable: pgcolumnar_make_predicates is a pure function of the scan keys and the tuple descriptor, and PgColumnarEstimatePruneSurvival already calls it at planning time to price the scan. Nothing about "this key is cross-type and the opfamily has no BTORDER proc for the pair" needs the table to be read.

So requiring ANALYZE here is a choice about where to source the number, not a constraint. Both readings are defensible — a runtime number is honest about what the run did, and it keeps the line beside its neighbours — but if the goal is that nobody again reads Pushed-Down Filters: 1 and concludes pushdown works, the plan-time form reaches more of the people who need it.

Not asking for it in this PR. Flagging it because "it describes the run, not the plan" reads as a constraint in the body, and it is not one.

On the #483 fixture

Pinning a known-wrong behaviour as an assertion rather than an echo, with a comment saying the check is meant to go red when #483 is fixed, is the right call and the opposite of what usually happens to such a check. Worth saying explicitly since it will look like a bug to whoever hits it: that red is the design working.

One consequence to be aware of: it makes this suite a dependency of #483's fix rather than an independent check of it. Whoever fixes #483 needs a replacement unusable fixture in hand before they can turn the suite green, and on current main the domain column is the only one available. If #483's fix removes the last unusable shape, the honest resolution is to delete the arm and say why — not to invent a contrived one.

jdatcmd pushed a commit that referenced this pull request Aug 8, 2026
Five lines were printed by three nodes -- the scalar custom scan and both
vectorized aggregates -- each from its own copy of the code. #484 had to add
"Usable Skip Predicates" in three places for that reason, and said why it could
not do fewer: fixing one would leave a line of plan text meaning two different
things depending on which node ran. That invariant was held by discipline.

Now by structure. PgColumnarExplainPushedDown and PgColumnarExplainGroupStats
are the only emitters, and the long explanations of WHY both numbers exist --
#191 for the filter count describing the plan rather than the run, #479 and #477
for the usable count -- live once beside the code they describe instead of three
times in three files.

Two functions rather than one, because the nodes interleave: the ungrouped
aggregate prints "Columnar Batch Fold" between the filter count and the group
counters, so a single combined emitter would reorder its plan output. Splitting
there keeps every plan byte-identical, which the 34 pre-existing checks in
pushdown_report.sh confirm -- they read specific fields from plans of all three
node types and all still pass.

Behaviour is deliberately unchanged, including the asymmetry #495 notes: the
filter count still prints outside the "did we read anything" guard and the usable
count inside it. That gap is real and reachable on the scalar node, but it is a
semantics question and belongs to #493; unifying the emission first is what makes
#493 a one-line change instead of a fourth edit to three places.

## The check that makes it durable

Unifying three call sites into one is worth little if a FOURTH is undetectable --
that is how the three arose. pushdown_report.sh now asserts each of the five
lines has exactly one emitter in src/, counted at the source, which is the only
place a new caller is visible before it has drifted.

Proved by growing a fourth the way a new node would, rather than by deleting the
check:

    baseline                     39 checks, 0 fails
    fourth emitter added         39 checks, 1 fail
      -> exactly one emitter for "Columnar Chunk Groups Total": got [2] want [1]

A source grep rather than a behavioural assertion on purpose: the failure being
guarded is "someone wrote a second emitter", which is a property of the code and
not of any plan. The behavioural half -- that the nodes agree with each other --
is what the rest of the suite already checks.

Refs #495, #484, #479, #493
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.

"Columnar Pushed-Down Filters" counts scan keys, not predicates that can exclude anything, so a filter that prunes nothing reports as pushed down

2 participants