ALTER TABLE ... ALTER COLUMN TYPE leaves pgcolumnar.storage.relation_oid
pointing at the dropped transient rewrite relation. The row survives, the data
survives, and the one lookup keyed on that column silently finds nothing from
then on.
Found while attributing the unindexed catalog scans for #1207. It is not a
performance finding and it changes what #1210 should build, so it is filed
separately.
Measurement
Premise, action, control, all in one run on ca3089fe:
PREMISE: a written limit exists and the row points at the live table
oid_of_t=64874
storage: relation_oid=64874 row_group_limit=1234
lookup by live oid finds: 1
ACTION: ALTER TABLE t ALTER COLUMN id TYPE bigint;
AFTER:
oid_of_t=64874 relfilenode=64881
storage: relation_oid=64881 row_group_limit=150000
lookup by live oid finds: 0
relation_oid resolves in pg_class: 0
rows still readable: 5000
CONTROL: a table that was never rewritten
lookup by live oid finds: 1
64881 is the transient relation PostgreSQL creates for the rewrite. Its OID
becomes the table's new relfilenode and its pg_class row is then dropped, so
the stored value resolves to nothing — relation_oid resolves in pg_class: 0.
The writer is columnar_write_state.c:756, s.relationOid = RelationGetRelid(rel), which is correct at the time it runs: during a rewrite
the relation it is handed is the transient one.
row_group_limit reverts to the default in the same step. That part is honest —
the rewrite genuinely ran under the session default — but it means the recorded
geometry is lost twice over.
Which paths are affected
ALTER COLUMN TYPE premise_before=1 after=0 orphaned_rows=1 data=3000
VACUUM FULL premise_before=1 after=1 orphaned_rows=0 data=3000 ERROR: columnar: CLUSTER and VACUUM FULL are not supported
CLUSTER premise_before=1 after=1 orphaned_rows=0 data=3000 ERROR: columnar: CLUSTER and VACUUM FULL are not supported
TRUNCATE + reinsert premise_before=1 after=1 orphaned_rows=0 data=10
ADD COLUMN DEFAULT premise_before=1 after=1 orphaned_rows=0 data=3000
SET ACCESS METHOD heap premise_before=1 after=0 orphaned_rows=0 data=3000
no-op control premise_before=1 after=1 orphaned_rows=0 data=3000
Only ALTER COLUMN TYPE orphans a row. SET ACCESS METHOD heap also ends at
after=0, but with orphaned_rows=0 — the row is deleted, which is right,
since the table is no longer columnar. The no-op control holds at 1, so
after=0 is not something this fixture produces on its own.
A note on the two ERROR lines, because they nearly cost me the finding. My
first pass read "VACUUM FULL leaves relation_oid intact" as evidence that the
rewrite path was selective. It is not evidence of anything: the extension
refuses VACUUM FULL and CLUSTER, so nothing ran. The premise column is
what separated "unaffected" from "never executed".
Consequence
storage.relation_oid has exactly one writer and one reader:
src/columnar_metadata.c:1979 values[Anum_native_storage_relation_oid - 1] = ObjectIdGetDatum(s->relationOid);
src/columnar_metadata.c:3335 ScanKeyInit(&key[0], Anum_native_storage_relation_oid, ...);
The reader is pgcolumnar_written_stripe_row_limit(). After a type rewrite it
matches no row and returns 0, so the cost model loses the written-geometry term
for that table permanently — until the next rewrite happens to run under a
session that sets the GUC. Nothing errors and no query returns a wrong answer;
the plan is simply costed without the term. That is why it has gone unnoticed.
What it means for #1210
@OffgridwithJD — this answers the question you left open and it answers it
awkwardly. relation_oid is unique: no duplicate survived ALTER COLUMN TYPE, TRUNCATE, ADD COLUMN ... DEFAULT, SET ACCESS METHOD, or an aborted
first write. So a UNIQUE index is buildable.
But uniqueness was the wrong question to be blocked on, and that is my doing as
much as yours — I would have gone looking for the same thing. The column is
unique and stale, so an index on it makes a lookup that returns the wrong
answer return it faster. Your measurement in #1210 stands exactly as it is: the
scan is real, it is 2 x pages, and position not size is the driver. It is the
remedy that needs a step in front of it — keep relation_oid current across a
rewrite — before the index is worth adding. I would rather fix that first and
then index, than index and inherit this.
I will take both, since they are one change to one column and I have the
upgrade-script machinery out for #1207 anyway.
Repro
CREATE EXTENSION pgcolumnar;
SET pgcolumnar.stripe_row_limit = 1234;
CREATE TABLE t (id int) USING pgcolumnar;
INSERT INTO t SELECT g FROM generate_series(1,5000) g;
SELECT count(*) FROM pgcolumnar.storage WHERE relation_oid = 't'::regclass::oid; -- 1
ALTER TABLE t ALTER COLUMN id TYPE bigint;
SELECT count(*) FROM pgcolumnar.storage WHERE relation_oid = 't'::regclass::oid; -- 0
SELECT count(*) FROM pgcolumnar.storage s
WHERE NOT EXISTS (SELECT 1 FROM pg_class c WHERE c.oid = s.relation_oid); -- 1
Verified on PG18, ca3089fe, clean main build (probe residue checked at 0
after restoring the container install).
🤖 Generated with Claude Code
https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
ALTER TABLE ... ALTER COLUMN TYPEleavespgcolumnar.storage.relation_oidpointing at the dropped transient rewrite relation. The row survives, the data
survives, and the one lookup keyed on that column silently finds nothing from
then on.
Found while attributing the unindexed catalog scans for #1207. It is not a
performance finding and it changes what #1210 should build, so it is filed
separately.
Measurement
Premise, action, control, all in one run on
ca3089fe:64881is the transient relation PostgreSQL creates for the rewrite. Its OIDbecomes the table's new relfilenode and its
pg_classrow is then dropped, sothe stored value resolves to nothing —
relation_oid resolves in pg_class: 0.The writer is
columnar_write_state.c:756,s.relationOid = RelationGetRelid(rel), which is correct at the time it runs: during a rewritethe relation it is handed is the transient one.
row_group_limitreverts to the default in the same step. That part is honest —the rewrite genuinely ran under the session default — but it means the recorded
geometry is lost twice over.
Which paths are affected
Only
ALTER COLUMN TYPEorphans a row.SET ACCESS METHOD heapalso ends atafter=0, but withorphaned_rows=0— the row is deleted, which is right,since the table is no longer columnar. The no-op control holds at 1, so
after=0is not something this fixture produces on its own.A note on the two
ERRORlines, because they nearly cost me the finding. Myfirst pass read "VACUUM FULL leaves
relation_oidintact" as evidence that therewrite path was selective. It is not evidence of anything: the extension
refuses
VACUUM FULLandCLUSTER, so nothing ran. The premise column iswhat separated "unaffected" from "never executed".
Consequence
storage.relation_oidhas exactly one writer and one reader:The reader is
pgcolumnar_written_stripe_row_limit(). After a type rewrite itmatches no row and returns 0, so the cost model loses the written-geometry term
for that table permanently — until the next rewrite happens to run under a
session that sets the GUC. Nothing errors and no query returns a wrong answer;
the plan is simply costed without the term. That is why it has gone unnoticed.
What it means for #1210
@OffgridwithJD — this answers the question you left open and it answers it
awkwardly.
relation_oidis unique: no duplicate survivedALTER COLUMN TYPE,TRUNCATE,ADD COLUMN ... DEFAULT,SET ACCESS METHOD, or an abortedfirst write. So a
UNIQUEindex is buildable.But uniqueness was the wrong question to be blocked on, and that is my doing as
much as yours — I would have gone looking for the same thing. The column is
unique and stale, so an index on it makes a lookup that returns the wrong
answer return it faster. Your measurement in #1210 stands exactly as it is: the
scan is real, it is
2 x pages, and position not size is the driver. It is theremedy that needs a step in front of it — keep
relation_oidcurrent across arewrite — before the index is worth adding. I would rather fix that first and
then index, than index and inherit this.
I will take both, since they are one change to one column and I have the
upgrade-script machinery out for #1207 anyway.
Repro
Verified on PG18,
ca3089fe, cleanmainbuild (probe residue checked at 0after restoring the container install).
🤖 Generated with Claude Code
https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n