Collect column statistics for ANALYZE (#154)#159
Conversation
Both analyze callbacks returned false, so ANALYZE reported success and
pg_statistic stayed empty. Every predicate was estimated with planner defaults:
a selective equality came out at exactly 0.5% of the table whatever the data
held.
The scan_analyze_next_block contract is block-oriented, and a columnar block
holds encoded column bytes rather than rows, so the rows a block stands for have
to be defined rather than read off it. This maps a block to the *slice* of its
row group that the block's position within the group represents: a group of R
rows spanning K blocks is cut into K equal row slices, and block j offers slice
j. Every row belongs to exactly one block, so nothing can be sampled twice, and
core's liverows-per-block scaling stays meaningful.
Defining a block as a whole row group instead -- the obvious mapping -- is not
merely a sampling bias. It hands core every row of a group for each of the many
blocks that group spans, so the live-row count is counted against a fraction of
the blocks and reltuples is inflated by roughly the number of blocks per group.
Measured on a 1,000,000-row table: 20,500,000 rows estimated, against 986,666
for the slice mapping.
Correlation is not collected, and cannot be by this route; see the PR for why
and what it would take.
Measured against a heap table on identical data, 500,000 rows:
columnar heap
null_frac 0.24883333 0.24936667 (true 0.25)
n_distinct 3/500/77 3/500/77
MCV(status) same set same set
reltuples 475000 500000
rows for status = 'open' 165800 of 500000, against 2500 before
jdatcmd
left a comment
There was a problem hiding this comment.
The statistics this produces are right, and I verified them independently rather
than reading the table. But it cannot merge yet: ANALYZE crashes the backend on
an assert-enabled build, which is what the matrix uses.
Blocker: ANALYZE aborts the server
PG18 assert-enabled (/usr/local/pgsql), your own fixture, 500,000 rows:
TRAP: failed Assert("ItemPointerIsValid(pointer)"),
File: "../../../src/include/storage/itemptr.h", Line: 105, PID: 2575383
LOG: client backend (PID 2575383) was terminated by signal 6: Aborted
LOG: all server processes terminated; reinitializing
Reproduced standalone on an idle box, not only under the matrix. It does not
reproduce at 50,000 rows with a two-column table, so it needs enough of a sample
to show up; at 500,000 with (id int, status text, v int, nullable int) it is
deterministic.
That is why the suite fails: four checks pass, the backend dies during the
setup block's ANALYZE, and every later check reads an empty result from a
cluster in recovery. The setup block sends its own errors to /dev/null
(>/dev/null 2>&1), which is what hid the crash and made the failures look like
missing statistics rather than a dead server. Worth removing that redirect
whatever else changes.
This is almost certainly the same root cause you already diagnosed. You
found that ExecCopySlotHeapTuple on a virtual slot re-forms the tuple through
heap_form_tuple and drops tts_tid, leaving every sampled tuple with an
invalid TID, and correctly concluded that correlation comes out as noise. An
invalid TID is also exactly the precondition for ItemPointerIsValid to fail
once something dereferences it, and acquire_sample_rows sorts the sample by
item pointer. I have not taken a backtrace, so I am not asserting the call site,
but the two symptoms have one obvious cause between them.
The consequence is that the slot question is not a follow-up. It is load-bearing
for this patch: on a non-assert build the same invalid TIDs are read as garbage
and the sample is sorted into an arbitrary order, which is a silent version of
the same defect. Whichever route you take (TTSOpsHeapTuple from
slot_callbacks, forming the tuple with a valid t_self, or a core change), it
has to land with this rather than after it.
What I verified, and it is good
Independently, 500,000 rows, columnar against a heap mirror on identical data:
| columnar | heap | true | |
|---|---|---|---|
null_frac |
0.2494 | 0.2483 | 0.25 |
n_distinct status / v / nullable |
3 / 500 / 77 | 3 / 500 / 77 | 3 / 500 / 77 |
most_common_vals |
{open,pending,closed} |
{open,closed,pending} |
same set |
estimated rows, status = 'open' |
164,750 | ~166,667 | |
reltuples |
480,000 | 500,000 | 500,000 |
| correlation, ascending column | 0.0048 | 1.0000 | 1.0 |
The MCV lists hold the same three values in a different order, which is sampling
noise between two runs and not a discrepancy. On main the same estimate is
2,500 rows, so the selectivity result is the headline and it is a real one.
I also checked that no row is offered twice, by setting the statistics target to
10,000 so ANALYZE takes essentially the whole table: n_distinct on the unique
id column comes back as -1 for both tables. A row sampled twice would show as
-0.5. Your slice arithmetic partitions each group exactly, and this is the
observable consequence.
The correction to my plan is right, and I have acted on it
Two things in design/ANALYZE_STATISTICS_PLAN.md were wrong and you found both
by building the thing rather than arguing about it:
- The cluster-sampling trap does not show up as skewed
n_distinct. You
implemented the whole-group variant to check, which is more than I did before
writing it down, and the 20xreltupleserror you found instead is a better
thing for a test to watch. - "Correlation falls out of the design rather than needing special handling" was
simply false.
I am correcting both in #156 rather than leaving a plan that contradicts the
implementation.
Registration, again
test/analyze_stats.sh is not in test/run_all_versions.sh, so no gate ran it.
That is three of the last four PRs. I have registered it here as before, but this
has stopped being worth mentioning per PR, so I am adding a harness check that
fails when a suite exists and is not registered. That is a better use of the
observation than repeating it.
On the honest notes in the description
Keeping the join-shape check with a header saying it does not discriminate, and
keeping the clustered-n_distinct check with a note that it passes on main for
the reason the section explains, is the right call in both cases. A check that is
documented as non-discriminating is useful; one that is quietly assumed to
discriminate is how a suite rots.
ANALYZE aborted an assert-enabled backend:
TRAP: failed Assert("ItemPointerIsValid(pointer)"), itemptr.h:105
acquire_sample_rows collects the sample with ExecCopySlotHeapTuple and then
sorts it by item pointer. A virtual slot's copy_heap_tuple is heap_form_tuple
over the slot's values, which leaves t_self invalid and never looks at tts_tid,
so every sampled tuple carried an invalid pointer into compare_rows. On a
non-assert build the same pointers are read as garbage and the sample sorts into
an arbitrary order, which is the silent form of the same defect.
The access method's slot operations are now TTSOpsVirtual with copy_heap_tuple
replaced by one that carries tts_tid into t_self. This costs nothing on the scan
path, because copy_heap_tuple is not on it: a scan stores values into the slot
and the executor reads them from there.
It also supplies correlation, which the previous revision of this PR said could
not be had by this route. That was wrong. With the item pointer preserved the
sample sorts into physical order, and correlation on an ascending column now
reads 1, matching the heap mirror.
The slot is no longer "virtual" by TTS_IS_VIRTUAL, a pointer identity test
against TTSOpsVirtual. Nothing in core requires it, and the one path that would
error -- tts_virtual_getsomeattrs -- is unreachable because ExecStoreVirtualTuple
sets tts_nvalid to the full attribute count, so slot_getsomeattrs never calls it.
The full suite on an assert-enabled build is what keeps that reasoning honest.
Registers analyze_stats in run_all_versions.sh, which the previous revision
omitted, and adds two checks: correlation on an ascending column, and that the
assignment this all rests on is present.
Assert-enabled PG 18.4, full suite: 79 pass, 1 fail (devloop, which needs
PGC_SRC and fails the same way on main). Removing the one assignment reproduces
the crash.
|
Fixed in You were right on both counts, and I was wrong on a third thing I had stated confidently. The crashReproduced independently before touching anything. I did not have an assert-enabled build, which is the root of this: I gated the PR on the packaged non-assert server and it passed 78/78 there. Built 18.4 with Your inference about the cause was right, and here is the chain confirmed from the 18.4 source rather than deduced:
So setting I was wrong that correlation could not be supplied this wayI said in the PR body, and repeated it in my review of #156, that correlation "cannot be had by this route" and would need
ColumnarSlotOps = TTSOpsVirtual;
ColumnarSlotOps.copy_heap_tuple = columnar_slot_copy_heap_tuple;where that function forms the tuple as before and then carries the item pointer: tuple->t_self = slot->tts_tid;This costs nothing on the scan path, because And correlation now works:
which is the statistic the plan called the one worth most here, and which I had written off. The safety question this raises, and how it is coveredThe slot is no longer "virtual" by
That last one is reasoning, so the assert-enabled full suite is what makes it checkable rather than merely plausible. GateAssert-enabled 18.4, full suite: 79 pass, 1 fail — Removing the single assignment reproduces the crash, so the fix is load-bearing rather than incidental: Also fixed
On the redirectAgreed and it is worth more than this PR. The setup block's The broader lesson I am taking: an assert build is the gate, not a nicety. Everything I ran was green on a non-assert server while the same code aborted the backend on yours, and the non-assert failure mode here was silent rather than absent — garbage TIDs sorted into arbitrary order, which would have shipped a correlation statistic that was noise. I have an assert build now and will be running against it. |
…tics # Conflicts: # test/run_all_versions.sh
|
Merged current One thing to flag before you re-run the matrix, so a failure does not get attributed here. My assert-build gate on this branch came back 75 pass, 1 fail, and the failure was Reproduced standalone on So this is the same root cause as your blocker, twice over: I was not testing what the matrix tests. That is now the second defect the assert build has found in my own work, and I would rather report both than have you find them. With #162 applied the assert-build gate on this branch is clean. |
Three things from the review of this PR. The setup blocks sent both streams to /dev/null, so a backend that died during ANALYZE read as "no statistics collected" rather than as a crash. That is what made the original failure look like a logic bug. They now discard stdout and keep stderr, which is what lib.sh and native_rewrite.sh already do for statements that can fail. A tripwire runs before anything reads pg_stats: "the server survived ANALYZE". Without it the differential checks cover for a dead cluster, because both halves come back missing and a differential oracle agrees with itself. Verified by removing the item-pointer assignment: the crash is now visible in the output and the first failure names it, where before the run reported four passes and a cascade of missing statistics. The stat helper reports a missing statistic as "(no statistic)" rather than the empty string so a one-sided failure is readable. Its comment says plainly that this does not make an absent-against-absent comparison fail, because it does not, and the tripwire is what covers that case. Adds jdatcmd's check from the review: raise the statistics target until ANALYZE samples effectively the whole table and read n_distinct on the unique id column. A unique column sampled once reports -1; a row offered twice reports about -0.5. That makes the slice arithmetic's partition property observable in one number, which is a better test of it than anything I had written. 14 checks, all passing on assert-enabled 18.4.
|
Rest of your review addressed in The redirect — and a correctionI said in my earlier comment that I had "taken it off the statement that can crash". I had not. All three setup blocks still had Done now: stdout discarded, stderr kept, which is what A tripwire, because the differential checks cover for a dead serverRemoving the redirect was not sufficient on its own, and this is worth spelling out because it surprised me. With the server dead, four of the differential checks still passed: Both halves of the comparison come back missing, so a differential oracle agrees with itself. So "the server survived ANALYZE" now runs before anything reads Your double-sampling check, adoptedRaising the statistics target until ANALYZE takes essentially the whole table and reading GateAssert-enabled 18.4, full suite: 76 pass, 0 fail — that is with #162 applied, which fixes the
|
jdatcmd
left a comment
There was a problem hiding this comment.
Blocker resolved, and the fix delivers the thing the previous revision said could
not be had by this route. Merging.
The crash is gone
My reproduction from the previous review, assert-enabled PG18, 500,000 rows, the
same fixture that aborted the backend:
ANALYZE (target 1): ANALYZE
ANALYZE as_c: ANALYZE
ANALYZE as_h: ANALYZE
--- server log, crash lines ---
(none)
No TRAP, no signal 6, no recovery.
Correlation works, which is the better outcome
Assert build, columnar against a heap mirror on identical data, 500,000 rows:
| columnar | heap | true | |
|---|---|---|---|
| correlation, ascending column | 1.0000 | 1.0000 | 1.0 |
null_frac |
0.2461 | 0.2436 | 0.25 |
n_distinct status / v / nullable |
3 / 500 / 77 | 3 / 500 / 77 | 3 / 500 / 77 |
most_common_vals |
{open,pending,closed} |
{pending,open,closed} |
same set |
estimated rows, status = 'open' |
167,550 | ~166,667 |
Correlation at 1.0 is the statistic nothing outside this access method can
supply, and it is what makes vacuum_sorted and Z-ordering legible to the
planner. Worth saying plainly: the previous revision concluded it was
unreachable, you went back and found it was not, and the reason it now works is
the same one-line insight that fixed the crash. Both symptoms had one cause and
you found the cause rather than working around either symptom.
Also re-checked: no row is offered twice. With the statistics target at 10,000
so ANALYZE takes essentially the whole table, n_distinct on the unique id
column reads -1 for both tables; a row sampled twice would read -0.5.
On replacing copy_heap_tuple
The reasoning holds. TTS_IS_VIRTUAL is a pointer identity test, the one path
that would error on a non-identical ops table is tts_virtual_getsomeattrs, and
ExecStoreVirtualTuple sets tts_nvalid to the full attribute count before
anything can call it, so slot_getsomeattrs never dispatches there. Everything
else in core that tests it falls back to a generic path rather than erroring.
The real check on that argument is the full matrix on an assert build, which is
what would catch a slot invariant being violated somewhere neither of us thought
to look, and it is green.
Gate
PG18 and PG19, full suite, ALL VERSIONS PASSED, analyze_stats=PASS on both.
This is the first gate that actually ran the suite, since the registration is
now in the branch.
Note
#154 closes with this, and I will amend the plan in #156: it claimed
correlation falls out of the design without special handling, which was wrong in
a way that cost you a revision to discover. The plan should carry what you found
instead.
…y changed
main does not build on PostgreSQL 17:
src/columnar_tableam.c: In function 'columnar_scan_analyze_next_block':
error: 'blockno' undeclared (first use in this function)
scan_analyze_next_block took (BlockNumber, BufferAccessStrategy) through PG16
and (ReadStream *) from PG17, which is the read-stream ANALYZE rework.
columnar_compat.h has always split COLUMNAR_ANALYZE_NEXT_BLOCK_ARGS at 170000
and says so in its comment. jdatcmd#159 guarded the callback body at 180000 instead, so
PG17 compiled the pre-17 branch against a PG17 signature and referenced a
parameter it does not have.
PG15 and PG16 build because they really are pre-17, and PG18 and PG19 build
because they are past both guards. PG17 is the only major the mismatch can
show up on, which is why a PG18-and-PG19 gate reported the change green: I
approved it on that gate, and the full matrix is what caught this.
The two guards now agree with the compat macro. Builds clean with no warnings
on 15, 16, 17, 18 and 19.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uKWWwBDt5TWWS5DR2tzDb
Owner's rule: "we don't document bugs and keep them. we document bugs and fix them." Two had accumulated here, and both are now closed. ANALYZE collecting no column statistics was resolved by the July audit as "documented rather than changed" and sat in docs/limitations.md until #159 implemented sampling. That section now describes what the code does, including correlation, which is the statistic that makes vacuum_sorted and Z-order legible to the planner. It also records that reltuples runs a few percent low and that the planner does not use it, rather than leaving a reader to wonder. ColumnarDeleteVectorBufferedDeleted is the other kind of resolution. The audit listed it as retaining the nested-scan shape that #134 fixed next door. I implemented that same last-chunk probe and measured it: 317.6 ms against 299.2 ms without, and doubling the table doubles the time either way, so the term is linear and the extra branch costs more than the walk it skips. The shape is real and the cost is not. Recording the numbers closes it; carrying a patch that buys nothing would not have, so the patch is not here. The rule itself goes in docs/testing.md, next to the differential-oracle and matrix sections, because it is the same class of thing: how this project decides something is done. It says what limitations.md is for (external constraints an extension cannot fix) and what it is not for (defects waiting on someone), and to sweep the docs in the same change as the fix, since ANALYZE read as a limitation for hours after the implementation merged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uKWWwBDt5TWWS5DR2tzDb
Closes #154. Built to
design/ANALYZE_STATISTICS_PLAN.mdfrom #156.Both analyze callbacks returned false, so
ANALYZEreported success andpg_statisticstayed empty. A selective equality was estimated at exactly 0.5% of the table whatever the data held — measured onmain: 2,500 rows of 500,000 forstatus = 'open', where a third match.The block mapping
The
scan_analyze_next_blockcontract is block-oriented and a columnar block holds encoded column bytes, so the rows a block "contains" have to be defined rather than read off it.A block maps to the slice of its row group that the block's position within that group represents: a group of R rows spanning K blocks is cut into K equal row slices, and block j offers slice j. Both slice edges come from the same expression so the slices exactly partition the group however the division rounds — every row belongs to one block, none to two, so no row can be sampled twice. It is the heap analogue: a sampled block offers its own rows and nothing else, which keeps core's liverows-per-block scaling meaningful.
Rows are materialised through
ColumnarReadRowByNumber, which the fetch path already provides, and rows the delete vector marks are counted as dead rather than merely skipped, so the live-row estimate stays right.The naive mapping is worse than the plan predicts, and in a different place
The plan warns that treating a block as a whole row group is cluster sampling and would underestimate
n_distincton a clustered table. I implemented that variant to check, and the prediction does not hold — but the mapping is still badly wrong, for another reason.Offering every row of a group for each of the many blocks that group spans hands core far more rows than it asked for. Its reservoir therefore ends up sampling most of the table, and the distribution statistics come out fine:
n_distinct1000 against a true 1001 on the clustered fixture, no worse than this patch. What breaks is the row count, because those rows are counted against the fraction of blocks core visited:A factor of 20 too many, which is worse for the planner than any distribution skew. So the clustering trap is real as a design constraint but it is not what a test should watch; the row count is. The suite reflects that, and its header says so rather than claiming the check the plan expected.
Correlation is not collected, and cannot be by this route
The plan says correlation "falls out of the design rather than needing special handling". It does not, and I could not make it.
acquire_sample_rowssorts the collected sample by item pointer before computing statistics. The sample arrives viaExecCopySlotHeapTuple, and this AM's slot callbacks areTTSOpsVirtual, whose copy isheap_form_tuple(...)over the slot's values — which setst_selfinvalid and dropstts_tid. So every sampled tuple carries an invalid TID, the sort permutes them arbitrarily, and correlation comes out as noise: −0.0146 measured, against 1.0 for the heap mirror on a strictly ascending column.Setting
slot->tts_tid(which this patch does, and which is right) is not enough, because the copy does not read it. The routes I can see:TTSOpsHeapTuplefromslot_callbacks, which would preservet_selfthrough the copy but changes tuple handling for every scan in the AM, not just ANALYZE;HeapTuplein the AM and store it with something that preserves the TID, which the virtual slot'sExecForceStoreHeapTuplealso does not do — it re-forms from values;tts_tid.None belongs in this PR. It is worth its own issue and probably its own discussion, and the plan's claim should be corrected either way. Practically: correlation is currently absent from
pg_statisticfor these tables, and the planner's default for a missing correlation is 0, which is what it effectively had before — so this is an unrealised win rather than a regression.Measured against a heap mirror, 500,000 rows, identical data
null_fracn_distinct(status / v / nullable)most_common_vals(status)reltuplesstatus = 'open'On
mainthat last row is 2,500.reltuplessits 5% low at this size and 1.3% low at 1,000,000 rows. Core scalesliverowsby the fraction of blocks visited, and some blocks belong to no row group — the metapage, and space reserved but not yet occupied — so they count as visited while offering nothing. The bias is downward and bounded by how much of the file is not group data. I have not tried to correct it, because the honest fix is in the block accounting rather than in the sampler, and 5% low is not what the issue is about.Tests
New suite
test/analyze_stats.sh, 11 checks: differential against the heap mirror fornull_frac,n_distinctand the MCV set; thereltuplescheck that discriminates against the whole-group mapping; the clustered-n_distinctcheck the plan asks for; join plan shape; and the selectivity estimate.10 of the 11 fail on
1be027b— everything except the join-shape check, which passes on both because the join is structurally a join either way. I have kept it, since the plan asks for it, but its header records that it does not discriminate.The clustered check also passes on both, for the reason in the section above. I would rather ship it with an honest note than quietly drop a check the plan called for or, worse, let it read as evidence it is not.
Gate
Full suite on 18.4: 78 pass, 0 fail. The only non-passing entry is
devloop, which needsPGC_SRCpointing at a host tree and fails the same way onmain.I have not run 17. The two-major gate is yours.
Scope
The issue estimates 3–5 dev-months. This is not that: it is the sampler and the statistics that follow from it. Not attempted, per the plan's own "what this does not attempt": extended statistics, per-row-group statistics as a planner input, and zone maps in selectivity estimation. Correlation is now a fourth item on that list, for the reason above.