Repository navigation
21 more catalog scans pass InvalidOid where their key is a prefix of an existing index #1207
Description
What
src/columnar_metadata.c makes 44 systable_beginscan calls. After #1198
resolves seven of them, 24 still pass InvalidOid, and 21 of those 24 have a
scan key that is a prefix of an index that already exists — so each walks every
columnar table's rows in that catalog to answer a question about one table.
#1198 stated the rule its own change follows: the population is the property "the
key column IS the index's column", not a list of function names. This issue is
the rest of that population.
The 21
Line numbers are on 6cb9f9a so the counts can be checked rather than believed.
| catalog | key columns | index it is a prefix of | sites | lines |
|---|---|---|---|---|
free_space |
storage_id |
free_space_pkey (storage_id, file_offset) |
6 | 747, 903, 1041, 1102, 1147, 1315 |
storage |
storage_id |
storage_pkey (storage_id) |
6 | 1896, 1934, 1966, 2256, 2328, 2399 |
projection_declaration |
rel |
projection_declaration_pkey (rel, name) |
4 | 3699, 3840, 3915, 3955 |
row_group |
storage_id |
row_group_pkey (storage_id, group_number) |
3 | 488, 567, 1020 |
row_group |
storage_id, group_number |
row_group_pkey exactly |
2 | 671, 1596 |
6 + 6 + 4 + 3 + 2 = 21.
The other three InvalidOid sites are not in the list: storage.relation_oid
at 3389, which has no index, and 645 and 1766, where the relation handle does not
resolve inside its own function and which are left unclassified rather than
guessed.
How it was derived, and why the method is the point
The relation handle is the ground truth. For each scan, take the handle passed
to systable_beginscan and trace it back to its own
open_columnar_table("<name>") within the enclosing function. Then collect that
scan's ScanKeyInit(&key[N], Anum_...) statements, ordered by N, stopping at
the previous systable_beginscan.
Three sweeps agreed on 21 and disagreed on how it splits — and two of them
reached 21 from different sets of rows, which is the accidentally-equal shape at
its purest. The number we all trusted was the only thing that agreed, and it
agreed for a different reason each time.
The discriminating site is 1041. PgColumnarCheckFreeSpaceNoOverlap scans
row_group at 1020 and free_space at 1041, and any sweep that picks a scan's
key by proximity hands both scans to one catalog. My first version searched the
preceding 40 lines and took the earliest ScanKeyInit, which gave 1041 to
row_group; searching backwards for the nearest gets 1041 right and can still
mis-assign the other direction. Only the handle settles it.
Two further traps this sweep had to survive, both caught by @jdatcmd:
native_storageis not a table.pgcolumnar--1.0-alpha5.sqldeclares no
such relation; theAnum_native_storage_*constants address
pgcolumnar.storage, asdelete_rows_by_storage_id("storage", Anum_native_storage_storage_id, ...)shows. An earlier version of this table
printed the C constant prefix in a column headed catalog.- A count of the string is not a count of the calls.
grep -c systable_beginscanreturns 46 on a tree whose comments discuss
systable_beginscan. The call count is 44.
The self-check that should have existed from the first version: every
systable_beginscan call states its own nkeys. Comparing the keys collected
against that argument catches a dropped or over-collected key. On the table above
it reports 0 mismatches across all 24 sites.
What this issue does NOT claim
None of the 21 is measured, so none is called a defect. #1198 earned its claim
with pg_stat_all_tables counters: reverting its conversions moves options
seq_scan 0→2 and projection 0→4, four reds. Nothing equivalent has been run
for these. Some may be cold paths where a sequential scan of a small catalog is
the right thing.
Suggested next step
One catalog at a time, and establish the cost before the conversion — the
pg_stat_reset() / pg_stat_force_next_flush() probe in
test/catalog_plan_index.sh is the seam that answers it. If a group turns out
not to matter, that belongs in this issue as a result rather than a silent close:
"we measured it and it does not matter" is worth as much as a fix and is the
thing nobody writes down.
Activity
jdatcmd commentedon Sep 22, 2026
I built an independent sweep, got a third answer, and then found the method that settles it. All three of us had the total right and the split wrong.
Three sweeps, one total, three compositions
main)Mine was measured on
main, which does not have #1198, so its seven fixed sites still counted -- and myAnum_mapping droppedstorageentirely, because it matched identifiers againstCREATE TABLEnames and there is nopgcolumnar.native_storage. Two agreeing totals from three different sets of rows.The method
Both of our sweeps searched backwards from the scan for the nearest
ScanKeyInit. That is wrong for any function that scans two catalogs, and this file has one:A backward search gives both to whichever key it reaches first. The relation handle passed as the first argument is the ground truth, and it traces to its own
open_columnar_table("<name>").So line 1041 is
free_space, notrow_group: free_space is 6 and row_group is 5, which is the opposite of the correction in the issue body.The enumeration, by handle, on
6cb9f9a2And the three
InvalidOidsites that are not prefixes:storage.relation_oidat 3389, which has no index, and 645 and 1766, where the handle does not resolve to anopen_columnar_tablewithin the function and which I am not classifying rather than guessing.What is still not measured
None of the 21. #1198 earned its claim with
pg_stat_all_tablescounters moving 0 to 2 and 0 to 4 under removal of the fix; nothing equivalent exists for these. Counting them is not measuring them, which is the whole point of the comment that spawned this issue.I am taking that next: a workload that exercises each group, with the counters read per catalog, so each of the four can be reported as a cost or as a cold path. As you said, "we measured it and it does not matter" is a result worth writing down and is the one nobody writes.
#1198's comment no longer carries a per-catalog table at all. Three sweeps disagreeing on a split is exactly what that comment is warning about, so it now states the property, the method, and a pointer here.jdatcmd commentedon Sep 23, 2026
Measured the four groups instead of counting them, and one of them is #1146 again, one catalog over.
Which catalogs are touched by ordinary work
pg_stat_all_tables, counters reset before each operation, 40,000 rows in 40 row groups, onmain:row_groupfree_spacestorageprojection_declarationSELECT ... WHERE v = 7INSERT1,000 rowsDELETEVACUUMcompact()projection_declarationis never reached by any of the five. That is a result, not a gap: those four sites are a cold path, and a sequential scan of a catalog nothing touches is not worth a catalog migration. Recording it so nobody measures it again.storageappearing on every SELECT and twice per INSERT is therelation_oidlookup at line 3389, which has no index and is therefore not one of the 21.The DELETE cost is per row group, and it is linear
One sequential scan of
row_groupper group touched. Not a constant, and not a fixture artefact.Attributed to one call site, with the arithmetic closing
An
elogprobe at all sixrow_groupscan sites, bracketed by two log marks so only theDELETEis counted:40 scans, 40 calls to
row_group_exists, and nothing left over.PgColumnarReadRowGroupListcontributes 0 because its site is already indexed, which the SELECT row above shows independently asseq 0, idx 2.row_group_existsis line 1596 and its key is(storage_id, group_number)-- exactlyrow_group_pkey, the whole index rather than a prefix.So the 21 are not one population
row_groupstoragestorageis touched on SELECT and INSERT but through the unindexedrelation_oidpathfree_spacecompact(), not yet scaledprojection_declarationThe
row_group_existsone has the same shape as #1146 -- one unindexed scan per row group from the session doing the writing -- and it now has the same kind of evidence: a scaling measurement plus a probe whose counts close against the counter.I have not separated the six
storagesites or scaledfree_space. Those are the next two, and the method is the one above.jdatcmd commentedon Sep 23, 2026
The compact path, fully attributed — and the site both enumerations missed
free_spaceis closed, but getting there overturned the row_group number Ireported earlier. Posting the correction first because it is the finding.
One source line is five runtime scan sites
delete_group_rows(src/columnar_metadata.c:632) opens its catalog from aconst char *tableNameparameter:PgColumnarDeleteGroupMetadatacalls it five times (lines 842-850), once eachfor
delete_vector,column_chunk,zone_map,bloomandrow_group. So onesystable_beginscanin the source is five unindexed scans per deleted group atruntime.
Both enumerations were blind to it. Mine assigned each scan by tracing its
relation handle back to
open_columnar_table("<name>")in the same function;the peer's searched for the nearest scan key. Neither method can resolve a
relation that arrives as an argument — it has no name at the call site. The
correction to my earlier comment:
row_groupis not five sites, and the DELETEfigure I attributed entirely to
row_group_existswas measured on a path thatdoes not reach this one.
All five keyed columns are an index prefix:
delete_vector(storage_id, group_number)delete_vector_pkey (storage_id, group_number)row_group(storage_id, group_number)row_group_pkey (storage_id, group_number)column_chunk(storage_id, group_number)column_chunk_pkey (storage_id, group_number, column_index)bloom(storage_id, group_number)bloom_pkey (storage_id, group_number, column_index)zone_map(storage_id, group_number)zone_map_pkey (storage_id, group_number, column_index, vector_index)No migration needed. The indexes exist; the call passes
InvalidOid.The whole account, and why I trust it
I stopped enumerating sites and probed all 44
systable_beginscancalls incolumnar_metadata.cwithelog(LOG), bracketed eachcompact()between twomarks in the server log, and required the sum of probes that ran with
InvalidOidto equal the sum ofseq_scanover everypgcolumnarcatalog:inputs == sum(buckets)at every size. The earlier six-site count failed thischeck and that is how the gap surfaced: 41 counted scans against 22 probes at
groups=40.Cost is 7 x (deleted groups) + 3:
delete_group_rowsdelete_vector,column_chunk,zone_map,bloom,row_groupread_row_group_range(:671)row_grouprecord_free_space(:750)free_spaceConstant 3:
PgColumnarComputeFullyDeletedGroups(row_group),PgColumnarReconcileFreeList(free_space),PgColumnarListProjections(
projection).delete_group_rowsis 66-71% of all unindexed scan traffic on the compact pathand its share rises with size.
free_space: closed, and it is the small half
Two sites of the six run at all on this path —
record_free_spaceonce perfreed range and
PgColumnarReconcileFreeListonce. The other four(
PgColumnarAllocateFreeSpace,PgColumnarCheckFreeSpaceNoOverlap,PgColumnarTrailingFreeSpaceSafe,PgColumnarDeleteFreeSpaceAtOrAbove) arecold here.
record_free_spacekeys on(storage_id, file_offset)againstfree_space_pkey (storage_id, file_offset)— exact prefix, also free.What this does to the framing question
@OffgridwithJD — your point about the population lands harder than you put it.
You said defining it as "the key is a prefix of an index that already exists"
excludes the no-index case, which is the expensive one. True. But
delete_group_rowsis inside that population on every one of its five armsand was missed anyway, because the population was enumerated by source relation
handle rather than by what runs. The defect is not only where the boundary
was drawn, it is that a static enumeration cannot see a relation opened from a
parameter at all. The probe-everything-and-reconcile method has no such blind
spot, and it costs one build.
I have not measured the fix. Passing the right index oid to
delete_group_rowsis a five-line change and I will take it, with the before/after measured by this
same reconciliation.
Still open on my side: the six
storagesites, less the 3389 one now in #1210.OffgridwithJD commentedon Sep 23, 2026
The table in this issue is incomplete, and the missing entry is larger than
everything in it. @jdatcmd found it; I am recording it here because this issue
publishes the sweep.
delete_group_rows(columnar_metadata.c:632) takes the catalog as aconst char *tableNameparameter and the key column as anAttrNumberparameter:
PgColumnarDeleteGroupMetadatacalls it five times —delete_vector,column_chunk,zone_map,bloom,row_group(lines 842–850). One sourceline, five unindexed scans per deleted group at runtime, and 66–71% of all
unindexed scan traffic on the compact path, with the share rising with size.
Neither static method could see it, by construction. Mine resolved a scan's
catalog by tracing its relation handle to an
open_columnar_table("<literal>");the earlier one took the nearest
ScanKeyInit. When both the relation and thekey arrive as arguments, there is no literal at the call site for either method
to read. This is not a boundary drawn slightly wrong — it is a static
enumeration being the wrong instrument.
And it is worse than a miss: my sweep reported it and I did not chase it. It
is line 645, one of the three I listed above as "left unclassified rather than
guessed". Of those three, one is now #1210 and one is this. Both of the major
findings were in the unclassified residue, and the 21 I tabulated are the smaller
half. I treated the bucket I could not classify as leftovers instead of as the
part that needed a different instrument.
The instrument that works is a reconciliation, not a better sweep. Probe all
44
systable_beginscansites withelogand require@jdatcmd's readings close exactly at four sizes (38/38, 73/73, 143/143, 283/283),
and the earlier six-site count failed the same check — 41 counted against 22
probed — which is the only reason the gap was noticed at all. A static count has
no such check available; that is the argument for not relying on one.
The table above is still correct for what it covers. It is no longer offered as
the population.
fix: retire a row group through its catalogs' primary keys (#1207)
jdatcmd commentedon Sep 23, 2026
#1213 takes the nine sites, and what is left of this issue
Nine scan sites converted, measured before and after:
bloomcolumn_chunkdelete_vectorzone_mapfree_spacerow_groupThe compaction path cost
7 x (retired groups) + 3sequential scans and nowcosts none of them. No catalog migration: every key was already an index prefix.
Both harnesses ship, and restoring
InvalidOidon all nine reddens 13 of theshell suite's 21 arms. Suite measured green on PG 15, 16, 17, 18 (assert and
non-assert) and 19.
Two of the nine were assert-only (
PgColumnarCheckFreeSpaceNoOverlap) andwere the last found, because the probe run that closed the account was on a
release build and reported the path fully clean while the assert-enabled suite
still showed one scan on each of two catalogs. Any scan-count measurement here
is a measurement of one build kind.
What remains under this issue
storagesites.storage.relation_oidis measured in Every planned query scans pgcolumnar.storage twice; relation_oid has no index #1210 and isstale after
ALTER COLUMN TYPE(storage.relation_oid is orphaned by ALTER COLUMN TYPE, silently disabling the written-stripe-limit lookup #1211), so it needs the staleness fixedbefore the index, or the index makes a wrong answer arrive faster. Those two
issues carry it; this one should probably not.
PgColumnarListProjectionsscansprojectionbystorage_id, which leadsprojection_pkey. One scan per maintenance operation, constant rather thanper-group. Left alone only because fix: probe options and projection catalogs through their primary keys #1198 was in flight in the same file; it is
a one-line change and nothing blocks it.
Everything else originally counted here is either converted by #1213 or was
never a real site — the original enumeration counted a comment about
systable_beginscanas a call, and missed five real ones behind a parameter.test: record which build kind measured the scan counts (#1207)
test: sort catalog_delete_index into its C-order place (#1207)
fix: retire a row group through its catalogs' primary keys (#1207)
test: record which build kind measured the scan counts (#1207)
test: sort catalog_delete_index into its C-order place (#1207)
fix: name storage_pkey on both storage_id scans (commandprompt#1237)
fix: retire a row group through its catalogs' primary keys (#1207)
test: record which build kind measured the scan counts (#1207)
test: sort catalog_delete_index into its C-order place (#1207)
9 remaining items
docs: name the ported DROP test in TESTS.md (#1207)
test: the drop fixture premise could not say what it measured (#1207)
test: ledger rows for the four DROP-path arms (#1207)
Merge pull request #1272 from commandprompt/fix/1207-free-space-scans
OffgridwithJD commentedon Sep 25, 2026
The population as it stands today, after #1254, #1272 and #1277
Re-counted on
73e98664, because the header's 21 was measured before threechanges landed and a stale population is what makes this issue hard to finish.
The 13, with the catalog each opens, the key, and whether an existing index
starts with that key:
columnar_metadata.c:1047free_spacestorage_idfree_space_pkey (storage_id, file_offset)columnar_metadata.c:1258free_spacestorage_idcolumnar_metadata.c:1303free_spacestorage_idcolumnar_metadata.c:1760row_groupstorage_id,group_numberrow_group_pkey (storage_id, group_number)columnar_metadata.c:2077storagestorage_idstorage_pkey (storage_id)columnar_metadata.c:2115storagestorage_idcolumnar_metadata.c:2147storagestorage_idcolumnar_metadata.c:2516storagestorage_idcolumnar_metadata.c:4085projection_declarationrelprojection_declaration_pkey (rel, name)columnar_metadata.c:4226projection_declarationrelcolumnar_metadata.c:4301projection_declarationrelcolumnar_metadata.c:4341projection_declarationrelcolumnar_tableam.c:2582pg_constraintconfrelidTwelve of the thirteen are this issue's shape on four catalogs. The thirteenth
is a core catalog and I have not looked at what PostgreSQL indexes
confrelidwith; it should not be folded in on the strength of the othertwelve.
Three things this table is not
It is not a reconciliation. It is a static enumeration, and this issue's own
history says why that is not enough:
delete_rows_by_storage_idtook both thecatalog name and the key attnum as PARAMETERS, so one source line was seven
runtime sites and the stated method could not classify it at all. A grep cannot
see that. The population is only settled once an
elogat every reaching scanprints the relation name and the key it was given, and the printed count
reconciles against this list. The gap would be the finding.
It is not a claim that converting all twelve is right. #1213 and #1217
established that the probe can LOSE on a small catalog — at zero rows the heap
it replaces is zero pages and the scan is free — which is why
pgcolumnar_scan_index_oidexists and is size-aware. Each conversion needs areading on a populated catalog and a reading on an empty one, not an argument
from the table above.
It is not evidence that any of them is hot. #1210's lesson is that the cost
of an unindexed scan is the row's POSITION, and that which sites a query reaches
varies by shape from 0 to 4 arrivals. Four of these twelve are on
storage,which #1210 measured at 37 blocks per arrival on a 37-page catalog — but three
of the four are
RowExclusiveLockwriters, not planner-path readers, andnothing here says how often they run.
My reading of the two script sites, since they surprised me
columnar_metadata.c:2115and:2147are insidePgColumnarInsertNativeStorageRow's neighbourhood and my enumerator could notname their enclosing function within 70 lines — so those two rows are the
weakest in the table and want a human eye before anyone counts on them.
Happy to take this next; not claiming it while #1280 is open.
OffgridwithJD commentedon Sep 26, 2026
The reconciliation: 13 static sites, 13 reached, no gap
Instrumented build on PG 17.10 at
be318742, an appender at each of the 13literal-
InvalidOidsystable_beginscansites recording the relation name, thekey count and the first key's attno. Twenty-one suites over two runs.
The relation names are read from the running relation, not inferred from the
source, so a site that opened something other than what the source suggests
would show it. None did. That is the check #1272 needed and did not have: there
delete_rows_by_storage_idtook the catalog name and the key attnum asparameters, and one source line was seven runtime sites. It is already
converted, and nothing of that shape remains in these 13.
The three that were unreached, and how they stopped being unreached
My first run drove 14 suites and reached 10 sites. A zero is ambiguous: a
site that cannot be reached and a site nothing drove read identically. So the
three were classified from the code before anything else was run —
@jdatcmd asked for exactly this and it is the part I would have skipped.
:2077if (pgcolumnar_bulk_parallel_writer), off unless an opting-in loader sets it:1258pgcolumnar_do_end_truncationatcolumnar_vacuum.c:2815:1303:2784and:2821Each was then driven by the shape its classification named:
parallel_copyandsorted_pathkeysset the GUC;native_truncate,truncate_cleanupand the threenative_reclaim*suites drive end-truncation.All three arrived. So the bucket "reachable but not driven" emptied, and the
bucket "unreachable by construction" turned out to hold nothing — which is a
better answer than either, and it is only available because the classification
came before the second run rather than after it.
What this instrument cannot see, and it matters for the population
The 13 are sites that can never use an index. They are not the runtime
population of sequential scans. The other 34 call sites pass a resolved index
oid, and
pgcolumnar_scan_index_oidreturnsInvalidOidbelowpgcolumnar.index_min_blocks— by design, from #1213 and #1217, because theprobe loses on a small catalog. A converted site under the threshold scans
sequentially at runtime and this probe is blind to it.
So 13 is a lower bound, and any claim of the form "N sequential scans
remain" needs a different instrument from this one.
Two instrument failures, both fail-open, both caught by an implausible value
Recording them because each produced a confident zero.
The first collection read 0 arrivals from 14 green suites. The probe was an
elogand I collected from the suites' server logs after they finished — butlib.sh:603removes$PGC_WORKDIRin its exit trap, so the logs were gone. Theprobe was in the
.so(13 occurrences, checked); the collection could not look.Replaced with an appender to a fixed path outside the harness's reach.
The second run then read 0 for every site, including ones that had taken 1471
arrivals an hour earlier. I had pre-created the output file as root; the
backends run as
postgres,fopenreturned NULL, and the helper returnedsilently.
Both zeros were plausible on their face. Neither was distinguishable from "this
site is never reached" without a number to compare against, and the only reason
I caught the second is that the first run had given me one. The probe now says
so instead, once per backend:
A probe that fails open reports the same zero as the absence it is looking for.
The binary is not the shipped one
An appender at every reaching scan changes the build. The arrival counts
transfer; a block count or a timing from these runs does not, and none is
quoted above. Saying it here rather than in a footnote, because a number from a
compromised apparatus sitting in the record as a property of the code is worse
than no number — it gets cited.
What this does not yet decide
Nothing here says any conversion is worth making. #1213 and #1217 established
the probe loses on a small catalog; three of the four
storagesites areRowExclusiveLockwriters rather than planner-path readers; and an arrivalcount is not a cost. The next step is a per-site reading on a populated catalog
and on an empty one, and only the sites that earn it get converted.
@jdatcmd has offered to attack this table rather than confirm it. The direction
I would most like attacked: the claim that all 13 are reachable, and the claim
that the relation each one opens is the one its source suggests.
jdatcmd commentedon Sep 26, 2026
Attacked both claims. Both hold, by a different method than the one that produced them
Claim 2, that each site opens what its source suggests: verified statically
Enumerating every
InvalidOidsystable_beginscanonmain(be31874) andasking, for each, where its
relcame from:All twelve open a literal catalog name. None is the
delete_rows_by_storage_idshape, so there is no source line standing forseveral runtime relations, and this is independent of your runtime read rather
than a restatement of it. Your measurement says the relations are what the source
says; this says the source cannot be saying more than one thing.
The thirteenth is
columnar_tableam.c:2582, onpg_constraint, opened at:2581bytable_open(ConstraintRelationId, AccessShareLock). A core catalogreached from the FK rejection path, which is why
fk_referencingdrives it.One defect in the table: the rows do not name their file
:2582iscolumnar_tableam.c; the other twelve arecolumnar_metadata.c. Thetable gives bare line numbers, so I mapped
:2582ontocolumnar_metadata.c:2582— which isopen_columnar_table("storage")insidePgColumnarGetSortedInfo— and briefly concluded yourpg_constraintattribution was wrong. It was not; my identifier was ambiguous.
file:lineper row fixes it. This matters more than tidiness because your treecarries the
elogand mine does not, so a bare line number is a coordinate in afile the reader may not have. Twelve of the thirteen happen to match
mainexactly, which is luck rather than a property.
Claim 1, that reachability is not a fixture accident: holds, and the thin row is thinner than the count suggests
The three rows you classified before running are the strongest, because each was
driven by the shape its classification named rather than by whatever a suite
happened to do. The rows I went after are the low counts you did not
classify, where reachability rests on the arrival number alone.
:4226at 2 arrivals is the thinnest. It is inPgColumnarRenameProjectionDeclarationColumn, and it has exactly one caller:So "reachable" there is not a fixture accident in either direction — that call
site is the only path to it, and 2 arrivals is what one
RENAME COLUMNon aprojected table with one child produces. A single-caller site cannot be reached
incidentally; it is reached or the caller is not exercised.
That is a stronger statement than its arrival count, and it is available for any
low-count row: count the callers, not the arrivals. A site with one caller and
2 arrivals is better evidenced than a site with four callers and 400, because the
first has one shape to name and the second has four to account for.
On the lower bound
Agreed and worth keeping prominent. Thirteen sites that can never use an index is
not "thirteen sequential scans remain": a converted site under
index_min_blocksscans sequentially by design (#1213, #1217) and this probecannot see it. Saying so before anyone asks is the difference between a bound and
a census.
On the two fail-open probes
The second is the one worth the note.
fopenreturning NULL because the file wasroot-owned, the helper returning silently, and 0 arrivals for sites that had
taken 1471 an hour earlier — caught only because a prior run had given you a
number to disbelieve. With no prior, that output is indistinguishable from three
genuinely unreached sites, and the rows you would have written would have looked
honest.
That is the eighth or ninth today, and your formulation is the one I would keep:
the artifact, not the exit code, and a number you can disbelieve. The second
clause is the part neither of us had before this week.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP
OffgridwithJD commentedon Sep 26, 2026
Callers per site, which is the better evidence — and my first two attempts at it were wrong
@jdatcmd's point stands and I have taken it: count the callers, not the
arrivals. A site with one caller and 2 arrivals is better evidenced than one
with four callers and 400, because the first has one shape to name.
Three of the four
storagesites are one function.:2077,:2115and:2147are all insidePgColumnarInsertNativeStorageRow, reached from twoplaces in
columnar_write_state.c. That is a single write-path function holdingthree of the thirteen, which changes how the population should be thought about:
they are not three independent conversions, they are one function's three scans.
Every hit is reconciled rather than filtered and trusted:
My first two attempts produced zeros, and only @jdatcmd's number caught them
Both runs of this count reported
:4226as having 0 callers. It has one, atcolumnar_tableam.c:3119, which they had already found independently and saidso — otherwise I would have published a table of zeros with an argument built on
them.
The bug was my filter. I excluded "the definition line" by matching the function
name at the start of the stripped text, and a wrapped call whose arguments
spill to the next line is indistinguishable once the indentation is gone:
A definition starts at column 0; the test has to run against the raw line.
That also swallowed both callers of
PgColumnarInsertNativeStorageRow, which iswhy the three hottest
storagesites read 0 in the same table that gave them1479 and 3437 arrivals — two numbers from the same tree contradicting each other
and no error from either.
The fix is not a better regex. It is that the script now accounts for every
line it drops, by reason, and prints the tally. A filter that discards without
counting is the same defect as a grep that cannot match: the output is a
plausible number with no way to tell what is missing from it.
One correction to what @jdatcmd reported
The bare
:2582that sent them tocolumnar_metadata.c:2582was in my messageto them, not in the table above, which carries
columnar_tableam.c:2582infull. So the artifact was not ambiguous; my summary of it was. The lesson lands
in the same place — an identifier that is only unambiguous in the tree you are
holding is not an identifier — and it is worth being exact about which document
had the defect.
jdatcmd commentedon Sep 26, 2026
Correcting myself: the table was not defective, my source for it was
Above I wrote "One defect in the table: the rows do not name their file". That is
wrong about this issue. The table carries
columnar_metadata.c:NNNNandcolumnar_tableam.c:2582in full, which I have now checked:The bare
:2582that sent me tocolumnar_metadata.c:2582was in@OffgridwithJD's message to me, not in the artifact. They said so and they are
right: "the table is defective" and "my summary of it was not" are different
findings, and only the second is true.
The lesson I drew stands and lands in the same place — an identifier that is only
unambiguous in the tree you are holding is not an identifier — but I attached it
to the wrong document, and a false criticism of someone's work sitting in the
record is worse than no criticism. Withdrawn.
What I should have written: I read a bare line number out of a chat summary
instead of out of the issue, and mapped it onto my own checkout. The table was
right; my source was lossy and I did not go back to the primary one. That is the
same failure as citing
parsenodes.h:3989earlier in this thread, one stepremoved.
The rest of that comment is unaffected: the static verification of claim 2, the
single-caller argument for
:4226, and the note on the lower bound.🤖 Generated with Claude Code
https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP
OffgridwithJD commentedon Sep 27, 2026
How big these catalogs actually get, which orders the work and rules one group out
Before measuring any conversion's cost, the question #1213 and #1217 already
answered for other catalogs: can the probe win here at all? Below
pgcolumnar.index_min_blocks(default 3) the size-aware resolver declinesand the scan stays sequential by design, so a catalog that never reaches 3 pages
cannot pay for a conversion however hot the site is.
PG 17.10,
be318742. 2000 columnar tables at 400 rows, then 400 of themdeleted-from, vacuumed and given a covering projection. Every catalog vacuumed
before reading
relpages— see below for why that matters.What this orders
free_spaceis empty in every shape I could drive, including 400 tablesdeleted-from and vacuumed. Its three sites scan a zero-page catalog, which
is exactly the case #1217 showed the probe cannot win: the heap it replaces is
zero pages and the scan is free. On this evidence those three are not worth
converting, and that is a measurement rather than a judgement.
I could not produce a non-empty
free_space, so the honest statement is emptyin every shape I drove, not always empty. Something must fill it — the three
functions exist — and whoever knows that shape should say so, because it changes
this row.
row_groupat 32 pages andstorageat 22 are both well clear of thethreshold. Those five sites are where a conversion can pay.
projection_declarationat 8 pages clears 3 but not by much, and it scaleswith the number of declared projections, not with data. Its four sites are the
marginal group.
Two corrections to my own numbers
relpagesis stale unless you vacuum. My first pass readzone_mapas 0pages against 9600 rows and
column_chunkas 54, because myVACUUMlist didnot include them. After vacuuming: 142 and 80. A zero page count beside
a five-figure row count should have stopped me, and did — but only because the
two were printed side by side. Every number above is post-vacuum.
storageandrow_groupeach grew by 400 when the projections were added,2000 to 2400. That is #1210's finding arriving from a different direction: a
covering projection gets its own storage row. It is a useful independent
confirmation and it means
storagegrows with projections as well as withtables.
One fixture that produced nothing, silently
My first attempt at the delete/vacuum/projection loop was a
plpgsqlDOblockwith nested dollar quoting passed through a shell heredoc. It ran, returned no
error to the caller, and changed nothing — the whole block was one failed
statement.
free_spaceandprojection_declarationboth read 0 afterwards,which is the same reading the correct fixture gives for
free_space.Rewritten as one statement per
psql, generated in bash: 1200 statements, 1error (
projection "p" already exists on "sz1", from an earlier single-iterationtest), and
projection_declarationmoved 0 to 400. Without that rewrite I wouldhave reported
projection_declarationas empty and drawn the same conclusionabout its four sites that I now draw only about
free_space's three.jdatcmd commentedon Sep 27, 2026
The shape that fills
free_space, and why I think that row is wrongYou asked, and said it is the row you are least able to check because you would be
looking for the absence of something. There are exactly two insert paths:
The second cannot bootstrap: it only splits a free range that already exists. So
everything in
free_spaceoriginates from row-group retirement, and:617states the condition:
Retirement needs the whole group dead. A delete that leaves one live row in a
group retires nothing, so
free_spacestays empty however much you deleted.Which is the trap you already found this morning, on this catalog
From your own #1272 work:
So
free_space 0 pages / 0 rowsis very likely the fixture again rather than thecatalog. "400 of them deleted-from" does not say contiguously, covering whole
groups, and a modulo or scattered delete gives exactly the reading you got.
The check that distinguishes them, before any page count: after the delete and
vacuum, does
pgcolumnar.statsshow groups actually gone? If the group countis unchanged, nothing retired and
free_spacewas never reachable in that shape.I would treat the three
free_spacesites as unmeasured rather thannot-worth-converting until a fixture retires a group. That is the one row where
your conclusion and a dead fixture produce the same number, which is the condition
you named in the same message as the reason a dead fixture survives inspection.
Two of your findings I want to keep
relpagesis stale without a vacuum, and you caughtzone_mapreading 0pages against 9600 rows only because the row count was printed beside it. A page
count alone would have gone in as a real zero. That is the "print both sides"
rule arriving in a third place today —
11 of 1787,arrivals with a control,and now
pages beside rows.The broken fixture and the correct one agreed on one of the two catalogs.
projection_declarationmoved 0 to 400 when you rewrote theDOblock, andfree_spacestayed 0 either way. Partial agreement is what lets a dead fixturepass inspection, and it is a sharper statement of the problem than any of the four
instances we hit separately.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP
OffgridwithJD commentedon Oct 12, 2026
You were right: my
free_spacerow was the fixture. Corrected, with retirement asserted firstPG 17 assert build at
be318742(.somd5464a126f, installed copy identical).stripe_row_limit=1000, autovacuum off. 400 tables of 4000 rows (4 groups each), plus 400 control tables. Each claim below is printed next to the premise it depends on.The control arm is my original reading. A scattered delete retires 0 groups, so it gives the same zero as a catalog that is truly empty. Retiring adjacent groups coalesces them into one row (800 groups give 400 rows). Non-adjacent ranges stay separate (800 groups give 800 rows). In both arms the catalog takes 6 pages.
I had a stale
relpagesagain in the first pass. My VACUUM was a multi-statement-c, so it failed (cannot run inside a transaction block) andrelpagesread 0 beside 400 rows. The 6 above is frompg_relation_sizeand agrees withrelpagesonce the VACUUM ran properly.What this means for the three sites
free_spaceonly grows under an insert-only workload. After the retirement I inserted 1000 more rows into each of the 400 tables. Rows and bytes did not change (400 / 19603200 before and after). That is by design.columnar_write_state.c:3132reuses freed space only when the writer holdsShareUpdateExclusiveLock, i.e. during online compaction. Plain inserts always append. So retirements accumulate until a compaction consumes them or end-truncation removes them.It clears the 3-page threshold at a few hundred retired ranges across the whole catalog. The scan keys on
storage_id, but its cost is the size of the whole heap. 800 ranges took 6 pages. I have not measured the exact crossover.All three sites are maintenance paths, not query paths:
So I am changing the row from not worth converting to reachable, low frequency. The catalog does grow past the threshold.
:1047, the hot one of the three, runs once per group an online compaction writes, which makes it a fullfree_spacescan per group written. A compaction of G groups against R free ranges is O(G·R). That is the only case where I would expect conversion to show up in a measurement, and I have not timed it.What this does not cover: the cost of
:1047under a realcompact_rewrite, and what happens at sizes beyond 400 tables.fix: twelve metadata lookups read their whole catalog (#1207)
Merge pull request #1285 from OffgridwithJD/fix/1207-catalog-scans-by…