Skip to content

CTAS into a columnar table with a parallel source plan (#387) - #408

Merged
jdatcmd merged 1 commit into
mainfrom
fix/387-ctas-parallel
Aug 5, 2026
Merged

CTAS into a columnar table with a parallel source plan (#387)#408
jdatcmd merged 1 commit into
mainfrom
fix/387-ctas-parallel

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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

PgColumnarInsertNativeStorageRow re-checks for an existing storage row under the
advisory lock against GetLatestSnapshot(). That call raises

ERROR:  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 exactly when someone bulk
loads a real table.

Two things your issue left open, answered before I wrote anything:

  • Not PG18-only. Reproduced identically on PG15, plan captured each time so a
    green run cannot be a serial one. No version to gate on.
  • INSERT ... SELECT is unreachable, structurally. With the same forced costs the
    plan has no Gather at all, because Postgres will not put one under a writing
    ModifyTable. 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 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 (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 curcid advanced, which
sees 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 plan
assertion
, because a CTAS that quietly ran serially would satisfy all of them and
prove nothing.

Removal proof on unmodified main:

check on main with fix
premise: source plan is parallel PASS PASS
parallel CTAS succeeds FAIL, cannot update SecondarySnapshot PASS
parallel CTAS wrote every row FAIL PASS
parallel CTAS content matches source FAIL PASS
exactly one storage row FAIL (0) PASS
serial CTAS, WITH NO DATA + INSERT PASS PASS
concurrent first writes still serialize PASS PASS

The 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

  • Five-major matrix: ALL VERSIONS PASSED, PG15 through PG19.
  • concurrent_diff, unique_conc, native_dml, differential, native_writer,
    parallel_copy all pass, since those are the suites that own the race this touches.

Docs

CHANGELOG.md gains a Changed entry naming the shape, the cause and the fact that the
committed-table race is unaffected. Nothing in docs/limitations.md claimed this
worked, so there is nothing to retract there.

🤖 Generated with Claude Code

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 ChronicallyJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jdatcmd
jdatcmd merged commit 21a3c31 into main Aug 5, 2026
11 checks passed
@jdatcmd
jdatcmd deleted the fix/387-ctas-parallel branch August 5, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CREATE TABLE AS into a columnar table fails when the source plan is parallel, which is the default at scale

2 participants