fix: CREATE MATERIALIZED VIEW WITH DATA orphaned its storage row (#1275) - #1280
Conversation
CREATE MATERIALIZED VIEW ... USING pgcolumnar ... WITH DATA builds a
transient relation, fills it and swaps, exactly as a rewriting ALTER does.
The storage row was written with the transient's OID and the swap left it
naming a relation that no longer existed:
the row named a dropped 16527 while the matview was 16523
REFRESH MATERIALIZED VIEW already repaired it, because RefreshMatViewStmt
is one of the node types the repair gate covers. CreateTableAsStmt was not,
so a matview stayed broken between CREATE and its first REFRESH.
RESTRICTED TO objtype == OBJECT_MATVIEW, AND A CONTROL SAYS WHY.
CREATE TABLE ... AS ... USING pgcolumnar is the same parse node and does
NOT have the defect -- measured, its storage row points at itself, because
it fills the relation it created instead of swapping a transient in.
Without that control the fix would reasonably have been written for the
node type as a whole, which is broader than anything measured asked for.
test/rewrite_storage_oid.sh asserts it as a premise, so the narrowing is
refused the day it stops being true rather than being silently wrong.
ITS OWN ARM RATHER THAN THE EXISTING CAST. The comment already standing
there warns that AlterTableStmt and RefreshMatViewStmt share a field offset
"by coincidence of layout, not by any rule"; CreateTableAsStmt does not
share it at all, reaching its relation through ->into->rel.
pgcolumnar_rewritten_relids is EMPTY at that point (measured), so the
recorded-list route the TRUNCATE path uses is unavailable and the
statement's own name is the only handle.
Step 1 measured before any code was written, with the node kind, the depth,
the recorded list and the resolved relid printed per statement:
CREATE MATERIALIZED VIEW ... WITH DATA CreateTableAs/MATVIEW recorded=0 into_rel=16523
CREATE TABLE ... AS (columnar) CreateTableAs/TABLE recorded=0 into_rel=16532
REFRESH MATERIALIZED VIEW (control) RefreshMatView recorded=0 into_rel=0
ALTER TABLE ... TYPE (control) <nothing>
The ALTER control DID NOT RUN -- it is refused by the matview's dependency
on the column, so the statement errors and the probe, which sits after the
PG_TRY block, never executes. Evidence of nothing, and recorded as such
rather than retried a third way.
Removal proofs, both harnesses, with the mutation asserted on the host AND
in the container copy (0 occurrences of CreateTableAsStmt in each):
shell -- mv=0 cta=1
FAIL a matview created WITH DATA leaves its storage row
pointing at itself: got [0] want [1]
10 passed + 1 failed
pytest AssertionError: ... got 0 want 1
Green: -- mv=1 cta=1, 11 passed + 0 failed, and the pytest twin 11 checks.
Neighbours on PG17: projection_rewrite 84/84, preimage_rewrite 86/86,
alter_am_cleanup 45/45, truncate_cleanup 25/25, native_rewrite 17/17,
drop_cleanup 16/16, native_ctas 12/12, projection_rename_restore 8/8,
rewrite_group_scan 5/5.
Ledger regenerated from five majors, each rc=0 with 11 RESULT records and
one source fingerprint: rows 1804 -> 1810, never 1627 -> 1633, budget
updated to match the census, 6 added and 0 removed or altered.
was still wrong for every other reader until this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
) `pytest (cluster tests, PG 18)` went red on this branch with one failure: premise: the matview's relation_oid still finds no storage row: got 1 want 0 THAT IS THE PREMISE DOING ITS JOB. #1276 added that arm to record a behaviour change -- a WITH DATA matview had an orphaned relation_oid, so analyze() keyed on that column refused a matview holding rows while the metapage route resolved it -- and its premise asserted the orphan was still there. This change removes the orphan, so the premise reddened instead of letting an arm pass quietly under a claim that no longer meant anything. WHAT IS LEFT TO GUARD, AND WHAT IS NOT. With the orphan gone both routes resolve, so that shape no longer distinguishes them and is NOT evidence for #1276's fix -- the projection arms beside it are, and they are untouched. What it still holds is that analyze() reaches a freshly created matview at all, which was an outright error before #1276, and that the two routes agree about WHICH storage. THE IDS, NOT THEIR COUNTS. Two rows counting 1 each can still be two DIFFERENT storages, which is the failure this arm would most want to see, so it compares storage ids: -- a WITH DATA matview: by relation_oid=10000000008, by metapage=10000000008, analyze() succeeded That is #1275's guarantee cross-checked from a different file than the one asserting it. Both harnesses, names kept in parity: premise: the matview's relation_oid finds a storage row the two routes agree which storage a WITH DATA matview owns pgcolumnar.analyze() reaches a matview created WITH DATA analyze_function 68 passed + 0 failed (PG18) test_analyze_function.py + test_compare_to_bash.py 45 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
|
All three of the places you asked me to push. Two hold, one needs a clause in a 1. The
|
) FAIL CHANGELOG.md carries no em or en dash: got [2] want [0] Both mine, in this branch's CHANGELOG entry. Replaced with the ASCII `--` the rest of the file uses. FOUND ONLY BY THE FULL CORPUS RUN. Nine targeted suite runs and both harnesses were green; `docs_style.sh` is not a neighbour of anything this change touches, so nothing in a reasonable list of affected suites would have reached it. That is the argument for the corpus run rather than a curated subset, and it is worth recording because the curated subset otherwise looks sufficient. TESTS.md holds 238 em dashes already and docs_style does not read it; none of them is mine. Checked rather than assumed: every line this branch adds, across all files, carries zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
The full PG17 corpus found something nine targeted suite runs did notTwo em dashes in this branch's own It is worth recording how it was found. Both harnesses were green, and so That is the argument for running the corpus rather than a curated subset, and it The ledger gate was clean in that same run, which is the part the run was Checked rather than assumed: every line this branch adds, across all files, Corpus restarted on the head that carries both this and the superseded-arm fix. |
@jdatcmd's review: the lock is held and the call is right, but the justification standing above it was written for TRUNCATE and for a type change -- the statement already holds AccessExclusiveLock on the hierarchy, so NoLock takes nothing new -- and those operate on relations that existed before the statement began. A CREATE MATERIALIZED VIEW has no hierarchy and no pre-existing relation. The lock is held because the creating transaction holds AccessExclusiveLock on a relation it has just made, and this block runs after standard_ProcessUtility so the create has completed. Same conclusion, different derivation, and without the clause the next reader inherits the stated reason rather than the true one. I had flagged that I never re-derived it; that was the right worry and this is the answer. AND THE NARROWING IS EXHAUSTIVE RATHER THAN MERELY UNFALSIFIED. I asked whether a CreateTableAsStmt could swap a transient without being a matview. parsenodes.h annotates CreateTableAsStmt.objtype as OBJECT_TABLE or OBJECT_MATVIEW and nothing else, so `objtype == OBJECT_MATVIEW` partitions the node type. SELECT INTO is OBJECT_TABLE, which the CREATE TABLE ... AS control already measures, not a third case. Read on the 15, 17 and 19 headers rather than on one, because this ships across the matrix and a header annotation is exactly the kind of thing that differs between majors. All three agree. Comment only. Rebuilt and re-run rather than assumed inert, because a comment moves __LINE__: build rc=0, 0 warnings rewrite_storage_oid 11 passed + 0 failed The first draft of this clause embedded parsenodes.h's own `/* ... */` and escaped the terminator. It compiled, and it was the wrong thing to ship: nested comment delimiters are a -Wcomment hazard and unreadable besides. Rewritten as prose naming the annotation instead of quoting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n
|
Re-verified at My citation was wrong even though my conclusion was rightI wrote The comment now cites the annotation and names the three majors, which is the Point 2 is in, in the derivation and with the reason it matters"Same conclusion, different derivation, and the next reader would otherwise The superseded arm is rewritten better than it was writtenYour premise asserted the orphan was still there, #1275 removed the orphan, the
And the rewrite says what it no longer proves, which is the harder half:
Asserting ids rather than counts is the right repair. Two rows counting 1 Point 3, settledNot touching the fixture. The control On the nested comment delimitersRebuilding and re-running rather than assuming a comment is inert was right, and Holding the approval for a definite green with 🤖 Generated with Claude Code |
Full PG17 corpus, green on this exact head
Re-run on The one thing the earlier corpus caught that nine targeted suite runs did not CI is 15 of 15 with zero failures. |
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at 004e801b. Reviewed across four heads; the delta re-checked at each.
The change
CreateTableAsStmt joins the node types the storage-oid repair runs for, narrowed
to objtype == OBJECT_MATVIEW. CREATE MATERIALIZED VIEW ... WITH DATA builds a
transient, fills it and swaps, so the storage row was written with the
transient's oid and left naming a relation that no longer exists.
The narrowing is exhaustive, and my own citation for that was wrong
I offered parsenodes.h:3989 as evidence. Your copy has the field at :3888 —
different minors, so the coordinate proved nothing. The annotation is the
evidence, and you read it on 15, 17 and 19 rather than on one, which is the right
standard for something shipping across five majors and not one I applied. The
comment now cites the annotation and names the three majors.
SELECT INTO is OBJECT_TABLE, so it is the control's case rather than a third
one.
CREATE TABLE ... AS is the control that keeps the fix honest
Same parse node, no defect, measured at rows_by_relation_oid = 1. Without it the
fix would reasonably have been written for CreateTableAsStmt as a whole, which
is broader than anything measured asked for. It is also why the blocked
ALTER TABLE control is not needed: this one is closer to the subject.
NoLock keeps the call and gains the right reason
The comment above it argued from AccessExclusiveLock on the hierarchy,
which was written for TRUNCATE and a type change. A CREATE has no hierarchy
and no pre-existing relation: the lock is the creating transaction's own on a
relation it just made, and this block runs after standard_ProcessUtility. Same
conclusion, different derivation — and a correct conclusion resting on an
inapplicable reason is harder to catch than a wrong one, because nothing forces
the re-derivation.
The superseded arm
#1276's matview premise asserted the orphan this change removes, so it went red.
That is the premise working rather than failing: it recorded a fact about the
world and reddened when the world changed, instead of passing quietly under a
claim that no longer meant anything. It would have said nothing as the comment it
was first written as.
Rewritten rather than deleted, and it now states what it no longer proves — with
the orphan gone both routes resolve, so the shape is not evidence for #1276's
fix; the projection arms are. Asserting ids rather than counts is the right
repair: two rows counting 1 each can be two different storages.
Verified independently
composed with main: ledger 1810 rows, never 1633, budget 1633 MATCH
guard_tests 404, cluster_tests 488
cluster_tests COLLECTED, not 487 + 1; the third collision of the day
and the first where the arithmetic was wrong
Merging.
🤖 Generated with Claude Code
Closes #1275.
What was wrong
CREATE MATERIALIZED VIEW ... USING pgcolumnar ... WITH DATAbuilds a transientrelation, fills it and swaps — exactly as a rewriting
ALTERdoes. The storagerow was written with the transient's OID and the swap left it naming a relation
that no longer existed:
REFRESH MATERIALIZED VIEWalready repaired it, becauseRefreshMatViewStmtisone of the node types the repair gate covers.
CreateTableAsStmtwas not, soa matview stayed broken between
CREATEand its firstREFRESH.Measured before any code was written
An
elogin the utility hook, printing the node kind, the depth, whatpgcolumnar_rewritten_relidsheld at that moment, and what the statement'srelation resolved to:
Three things that settled the shape of the fix:
into->relresolves to the matview (16523), not tothe transient the orphaned row names (16527).
pgcolumnar_rewritten_relidsis empty, so the recorded-list route theTRUNCATEpath uses is unavailable and the statement's own name is the onlyhandle.
CREATE TABLE ... AS ... USING pgcolumnardoes NOT have the defect.The
ALTER TABLEcontrol did not run, and is evidence of nothingIt is refused by the matview's dependency on the column, so the statement
errors, and the probe — which sits after the
PG_TRYblock — never executes. Iintended it as a second control showing the existing repair path works, and it
produced no reading. Recorded rather than retried a third way. Second time in
this session a control of mine was blocked by the same fixture property.
The fix
A
CreateTableAsStmtarm in the repair gate, restricted toobjtype == OBJECT_MATVIEW.That restriction is a control, not a guess about internals.
CREATE TABLE ... ASis the same parse node and has no defect, because it fills the relationit created instead of swapping a transient in. Without that reading the fix
would reasonably have been written for the node type as a whole, which is
broader than anything measured asked for.
test/rewrite_storage_oid.shnowasserts it as a premise, so the narrowing is refused the day it stops being
true rather than being silently wrong.
Its own arm rather than the existing cast. The comment already standing
there warns that
AlterTableStmtandRefreshMatViewStmtshare a field offset"by coincidence of layout, not by any rule".
CreateTableAsStmtdoes not shareit at all — its relation is reached through
->into->rel.Tests, both harnesses, in the suite that already owns the property
rewrite_storage_oid.shis #1250's and already holds "a type rewrite leaves thestorage row pointing at the live table", with the two premises either side that
make a pass mean something. The matview case is the same claim about a different
statement, so it belongs beside those rather than in a file of its own.
Six new checks, three of which exist to stop the claim going quiet:
CREATE TABLE ... AScontrol, which must pass before the fix aswell as after — if it ever reads 0 the claim is no longer about matviews and
the remedy is a different one;
REFRESHarm, because that path already worked through a differentnode type and something has to say the new arm did not displace it;
WITH NO DATAwrites no storage row at all and every arm would then be asking about nothing.
Removal proofs, both harnesses
The mutation is asserted on the host and in the container copy — 0
occurrences of
CreateTableAsStmtin each — because a revert that did not applyproduces a green run that reads as the arms having stopped working.
Green:
-- mv=1 cta=1, 11 passed + 0 failed, and the pytest twin at 11 checks.Verification
The rebase conflict, resolved by collecting twice
This branch and #1278 were both cut from
5389b35aand both movedcluster_teststo 487, for different tests. #1278 merged first, so 487 isnow main's value and this branch's test is not in it. Git conflicted loudly,
which is the lucky case — and the answer is neither branch's number.
pytest --collect-onlyover the 53 cluster files on the rebased tree reports488. The reasoning is recorded beside the key, including that the same thing
happened on the previous rebase and the arithmetic was right by luck that time.
Scope
This fixes the orphan. #1276 stopped
pgcolumnar.analyze()reading the column;until now the column itself was still wrong for every other reader.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n