feat: most-common values, exact and excluded from the histogram (#414 slice 3b) - #476
Conversation
…slice 3b)
pgcolumnar.analyze() now collects most_common_vals and most_common_freqs from
the same single-column read that already produced n_distinct and the histogram,
and removes those values from the histogram.
The selection rule is core's, and reading the whole column removes most of it.
analyze_mcv_list() opens with
if (samplerows == totalrows || totalrows <= 1.0)
return num_mcv; analyze.c:2995
so the continuity-corrected Wald interval over a hypergeometric variance -- the
entire significance filter -- is skipped when the table was read rather than
sampled. That machinery decides whether a SAMPLE frequency can be trusted; we do
not sample. What is left is mechanical: values with count > 1 are eligible
(:2549), the top attstattarget of those by count (:2552), and a frequency over
TOTAL rows including nulls (:2720).
The last of those fails quietly, so the fixture is built to catch it: `cat` is
one-tenth null, which makes count/total and count/non-null differ. Dividing by
the non-null count yields 0.2/0.12/0.06 where the truth is 0.18/0.108/0.054 --
ordered, summing to less than one, plausible, wrong. Injecting that defect fails
three checks.
Excluding the most-common values from the histogram is required rather than
cosmetic. Keeping them counts those values twice in selectivity: eqsel takes the
frequency from the MCV list and the range estimators count it again inside the
bucket holding it. Core collapses them out at :2768-2799 and sizes what remains
at :2744, so the bucket count had to move with the population: asking for a
fixed target+1 fractions after removing 100 values from a 150-distinct column
would repeat bounds, describing buckets that hold no rows.
This changes slice 3's behaviour rather than only adding to it. skew's minimum
is 0, which occurs five times and is therefore now a most-common value, so the
histogram no longer starts there; that check asserted the column minimum and now
asserts the smallest non-most-common value.
Also fixes the per-column statistics target, which was already diverging and
invisible. Core reads attstattarget (:1065, :1897) where this function read the
global default, so ALTER TABLE ... SET STATISTICS was ignored -- this suite had
been setting it on skew since slice 3 while asserting nothing about it. A target
of zero means do not collect, and is now honoured; requesting only zero-target
columns no longer raises an error whose hint blamed missing row groups.
test/analyze_differential.sh is new, and lands with the slice rather than after
it because the failure it catches is silent. pg_restore_attribute_stats takes
VARIADIC "any" and answers a mistyped argument with a WARNING, not an error:
most_common_freqs is real[] and a float8[] there is dropped while the call
reports success. Values cannot be the oracle, since exact and sampled statistics
differ by design, so it compares shape against core -- which kinds are present,
their operator and collation -- across five column types, and verifies every
stored value against an independent count. Injecting the float8[] defect
produces ten WARNINGs, drops all five MCV lists, and raises nothing.
Its own first draft had the blind spot it exists to prevent: with no nulls in
the fixture the two frequency denominators agree, so it passed with the
denominator defect injected while the value suite caught it. The fixture is now
one-in-seven null and catches it on all five types.
Gated PG18+19; 15 through 17 skip cleanly, since pg_restore_attribute_stats
arrived in 18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sY1RQR5MjZNUixA2um31r
…#469) harness_selftest reported "unregistered: parquet_nested_import" on #476's PG18 CI, in a run whose own summary listed that suite as having PASSED. The two are not in conflict; the check was wrong. listed_suites | grep -qx "$name" || unregistered="$unregistered $name" This file runs under `set -o pipefail`. `grep -q` returns the moment it matches, which closes the pipe while printf is still writing; printf takes EPIPE and exits non-zero, and pipefail reports the PIPELINE as failed even though grep matched. A registered suite is then recorded as unregistered. The CI log carries both halves: "printf: write error: Broken pipe" from line 209, and the false red. The note above the cache blamed transient fork failures for this symptom in the #473 matrix -- innocent suites, different names each run, some majors only. That diagnosis is wrong, or at least incomplete: caching removed the forks and the symptom survived. A race between grep exiting and printf finishing explains every observed property, including why it never reproduces locally. Measured rather than argued: 4,000 names, matching the first, 200 attempts. With pipefail, 18 false negatives; without it, 0. At the real list size of 132 short names it does NOT reproduce locally -- the whole list fits the pipe buffer, so printf's single write completes before grep can exit -- which is consistent with it having been seen once, under CI load, on a name near the end of the list. The fix is to stop piping. Membership is now a case over the cached string, with newlines on both sides for the whole-line match grep -x provided. No reader, no EPIPE, no race at any list size. Three controls come with it, because a matcher that always matched would also have made the red go away while making the check meaningless: a registered name must be found, an absent one must not be, and a prefix of a registered name must not count as registered. Proven by removal: deleting analyze_differential from SUITES fails the check with that name. 43 checks green on PG18 and PG19. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sY1RQR5MjZNUixA2um31r
The PG18 red was real, and it was not this branch
The check is: listed_suites | grep -qx "$name" || unregistered="$unregistered $name"The file runs under This contradicts the diagnosis recorded in that file. The note above the cache attributes this exact symptom in the #473 matrix -- innocent suites, different names each run, only some majors -- to transient fork failures. Caching removed the forks and the symptom survived. A race between grep exiting and printf finishing explains every property including why it never reproduces locally. Measured rather than argued, 4,000 names matching the first, 200 attempts:
At the real list size it does not reproduce locally -- 132 short names fit the pipe buffer, so printf's write completes before grep can exit. That is consistent with it having been seen once, under CI load, on a name near the end of the list. I am not claiming a local reproduction at production size; the mechanism rests on the 4,000-name measurement plus the two contradictory halves of the CI log. Fixed by removing the pipe: membership is now a Three controls added, since a matcher that always matched would also have made the red disappear while making the check useless: a registered name must be found, an absent one must not be, and a prefix of a registered name must not count. Proven by removal -- deleting 43 checks green on PG18 and PG19. This is #469's territory rather than #414's, but it was blocking this PR and it is a false red that would keep naming innocent suites. |
|
CI is green on the current head, checked against the run rather than the badge: PR head Not self-merging. This PR changes behaviour you merged in slice 3 -- the histogram exclusion moves The two things I would look at hardest if I were reviewing this:
One gap I did not fill: |
Post-merge review of #476: both points you flagged hold, and here is the independent checkYou asked to hear disagreement late rather than not at all. I do not have any on either point — 1. The frequency denominator includes NULLs. Confirmed, and there is a structural argumentYou read mcv_freqs[i] = (double) track[i].count / (double) samplerows; /* MCV frequency */
stats->stanullfrac = (double) null_cnt / (double) samplerows; /* null_frac */
hold, and that identity is the thing the planner depends on: Same expression appears in Your fixture pinning it at 0.2-vs-0.18 is the right shape: the two denominators cannot agree, so the 2.
|
| mine | merged | |
|---|---|---|
attstattarget NULL (PG18 default) |
< 0 → NULL → guard skipped silently |
coalesce(attstattarget, deftarget) |
attstattarget = 0 |
wrote statistics anyway | honoured — column skipped |
The second row is the one I would not have got to. A zero is a DBA instruction, and writing
statistics over it hands the planner numbers somebody deliberately turned off. Shipping MCVs sized by
the wrong target while leaving that in place would have been the worse trade.
Splitting it out now would mean a PR whose only content is re-deriving a fix that is already correct
and already covered.
What I did not review
#475 and #476 both merged without a recorded review, so this is post-merge and I have not run the
suites myself on this branch — I read the merged source and checked the three claims above against
upstream analyze.c. The 38 + 26 check counts are yours, not mine to vouch for.
I have one substantive thing outstanding on the merged function, which is a performance shape rather
than a defect, and it belongs in its own PR rather than in this thread. Writing it up separately.
Closes the slice 3b half of #414:
most_common_valsandmost_common_freqs, thehistogram exclusion they force, and the differential harness you asked to land
with them rather than after.
The selection rule was not a design decision
I went to
analyze.crather than reasoning from sampled behaviour, and coreanswers it outright.
analyze_mcv_list()opens with:The whole significance filter -- a continuity-corrected Wald interval over a
hypergeometric variance -- exists to judge whether a sample frequency is
trustworthy enough to store. We read the entire column, so core's own answer is
"keep them". What is left is mechanical, and all of it read off the same file:
:2549nmultiple == 0at:2588attstattargetby count:2552-2564ORDER BY count DESC LIMIT target:2995:2720count / totalrows, not over non-null rowsThe denominator is the one that fails quietly, so the fixture catches it
Dividing by the non-null count instead scales every frequency by
1/(1-null_frac). On the newcatcolumn, which is deliberately one-tenth null:Three numbers that are individually plausible, correctly ordered, and sum to
less than one. Nothing raises. Injected, it fails three checks with exactly
those values.
The exclusion changes slice 3 rather than only adding to it
Core builds the histogram from what is left after the most-common values are
collapsed out (
:2768-2799) and sizes it fromndistinct - num_mcv(:2744).Keeping them counts those values twice in selectivity:
eqseltakes thefrequency from the MCV list, and the range estimators count it again inside
whichever bucket holds it.
The bucket count had to move with the population, not just the population. A
150-distinct column with 100 most-common values has 50 left; asking
percentile_discfor a fixedtarget+1fractions would return 101 bounds,mostly duplicates, describing buckets that hold no rows.
One merged check moved.
skew's minimum is0, which occurs five times andis therefore now a most-common value, so the histogram no longer starts there.
That check asserted the column minimum; it now asserts the smallest
non-most-common value, derived from the written list rather than hardcoded.
A defect that was already there and invisible
Core sizes both lists from the column's
attstattarget(:1065,:1897); thisfunction read the global
default_statistics_target, soALTER TABLE ... SET STATISTICSwas ignored. This suite has been setting it onskewsince slice 3 while asserting nothing about it, so the divergence wasalready present in the file that should have caught it.
A target of zero means "do not collect", and is now honoured. Requesting only
zero-target columns also no longer raises
collected statistics for no columnswith a hint blaming missing row groups, which pointed at storage for what was a
deliberate setting.
The harness, and why it is not a value comparison
pg_restore_attribute_statstakesVARIADIC "any"and answers a mistypedargument with a WARNING rather than an error (
attribute_stats.c:247-251):most_common_freqsisreal[], and afloat8[]there is dropped while the callreports success. Values cannot be the oracle, since exact and sampled statistics
differ by design -- that is the feature. So
test/analyze_differential.shcompares shape against core across five column types: which statistic kinds
are present, their operator and collation, plus every stored value verified
against an independent count.
Two text values in the fixture (
alpha,betaandit's here) exercise the arrayliteral, which is why
array_agg(...)::textis used rather than assembling one.Proof by removal, which is the only reason to believe any of it
most_common_freqsasfloat8[]attstattargetignoredTwo things I got wrong, since they shaped the result
The first draft of the slice 3b checks scored three of four passes against a
function that wrote no most-common values at all. They read core's leftover
list from the
ANALYZEabove them;most_common_valsmatched because core hadalready written
{7,42,99}, and 99's frequency matched because core's samplehappened to round to
0.054. Statistics are now cleared before our call, soattribution is structural rather than lucky.
The harness had the blind spot it exists to prevent. Its fixture had no
nulls, so both frequency denominators agreed and it passed with that defect
injected while the value suite caught it. Found by running the removal proof
rather than by reading. It is now one-in-seven null and catches it on all five
types.
I also wrote an element-type check that compared
pg_typeof(stavalues1)againstthe column type and reported all nine slots as mismatched.
stavalues1isdeclared
anyarray(pg_statistic.h:119), sopg_typeofreturns the statictype for every row ever stored -- it fails identically against core's own
statistics. Replaced with the per-value verification, which observes something
real.
Verification
analyze_function.sh38 checks,analyze_differential.sh26, both green onPG18 and PG19. Full 132-suite matrix on both majors. 15 through 17 skip cleanly
(exit 66, no checks) since
pg_restore_attribute_statsarrived in 18.One false red worth recording: the first PG19 run failed all five suites with
pg_ctlexhausting its 8 start retries, including suites this branch does nottouch. It was the per-major object-reuse footgun -- a PG18-built
.sorelinkedfor PG19 -- not the code. A fresh per-major tree passes, which is what
run_all_versions.shdoes.Not in this PR
pgcolumnar.analyze()has no entry indocs/sql-reference.mdat all, fromslices 1-3. Worth its own change once the slices are done.
this, so these statistics rot silently into bad plans. Unresolved, Future: a background worker to schedule columnar vacuum and analyze, which autovacuum cannot reach #415.
🤖 Generated with Claude Code
https://claude.ai/code/session_013sY1RQR5MjZNUixA2um31r