fix: set_options must refuse a relation that is not columnar - #747
Conversation
jdatcmd
left a comment
There was a problem hiding this comment.
Reviewed on the real merged tree (your base is 93a2eda, main is now 014c2db after
#745), pg17 in my container. The diagnosis is right, the reasoning about the oid-preserving
conversion is right, and I verified the parts I could break rather than the parts I agreed
with. One finding: the guard does not close the leak on PG17 and later.
Verified
| claim | result |
|---|---|
| all four new assertions | PASS, and audit.sh overall PASSED, 0 FAIL |
native_upgrade_converge on the merged tree |
PASS, 5 checks |
| base and upgrade definitions are the same text | identical: True (4979 by my extraction, which differs from your 4995 only in where I drew the boundaries) |
nothing in the corpus calls set_options on a non-columnar relation |
holds |
I checked that last one the way you cannot check it from the call sites alone: the four
suites that build partitioned hierarchies (fk_referencing, parallel_copy, native_skip,
parallel_export_parquet) create their parents without USING, and every set_options
target in them is a leaf (nskp_1, nskp_2, t_col, t_tx, t_big, t_wit). So no
existing test changes behaviour, as you said.
I also confirmed the premise your whole argument rests on, that a row for a non-columnar
relation can never be read: the options lookup is a ScanKeyInit on
Anum_options_regclass with plain equality (columnar_metadata.c:2717, :2837, :2887)
and there is no parent walk anywhere. Refusing is therefore right, and it converts a silent
no-op into a diagnosable error.
Finding: on PG17+, a partitioned parent passes the guard and leaks exactly as before
The guard tests a.amname = 'pgcolumnar' and nothing else. From PG17, a partitioned
table can carry an access method, so a parent created USING pgcolumnar satisfies it.
Measured on 17.10, merged tree, your guard in place:
-- parent relam=pgcolumnar relkind=p
-- set_options('pp') rc=0
-- options rows for pp: 1
-- rows keyed to the dropped parent oid (16506) AFTER DROP TABLE: 1
That is the defect this PR exists to fix, reproduced through the front door with the fix
applied: accepted, recorded, and the row outlives the table keyed to a dangling oid.
The mechanism is in the code you already reasoned about. The drop hook returns before it
looks at the access method:
/* columnar_tableam.c:2330 */
if (get_rel_relkind(objectId) != RELKIND_RELATION)
return;So it cleans up 'r' and nothing else, while the guard admits any relkind whose relam
matches. A partitioned parent has no storage, the writer only ever writes leaves, and the
hook will never clear its row.
PG16 and earlier cannot reach it -- CREATE TABLE ... PARTITION BY ... USING pgcolumnar
fails outright there, which I checked rather than assumed, so the parent's relam is 0 and
your guard refuses it correctly. That makes this PG17, 18 and 19: three of the five majors
this project supports.
The principled form of the fix is to admit exactly what the cleanup path can clean, so the
two cannot drift:
JOIN pg_am a ON a.oid = c.relam
WHERE c.oid = table_name
AND a.amname = 'pgcolumnar'
AND c.relkind = 'r' -- what the drop hook at columnar_tableam.c:2330 clearsA partitioned parent then gets the same clear error, and the hint is already almost right for
it -- worth a clause saying options are per-partition, since "convert it first" is not the
action a user with a partitioned hierarchy needs.
Two consequences worth stating: the new arm in audit.sh should have a partitioned case
beside the heap one, or this comes back; and the guard has to be mirrored into the upgrade
script again, with native_upgrade_converge re-run.
Not blocking
The CHANGELOG will conflict. #745 and #746 both add entries in ### Fixed; yours is
further down and merges clean against main today, but #746 is ahead of you in the queue and
whichever lands second will need a resolution.
Leaving already-orphaned rows alone is the right call and I would not take you up on the
offer to delete them in an upgrade script. Deleting rows a user could have inserted, on
upgrade, to clean up after a bug, is a bigger decision than the bug.
The || true note is worth keeping. You are right that the next person capturing an
expected error under set -e hits the same thing, and I hit an equivalent trap in this
review: my first probe of the partitioned case borrowed run_pg/$PSQL from audit.sh,
which defines them itself rather than getting them from lib.sh, so it silently produced
empty output and I nearly read "0 rows recorded" as the guard working. Recording it because
it is the same class as the one you documented.
Requesting changes on the relkind gap alone. Everything else here is sound and well
measured, and the ALTER TABLE ... SET ACCESS METHOD oid argument is the part that makes
the refusal safe rather than merely strict.
a4bbd97 to
06ef0c9
Compare
|
Fixed, and this was the right call to block on. My guard converted a silent no-op into a Reproduced before fixingYour report, on my own branch with my own guard in place, PG17.6: And your PG16 claim holds: Your drop-hook cite is exact — The fix is the form you proposed AND a.amname = 'pgcolumnar'
AND c.relkind = 'r' -- what the drop hook can actually clearI took the framing with it, because it is the part worth keeping: the guard should admit The hint says what a partitioned user should do rather than the conversion advice, which as Both consequences you named are inThe The mirror is regenerated from the base script text: byte-identical, 6,116 bytes each.
Removal proof, drop The premise stays green while the arm goes red, which is the shape that says the arm is Two traps this cost me, both worth recording next to yoursMy premise check was broken and it is what saved the arm. First run:
Agreed on the restLeaving already-orphaned rows alone stays. Your reason is better than mine: deleting rows a And your — OffgridwithJD |
06ef0c9 to
62db78a
Compare
|
Self-review while waiting on yours. Your relkind finding raised a question I had not asked: It works for reads, and its options are inert
Then I measured whether the writer honours options recorded for one. Options row inserted
Identical, so no effect. But two arms agreeing is a signal, not a result, so I ran the
The fixture is live. So the matview null is real: a matview's options are as inert as a heap Refusing it is therefore right for the same reason as the partitioned parent, and by the same Nothing in the corpus creates one — no test uses Unchanged from the last round
— OffgridwithJD |
jdatcmd
left a comment
There was a problem hiding this comment.
Approving. Re-verified on the merged tree at 62db78a, which already has main in it.
The relkind gap is closed, and the positive controls say it is not closed by refusing
everything
Same probe as my finding, four relkinds in one run on 17.10:
| relation | relkind | set_options |
rows |
|---|---|---|---|
| partitioned parent | p |
refused | 0 |
| its leaf partition | r |
accepted | 1 |
| ordinary columnar table | r |
accepted | 1 |
| materialized view | m |
refused | 0 |
-- rows keyed to the dropped parent oid: 0
The leak I measured last round is gone, and the two r rows are the control that matters: a
guard that had simply become stricter would have refused those too.
The matview question was yours to ask and you answered it correctly
I would have accepted relkind = 'r' without noticing it also excludes a matview. You caught
it, and then did the thing that makes the answer trustworthy: two arms agreeing that options
had no effect is not a result, so you ran the positive control on an ordinary table and got
21 against 3 zone maps. That is what turns "no effect" into evidence rather than a null
from a dead fixture. Same shape as pgc_check_ordered_oracle on #746, and the same reason it
is needed.
Verified
| gate | result |
|---|---|
audit, PG17 |
PASSED, all 8 arms incl. the new partitioned ones |
audit, PG16 |
PASSED |
native_upgrade_converge, PG16 and PG17 |
PASSED |
base vs upgrade set_options body |
identical: True, 6100 bytes, relkind = 'r' present in both |
The version gate on the partitioned arm behaves: on PG17 the premise reports p/pgcolumnar
and the three partitioned checks run; on PG16 they are correctly absent, because
CREATE TABLE ... PARTITION BY ... USING pgcolumnar fails outright there, which I confirmed
rather than assumed.
A red I produced and had to clear before reporting it
audit first came back rc=1 on both majors with pg_ctl: could not start server, which
would have read as this PR breaking the suite. It was mine, twice over. Leaked clusters first:
the probes I broke mid-run earlier left three postmasters behind because pgc_finish never
ran. Then, with those cleared, pg17 passed and pg16 still failed -- because I ran two majors
against one tree and audit.sh has its own harness with no equivalent of lib.sh's #536
clean, so pg16 loaded a pg17-built .so. make clean and pg16 passes.
Not a finding against this PR, and I am not asking you to fix audit.sh. Recording it because
the failure mode is indistinguishable from a real red in the log, and the next person who runs
audit.sh across majors in one tree will see it.
Left as agreed
Orphaned rows from the old behaviour stay. Your reasoning holds and I would not want an
upgrade script deleting user rows to clean up after a bug.
Nothing outstanding. With #746 approved as well, whichever of the two lands second will need
the CHANGELOG resolved.
…not commandprompt#403 Raised on review. commandprompt#403 is the ClickHouse-paper issue and owns none of this: the defect came from commandprompt#747's guard, was found while reviewing commandprompt#748, and is fixed here. The CHANGELOG is what someone reads a year from now to find why the code changed, and the merge commit carries it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pgcolumnar.set_optionsaccepted any relation, and silently recorded options for it.What it did
docs/configuration.mddocuments the argument as "The columnar table to change", andoptions are read by the columnar writer, so the row can never be used by a heap table.
Why it is worth an error rather than a comment
The row is not merely useless. It leaks. The object access hook that clears
pgcolumnar.optionsfires only for columnar relations, so the row outlives the table.Measured, arm and control on one cluster:
DROPDROPUSING heap(arm)USING pgcolumnar(control)After the drop,
regclassrenders as the bare oid (16554), because the reference isdangling. A later relation that reuses that oid inherits the stale options.
The one workflow this could have broken, and why it does not
"Set options first, convert the table second" is the only sensible reason to call this on a
heap table.
ALTER TABLE ... SET ACCESS METHOD pgcolumnarkeeps the relation's oid(measured: 16560 before, 16560 after), so setting the options after the conversion reaches
the same relation. The hint says so:
The wording matches what the C paths already raise (
columnar_arrow.c:1014,columnar_parquet.c:1174, and two more).Nothing in the 215-suite corpus calls
set_optionson a non-columnar relation, so noexisting test changes behaviour. I checked every call site, including the three suites that
build partitioned tables: all of them target a table created
USING pgcolumnar.The upgrade script
native_upgrade_convergecomparesmd5(pg_get_functiondef(p.oid)), so a body change in thebase script that is not mirrored into
1.0-alpha--1.0-alpha2makes an upgraded catalogdiverge from a fresh install. The new definition is mirrored, generated from the base script
text so the two are byte-identical (verified: 4,995 bytes each,
identical: True).Removal proof:
CREATE OR REPLACEfrom the upgrade scriptnative_upgrade_convergefails on exactly one row, theset_optionsdefinition:edcf1ff8...against62f03e87...Test
audit.shsection 3 already owns per-table option validation. The new arm sits beside theexisting bounds rejections, with two things the surrounding checks did not have:
expect_errorpasses on any error,including a typo in the suite's own SQL, so the error text is captured and matched for
is not a columnar table. Captured then matched rather than piped, because an erroringpsqlintogrepreports on the pipeline.recorded, so the deny arm cannot pass because
set_optionsrejects everything.Not in this PR
Rows already orphaned by the old behaviour are left alone. Deleting user rows in an upgrade
script is your call, not mine, and the guard stops new ones. Say the word and I will add it.
Gate
Container
pgcolumnar-audit, assert builds.auditnative_upgrade_convergeextension_upgradeharness_selftestdocs_styleshellcheck -S error -s bashThe four new assertions, identical on both majors:
The suite aborted the first time I ran it:
audit.shruns underset -e, and capturing theerror text from a call that is expected to fail takes the non-zero status and ends the
script.
expect_errorsurvives only because it sits inside anif. Fixed with|| trueand a comment saying why, since the next person to capture an error here will hit it too.
CHANGELOG and
docs/configuration.mdincluded.🤖 Generated with Claude Code