Summary
pgcolumnar.storage.row_group_limit records the row-group geometry a table was actually written with. Nothing reads it back. The cost model instead calls pgcolumnar_effective_stripe_row_limit(), which returns the per-table option if set and otherwise the planning session's GUC — so a table written while pgcolumnar.stripe_row_limit differed is costed against a geometry it does not have.
I found this while measuring #803 and it invalidated one of my own fixtures, which is how it surfaced.
Measured
Two tables, same 400,000 rows, same shape. ga written under SET pgcolumnar.stripe_row_limit = 20000; gb written under the default. Geometry read from the catalog, R read from an instrumented build that logs the penalty's own inputs:
table storage.row_group_limit actual row groups rows/group planner's R
ga 20000 20 20000 150000 <- 7.5x wrong
gb 150000 3 100000/150000 150000 <- agrees
gb is the control: where the write-time and plan-time values coincide, R is right. Only ga diverges, and it diverges by exactly the ratio of the two settings.
Why it matters
R is not a minor term. In pgcolumnar_index_fetch_penalty (src/columnar_customscan.c:1850) it drives four things at once:
R = pgcolumnar_effective_stripe_row_limit(relid);
n_groups = ceil(N / R);
pages_per_stripe = ceil(rel->pages / n_groups);
decode_per_group = seq_page_cost * pages_per_stripe + cpu_operator_cost * R * nproj;
groups_min = ceil(rows / R);
decoded_width = decodedWidth * R; /* the 32 MB fetch-cache cap test */
With R 7.5x too large the model believes the table has 3 groups of 150,000 when it has 20 groups of 20,000. It overstates the per-group decode CPU by 7.5x, understates the group count by 7.5x, and — the part that worries me most — feeds the wrong number into the cap test, so decodedWidth * R > COLUMNAR_FETCH_CACHE_MAX_BYTES can come out wrong in either direction. A table whose groups genuinely overflow the cache can be judged to fit, and vice versa. That branch is the one #359 added, and #795 is currently open on how thin its margin is.
The same call site pattern is at src/columnar_customscan.c:2193.
The value is already there
storage.row_group_limit is populated on write (src/columnar_metadata.c:1872, from writeState->stripeRowLimit) and, on a grep of src/, is read by nothing:
src/columnar_metadata.c:69 #define Anum_native_storage_row_group_limit 5
src/columnar_metadata.c:1872 values[...] = Int32GetDatum(s->rowGroupLimit); <- written
src/columnar_write_state.c:684 s.rowGroupLimit = stripeRowLimit; <- written
src/columnar_write_state.c:2765 s.rowGroupLimit = writeState->stripeRowLimit; <- written
src/columnar.h:255 int rowGroupLimit;
This is the same class as #7/#11, one level deeper
pgcolumnar_effective_stripe_row_limit's own comment says why it exists:
The writer honors the per-table override, so every consumer that reasons about how many row groups a table has (the writer, the zone-map survival estimate, the index-fetch cost penalty) must read the same effective value. Reading only the GUC mis-sizes the group count for a table that set the option (#7 / #11).
That fixed option versus GUC. It does not fix written versus current: the writer read the value in force at write time, and the planner reads the value in force now. They are the same function and still disagree whenever the setting changed in between — which includes the ordinary case of a session that sets the GUC to load a table and a later session that queries it.
What I am not claiming
- No measured plan change. I have not shown a query that picks the wrong plan because of this; I stopped at proving the input is wrong. The 7.5x is in the model's input, not in a demonstrated regression.
- Whether a single scalar is even the right thing to read is a design question.
gb shows one table holding groups of 100,000 and 150,000, so no single R describes an existing table exactly. storage.row_group_limit is at least the value the writer used, which is strictly closer than a setting from an unrelated session.
Reproduction
SET pgcolumnar.stripe_row_limit = 20000;
CREATE TABLE ga (id int, scat int, c1 int, c2 int) USING pgcolumnar;
INSERT INTO ga SELECT g, (g*2654435761::bigint % 1000000)::int, g, g+1
FROM generate_series(1,400000) g;
-- geometry as written: 20 groups of 20,000, limit recorded as 20000
SELECT s.row_group_limit, count(rg.*), min(rg.row_count), max(rg.row_count)
FROM pgcolumnar.storage s JOIN pgcolumnar.row_group rg USING (storage_id)
WHERE s.relation_oid = 'ga'::regclass GROUP BY s.row_group_limit;
Then plan any index-fetching query on ga from a session at the default and the penalty is computed with R = 150000. Reading R requires instrumentation; I used a build that logs the penalty's inputs (.so 3119b5c50c59, up/main 35846a1 plus one elog), which is not a build to draw any timing conclusion from.
Filed by OffgridwithJD. Container pgcolumnar-audit, PostgreSQL 18.4 assert build. Geometry from the pgcolumnar.storage / pgcolumnar.row_group catalogs; R from the instrumented build named above, with gb as the agreeing control.
Summary
pgcolumnar.storage.row_group_limitrecords the row-group geometry a table was actually written with. Nothing reads it back. The cost model instead callspgcolumnar_effective_stripe_row_limit(), which returns the per-table option if set and otherwise the planning session's GUC — so a table written whilepgcolumnar.stripe_row_limitdiffered is costed against a geometry it does not have.I found this while measuring #803 and it invalidated one of my own fixtures, which is how it surfaced.
Measured
Two tables, same 400,000 rows, same shape.
gawritten underSET pgcolumnar.stripe_row_limit = 20000;gbwritten under the default. Geometry read from the catalog,Rread from an instrumented build that logs the penalty's own inputs:gbis the control: where the write-time and plan-time values coincide,Ris right. Onlygadiverges, and it diverges by exactly the ratio of the two settings.Why it matters
Ris not a minor term. Inpgcolumnar_index_fetch_penalty(src/columnar_customscan.c:1850) it drives four things at once:With
R7.5x too large the model believes the table has 3 groups of 150,000 when it has 20 groups of 20,000. It overstates the per-group decode CPU by 7.5x, understates the group count by 7.5x, and — the part that worries me most — feeds the wrong number into the cap test, sodecodedWidth * R > COLUMNAR_FETCH_CACHE_MAX_BYTEScan come out wrong in either direction. A table whose groups genuinely overflow the cache can be judged to fit, and vice versa. That branch is the one #359 added, and #795 is currently open on how thin its margin is.The same call site pattern is at
src/columnar_customscan.c:2193.The value is already there
storage.row_group_limitis populated on write (src/columnar_metadata.c:1872, fromwriteState->stripeRowLimit) and, on a grep ofsrc/, is read by nothing:This is the same class as #7/#11, one level deeper
pgcolumnar_effective_stripe_row_limit's own comment says why it exists:That fixed option versus GUC. It does not fix written versus current: the writer read the value in force at write time, and the planner reads the value in force now. They are the same function and still disagree whenever the setting changed in between — which includes the ordinary case of a session that sets the GUC to load a table and a later session that queries it.
What I am not claiming
gbshows one table holding groups of 100,000 and 150,000, so no singleRdescribes an existing table exactly.storage.row_group_limitis at least the value the writer used, which is strictly closer than a setting from an unrelated session.Reproduction
Then plan any index-fetching query on
gafrom a session at the default and the penalty is computed withR = 150000. ReadingRrequires instrumentation; I used a build that logs the penalty's inputs (.so 3119b5c50c59,up/main 35846a1plus oneelog), which is not a build to draw any timing conclusion from.Filed by OffgridwithJD. Container
pgcolumnar-audit, PostgreSQL 18.4 assert build. Geometry from thepgcolumnar.storage/pgcolumnar.row_groupcatalogs;Rfrom the instrumented build named above, withgbas the agreeing control.