CTAS into a columnar table with a parallel source plan (#387) - #408
Conversation
PgColumnarInsertNativeStorageRow re-checks for an existing storage row under an advisory lock against GetLatestSnapshot(), so the loser of a cross-transaction first-write race sees the winner's committed row rather than failing on storage_pkey. GetLatestSnapshot() raises "cannot update SecondarySnapshot during a parallel operation" inside parallel mode, and CTAS runs its whole executor in parallel mode whenever the source plan is parallel. So CREATE TABLE ... USING pgcolumnar AS SELECT failed for any source large enough to earn a parallel plan, which is to say exactly when someone bulk loads a real table. Reproduced on PG15 and PG18 with the plan captured each time, so this is not a recent regression and there is no version to gate on. Skip the lock and the fresh snapshot when the relation was created by the current transaction. Nothing else can see it, so there is no second writer and no race to detect. The condition is creation, NOT parallel mode. Being in parallel mode says nothing about whether another session is writing: a committed, empty columnar table can be first-written by two backends at once, and keying on parallel mode would restore the storage_pkey failure the concurrent differential suite originally found. rd_createSubid is the invisibility test. Deliberately not rd_newRelfilelocatorSubid: a relation this transaction rewrote is still visible to others in its previous form, so that race is live. The existence check is kept, because the writer calls this on every row-group flush and it must stay idempotent. It reads the active snapshot with curcid advanced, which sees this transaction's own earlier insert and updates no global snapshot state. test/native_ctas.sh, registered, asserts both directions. Every value check is paired with a plan assertion, because a CTAS that quietly ran serially would satisfy all of them and prove nothing. Proven by removal on unmodified main: the four CTAS checks fail with the reported error, the premise and the controls pass, and the two race checks pass on both builds, which is what they are for. They guard the fix rather than detect it. Gate: five-major matrix, ALL VERSIONS PASSED, PG15 through PG19. concurrent_diff, unique_conc, native_dml, differential, native_writer and parallel_copy all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChronicallyJD
left a comment
There was a problem hiding this comment.
Approved. Verified independently on the bench, including the direction that must not change.
The reasoning is the best part of this, specifically the choice of condition. rd_createSubid
rather than IsInParallelMode() is right for the reason you give: parallel mode says nothing
about whether a second writer exists. And ruling out rd_newRelfilelocatorSubid because a
rewritten relation is still visible in its previous form is the kind of distinction that is
easy to get wrong and expensive to get wrong.
The fix, on my own repro rather than yours
Built the branch on the bench, ran the repro verbatim from #387:
############ pg18a (PostgreSQL 18.4)
premise, source plan parallel: yes
original #387 repro: ok, no error
rows landed: 200000
storage rows: 1
############ pg15a (PostgreSQL 15.18)
premise, source plan parallel: yes
original #387 repro: ok, no error
rows landed: 200000
storage rows: 1
Both ends of the support range, both with the plan asserted parallel first.
The direction that must not change
This is the one I most wanted to check myself, since the whole safety argument rests on it.
Four sessions first-writing a committed, empty columnar table at once:
errors from 4 concurrent first-writers : 0 (want 0)
storage rows for r : 1 (want 1)
rows present : 200000 (want 200000)
The lock still does its job. Invisibility is doing the work in the CTAS case, not a
weakening of the concurrent case.
Multi-flush idempotency, with the premise asserted
My first attempt at this was vacuous: it produced one row group, so it never exercised a
second flush. Redone at a size that forces several:
premise, plan parallel : yes
rows : 1000000 (want 1000000)
storage rows : 1 (want 1)
row groups (flushes) : 7 (want >1, else vacuous)
Seven flushes, one storage row. The existence check on the new path stays idempotent.
Your test suite
I could not run native_ctas.sh on my bench, for an environment reason on my side rather
than anything in the PR, so I verified the same properties with my own scripts instead. The
suite itself reads well. The premise assertion on the plan is the part I would have asked
for if it were missing, and the committed-table race is there as its own case.
One question, not a blocker
The new path is guarded by columnar_relation_is_new_in_xact(...) && ActiveSnapshotSet().
If a new relation is ever written with no active snapshot while in parallel mode, that
falls through to the lock and GetLatestSnapshot(), and fails exactly as before.
I could not construct a case that reaches it, and the fallback is the old behaviour rather
than something worse, so I am not treating it as a defect. Worth knowing it is there.
On the benchmark question
Agreed, none needed. This changes which branch a metadata insert takes on a relation that
cannot be contended. There is no throughput claim to make and none is made.
Approving.
Closes #387. @ChronicallyJD for review, per our workflow. No benchmark needed here;
if you disagree, say so and I will ask for one rather than guess.
What was broken
PgColumnarInsertNativeStorageRowre-checks for an existing storage row under theadvisory lock against
GetLatestSnapshot(). That call raisesinside parallel mode, and CTAS runs its whole executor in parallel mode whenever the
source plan is parallel. So
CREATE TABLE ... USING pgcolumnar AS SELECTfailed forany source large enough to earn a parallel plan, which is exactly when someone bulk
loads a real table.
Two things your issue left open, answered before I wrote anything:
green run cannot be a serial one. No version to gate on.
INSERT ... SELECTis unreachable, structurally. With the same forced costs theplan has no
Gatherat all, because Postgres will not put one under a writingModifyTable. CTAS is the entire exposure, which makes this narrower than the title.
The fix, and the condition I did not use
Skip the lock and the fresh snapshot when the relation was created by the current
transaction. Nothing else can see it, so there is no second writer.
The condition is creation, not parallel mode, and that distinction is the whole
review.
IsInParallelMode()is the tempting test and it is wrong: it says nothingabout whether another session is writing. A committed, empty columnar table can be
first-written by two backends at once, and keying on parallel mode would restore the
storage_pkeyfailure the concurrent differential suite originally found.rd_createSubidis the invisibility test. Deliberately notrd_newRelfilelocatorSubid: a relation this transaction rewrote (TRUNCATE, CLUSTER,ALTER TABLE) is still visible to other sessions in its previous form, so that race is
live and must keep the lock.
The existence check stays, because the writer calls this on every row-group flush and
it must remain idempotent. It reads the active snapshot with
curcidadvanced, whichsees this transaction's own earlier insert and updates no global snapshot state.
Tests
test/native_ctas.sh, registered. Every value check is paired with a planassertion, because a CTAS that quietly ran serially would satisfy all of them and
prove nothing.
Removal proof on unmodified main:
cannot update SecondarySnapshotThe last row is the point: those two checks pass on both builds. They guard the
fix rather than detect it, which is what a check on the direction that must not change
should look like.
Gate
ALL VERSIONS PASSED, PG15 through PG19.concurrent_diff,unique_conc,native_dml,differential,native_writer,parallel_copyall pass, since those are the suites that own the race this touches.Docs
CHANGELOG.mdgains a Changed entry naming the shape, the cause and the fact that thecommitted-table race is unaffected. Nothing in
docs/limitations.mdclaimed thisworked, so there is nothing to retract there.
🤖 Generated with Claude Code