Skip to content

storage.relation_oid is orphaned by ALTER COLUMN TYPE, silently disabling the written-stripe-limit lookup #1211

Description

@jdatcmd

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions