Skip to content

Parallel bulk ingest into a single columnar table (#300) - #324

Merged
ChronicallyJD merged 4 commits into
commandprompt:mainfrom
ChronicallyJD:feat/300-single-table-bulk
Aug 2, 2026
Merged

Parallel bulk ingest into a single columnar table (#300)#324
ChronicallyJD merged 4 commits into
commandprompt:mainfrom
ChronicallyJD:feat/300-single-table-bulk

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

Completes #300: pgcolumnar.parallel_copy can now load a single, non-partitioned columnar table in parallel and atomically. Partitioned targets already landed in #323 (each worker a distinct partition); this covers the one-table case, which the partition-parallel work deliberately deferred as the "columnar-core bulk" follow-up.

The blocker

The only transaction-length serializer on the same-storage bulk-write path is the per-storage advisory ExclusiveLock in ColumnarInsertNativeStorageRow (columnar_metadata.c), held to transaction end. It exists only to serialize the first-writer race for the storage catalog row, but holding it that long made concurrent writers to one table serialize (measured 2.01× for 2 concurrent) and deadlock under 2PC. Everything else on the write path is already concurrency-safe: row-number/stripe/offset reservation is a short metapage buffer lock (ColumnarReserveRowNumbers), and the row_group/column_chunk/zone_map/bloom inserts key on the distinct reserved stripe ids.

The change (conditional — the default write path is byte-for-byte unchanged)

  • A default-off session GUC pgcolumnar.bulk_parallel_writer (backing var columnar_bulk_parallel_writer, GUC_NOT_IN_SAMPLE). When set, ColumnarInsertNativeStorageRow skips the advisory lock iff the storage row already exists in the latest committed state — the lock only guards creation, so once the row is committed there is nothing to wait for. Every ordinary write leaves the flag off and takes the existing path; the guard is dead code for them.
  • ColumnarEnsureStorageRow(rel) pre-creates the storage row with exactly the metadata a normal flush records (reused, no drift).
  • parallel_copy now accepts a single columnar table: the coordinator pre-creates and COMMITs the storage row in its own session (the SQL function can't commit), then the loaders set the flag and write the one storage concurrently — disjoint stripe reservations, no shared xact-length lock → parallel and 2PC-safe. A naive byte split is used (any record-aligned split is correct; no partition key, so no sorted-input requirement). A non-columnar target is rejected; a partitioned target keeps the partition-aligned path.

Validation

  • Gate: 71 checks green on pg18a and pg19a (assert) and pg18_san (ASan/UBSan); clean preflight compile on all five majors (15–19). New tests: single-table parallel_copy == single-COPY oracle for 1/2/4 workers (the oracle equality is the concurrency-correctness check on the shared storage), no prepared-xact leak, atomic rollback on a bad row, and that the GUC defaults off. The default write path stays covered, unchanged, by the existing concurrent differential suites (flag off).
  • Bench (pg18n, 20M-row TSBS slice, warm, interleaved 3 rounds): single COPY ~132.8 s vs parallel_copy(8) into ONE columnar table ~22.0 s = ~6.0×; scaling N=1/2/4/8 = 133/69/37/21 s (near-linear); byte-identical result (both 20,000,000 rows, sum(usage_user)=1004254779.771), 0 prepared-xacts leaked. Compare the same-table baseline before this change: 2.01× serialized (2 concurrent writers took 2× a single load).

Note on the core write path

The one change to the shared write path (ColumnarInsertNativeStorageRow) is a single default-off-gated early-return. A plain COPY, INSERT, or any non-parallel-copy write never enters it. Flagged for review regardless — it's your engine.

ChronicallyJD and others added 2 commits August 1, 2026 20:43
…ommandprompt#300)

Lets pgcolumnar.parallel_copy load a single (non-partitioned) columnar table in
parallel and atomically, completing the feature: partitioned targets already work
by giving each worker a distinct partition; this covers the one-table case.

The blocker was the per-storage advisory lock in ColumnarInsertNativeStorageRow,
held to transaction end. It exists ONLY to serialize the first-writer race for the
storage catalog row, but holding it that long serialized concurrent same-table
writers (measured 2.01x) and deadlocked them under 2PC. Everything else on the
write path is already concurrency-safe: row-number/stripe/offset reservation is a
short metapage buffer lock, and the row_group/column_chunk/zone_map/bloom inserts
key on the distinct reserved stripe ids.

Change, gated so the default write path is byte-for-byte unchanged:
- columnar_metadata.c: a default-off session flag (columnar_bulk_parallel_writer,
  backing the pgcolumnar.bulk_parallel_writer GUC) makes ColumnarInsertNativeStorageRow
  skip the advisory lock when the storage row already exists in the latest committed
  state. Ordinary writes leave the flag off and take the unchanged path; the guard
  is dead code for them.
- columnar_write_state.c: ColumnarEnsureStorageRow(rel) pre-creates the storage row
  with exactly the metadata a normal flush records (reused, no drift).
- columnar_parallel_copy.c: parallel_copy now accepts a single columnar table. The
  coordinator pre-creates and COMMITs its storage row (in its own session, which
  the SQL function cannot), then the loaders set the flag and write the one storage
  concurrently -- disjoint stripe reservations, no shared xact-length lock, so
  parallel AND 2PC-safe. A naive byte splitter is used (any record-aligned split is
  correct; no partition key, so no sorted-input requirement). A non-columnar target
  is rejected; a partitioned target keeps the partition-aligned path.

Tests: single-table parallel_copy == single-COPY oracle for 1/2/4 workers (the
oracle equality is the concurrency-correctness check), no prepared-xact leak,
atomic rollback on a bad row, and that the GUC defaults off. A non-columnar target
is rejected (replacing the now-obsolete non-partitioned rejection).

Gated: 71 checks green on pg18a and pg19a (assert) and pg18_san (ASan/UBSan);
clean preflight compile on all five majors (15-19). The default write path is
covered unchanged by the existing concurrent differential suites (flag off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Review: the change is sound. One test gap, and it is the same one as last time.

Note on method: my usual multi-dimension review could not run (session limit), so
this is a direct read of the code rather than a fanned-out one. I verified the
load-bearing claim myself rather than take it, and I have marked clearly what I did
not check.

First, the previous blocker is fixed and I re-ran my reproduction. A text-keyed
RANGE-partitioned table with MINVALUE/MAXVALUE bounds no longer crashes the server:

ERROR:  pgcolumnar.parallel_copy supports only numeric or date/time partition keys
HINT:   The partition key is read from the text file without COPY de-escaping...
server alive afterwards: 1 | rows landed: 0 | prepared xacts leaked: 0

Fixed twice over, which is the right shape: pcopy_partition_bucket now dispatches
on bi->kind[mid][0] before touching the datum, mirroring core's
partition_rbound_datum_cmp, and by-reference key types are rejected outright.
That second half also closes the raw-key-parsing finding rather than half-solving
it, since raw parsing is only safe for the types you now accept.

The load-bearing claim checks out

You assert that once the advisory lock is skipped, "everything else on the write
path is already concurrency-safe". I read the actual paths in main rather than
accept it, because if it is wrong anywhere this corrupts data:

  • Stripe ids and row numbers. ColumnarReserveRowNumbers takes
    LockBuffer(BUFFER_LOCK_EXCLUSIVE) on the metapage, reads and bumps
    reservedStripeId/reservedRowNumber, MarkBufferDirty, WAL-logs. Two
    concurrent reservers get distinct stripe ids and disjoint row-number ranges.
  • File offsets, the corruption-critical one. ColumnarReserveOffset bumps
    meta->reservedOffset under the same exclusive buffer lock, and the whole
    reserve-then-write is inside LockRelationForExtension(rel, ExclusiveLock).
    Overlapping byte ranges are not reachable.
  • Free-space reuse is gated on
    CheckRelationLockedByMe(rel, ShareUpdateExclusiveLock, false), so a loader
    holding only RowExclusiveLock always appends and never races a reuse. The
    comment already says this.
  • The row_group/column_chunk/zone_map/bloom inserts key on stripe ids that
    are distinct by the first point.

So the lock genuinely guards only storage-row creation, and skipping it once the
row is committed is safe. The shape of the guard is right too: skip only when the
row provably exists, otherwise fall through to the unchanged locked path.

I also like that misuse is safe by construction. If someone sets the GUC by hand
and does an ordinary INSERT, the skip still only fires when the row already exists,
which is exactly when the lock has nothing to do. Worth saying that in the GUC
description, which currently says "not for manual use" without saying why manual
use is harmless.

ColumnarEnsureStorageRow reading the table's own stripeRowLimit closes the
question of whether the pre-created row matches what a flush would have written.

The gap: nothing proves the writers actually overlapped

The new single-table tests assert rows returned, oracle equality, no prepared-xact
leak, atomic rollback, and the GUC default. Every one of those passes if the
loaders serialise by accident.

That is the same finding as on #323, where you fixed it one layer down with
"split is real (interior offsets distinct, ranges non-empty)". It matters more
here: the entire point of this PR is concurrent writers sharing one storage, so a
run where they did not overlap tests nothing that the previous code did not already
do.

Two concrete additions would close it:

  1. Assert the concurrency happened. Distinct stripe ids per worker is the
    natural witness, since that is what the design relies on.
  2. Assert structural integrity after a concurrent load, not just row equality.
    Row-group byte ranges non-overlapping, row numbers unique and gap-consistent.
    COLUMNAR_ASSERT_NO_OVERLAP(storageId) already exists and the gate runs on
    assert builds, which is real mitigation, but an explicit SQL-level check would
    make the guarantee visible in the suite rather than incidental to a macro.

Related: a 5,000-row fixture at the default stripe limit yields few row groups, so
it is worth checking that two loaders even produce more than one group each.
Oracle equality on a fixture too small to interleave cannot fail for the right
reason.

Claims

  • 132.8 / 22.0 = 6.04, so "~6.0x" is accurate.
  • "near-linear" for 133/69/37/21 is fair at N=2 (1.93x) and N=4 (3.59x) but
    overstates at N=8: 6.33x of a possible 8, about 79%. Worth stating as measured
    rather than characterised, since the honest number is still good.
  • "byte-identical (both 20,000,000 rows, sum(usage_user)=1004254779.771)" is a
    row count plus one float sum, and a float sum is order-dependent, so matching it
    across a reordered parallel load is a decent signal but not "byte-identical".
    pgc_set_hash is what the rest of the suite uses for that word.

What I did not check

The 2PC coordinator changes in columnar_parallel_copy.c (+89/-19), the
cancel/SIGTERM prepared-transaction cleanup I raised on #323, and whether the
design doc still contradicts the abandoned single-table-atomic text. Those want a
look before merge; I could not get to them this session.

Not blocking on my side once the concurrency witness is in the suite.

…ding (commandprompt#300)

jdatcmd's review: the change is sound (he verified the write-path
concurrency-safety himself), but nothing in the suite proved the loaders
actually overlapped -- every oracle-equality check passes even if they
serialised. Same class as the commandprompt#323 'split is real' finding.

- test/parallel_copy.sh: add a concurrency witness. A 40k-row load with a
  small stripe_row_limit (set on the table, so every loader honours it)
  makes each of 4 workers flush several stripes, then asserts via
  pgcolumnar.stats: >N distinct stripe ids (many concurrent reservations,
  >1 group/worker), no stripe byte-range overlap, and complete row
  coverage -- the SQL-visible form of COLUMNAR_ASSERT_NO_OVERLAP.
- src/columnar_tableam.c: GUC description now says WHY manual use is
  harmless (the skip only fires once the row is committed, i.e. exactly
  when the creation lock guards nothing).
- design/PARALLEL_COPY_PLAN.md: state N=8 as measured (6.33x, ~79% of
  ideal) not 'near-linear'; the float sum is order-dependent so the suite
  proves byte-identical via pgc_set_hash; clarify the retired design is
  the lock-holding atomic-into-one-table approach, not single-table itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Follow-up: I went back and covered the three things I said I had not checked.

Cancel and prepared-transaction cleanup — addressed. The coordinator wraps its
work in PG_TRY/PG_CATCH (two of them), has CHECK_FOR_INTERRUPTS in the wait
loops, calls FinishPreparedTransaction directly rather than through SPI, and has
a best-effort rollback helper that never throws, for cleanup paths already in
error. That is the right shape and it closes the leak concern I raised on #323.

It is also nice to see the reason recorded where the next reader will find it:

Why a coordinator bgworker at all: COMMIT PREPARED / ROLLBACK PREPARED cannot
[be executed from a function]

The one residual I would think about, not necessarily now: PG_CATCH unwinds on
ERROR, and a bgworker SIGTERM normally arrives as one, so that path is covered. A
postmaster crash is not, but that is the ordinary 2PC orphan story and recovery
handles the prepared transactions themselves.

Design doc — one stale sentence, and it is the load-bearing one. Line 125 still
says:

The coordinator (the calling backend) runs a fixed pipeline:

That is exactly the sentence whose falsity forced this whole redesign. The
coordinator is now a background worker, precisely because the calling backend
cannot issue COMMIT PREPARED. The doc elsewhere is updated (the single-table path
is described at 90-91, staging is marked deferred at 108 and 204), so this reads as
a leftover rather than a disagreement, but the doc is the review contract for the
remaining phases and this line contradicts the implementation.

Coordinator diff — read it; nothing further from me beyond the above.

So the only thing I would still want before merge is the concurrency witness in the
suite, which is the one point from my main review. Everything else here is either
resolved or a judgement call I am happy to leave to you.

@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

The witness is exactly right, and it works. One line in it is broken, and it is a
test bug rather than a product bug.

The witness does what I asked, and more

I ran the suite on 2ea71cb. The substantive assertions all pass:

PASS  witness: parallel_copy(single table, 4 workers, 40000 rows) loads every row
PASS  witness: >4 distinct stripe ids (each worker reserved several stripes)
PASS  witness: no stripe byte-range overlaps another (no on-disk collision)
PASS  witness: stripe rowcounts sum to every input row (complete coverage)
PASS  witness: no prepared-transaction leak

The overlap join is the right assertion, and expressing it as the SQL-visible form
of COLUMNAR_ASSERT_NO_OVERLAP is better than relying on the assert gate, because
it holds on a non-assert build too. Sizing the fixture so each of 4 workers flushes
several stripes from 40k rows at stripe_row_limit => 2000 also answers the
"fixture too small to interleave" half of my point.

The structural argument in the comment is the part I would keep:

N 2PC-prepared loaders can only COMMIT together if the storage-row lock was
skipped -- with it held, loader 2 blocks on loader 1's prepared xact forever --
so a passing run is itself evidence the skip fired.

That is correct and it is the strongest evidence available, because it makes
completion itself the witness.

One calibration: check (a) proves many reservations against the shared metapage,
not concurrency on its own. With stripe_row_limit => 2000 and 40k rows there are
20 stripes, so count(DISTINCT stripeid) > 4 also holds for a single worker. It is
still worth having, and the deadlock argument above carries the concurrency claim,
so this is a wording point rather than a gap.

The one broken line

FAIL  witness: result == single-COPY oracle: got [6d636b...] want [QUERY_ERROR.1]

QUERY_ERROR.1 is pgc_set_hash's sentinel for a query that errored, on the heap
side. Cause, from the server log:

ERROR:  syntax error at or near "\" at character 88
STATEMENT:  DROP TABLE IF EXISTS t_wit_heap; CREATE TABLE t_wit_heap (id int, txt text);
            \copy t_wit_heap FROM '...' WITH (FORMAT text)

parallel_copy.sh:402-403 puts \copy inside a psql_run string alongside SQL.
psql -c does not run backslash meta-commands mixed with SQL, so the whole
statement fails, t_wit_heap is never created, and the oracle comparison has
nothing to compare against. The rest of the suite builds its heap mirrors with
server-side COPY ... FROM via make_file, which is what this block wants too
(the file is already server-side and server-readable).

Worth noting the failure mode is the good one: pgc_set_hash returns a per-call
unique sentinel precisely so a failing query cannot compare equal to another
failing query and pass vacuously. That guard is why this surfaced as a red check
instead of a green one.

77 checks, 76 pass. Fix that one line and I have nothing else outstanding on this
PR: the concurrency witness was my only remaining ask, and it is here.

…mpt#300)

The concurrency witness added in the previous commit crashed the
coordinator and loader bgworkers on an assert build:

  TRAP: failed Assert("snapshot->regd_count > 0 || snapshot->active_count > 0"),
        heapam_visibility.c:972
  ... systable_getnext -> ColumnarReadOptions

Root cause: both bgworkers read pgcolumnar.options via a visibility-checked
systable scan (ColumnarEnsureStorageRow in the coordinator; the columnar
insert path under CopyFrom in the loader), but neither had pushed an active
snapshot. StartTransactionCommand does not push one, and core's DoCopy
normally does via PortalRunUtility -- which we bypass by calling CopyFrom
directly. ColumnarReadOptions then scans on an unregistered
GetTransactionSnapshot() and aborts the moment the options relation has a
matching row -- i.e. whenever the target has custom options set. That is why
the default-options tests passed and only the set_options witness tripped it;
the bug was latent for any real table with tuned options.

Fix: PushActiveSnapshot(GetTransactionSnapshot()) around the coordinator
pre-create and around the loader's BeginCopyFrom/CopyFrom, popped before the
2PC PREPARE (a prepared transaction must carry no active snapshot). Engine
code untouched.

Also fix the witness fixture: CREATE TABLE and \copy cannot share one
psql -c (SQL vs backslash meta-command), so the heap oracle never built.
Split into two psql_run calls, matching the existing t_heap setup.

Gate: parallel_copy.sh 77/77 on pg18a assert (full witness block green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
@ChronicallyJD
ChronicallyJD merged commit 70426c1 into commandprompt:main Aug 2, 2026
11 checks passed
jdatcmd pushed a commit that referenced this pull request Aug 2, 2026
)

parallel_copy (#300, shipped in #323 + #324) had no user-facing docs -- it
existed only in the SQL COMMENT. Document it:

- features.md, user-guide.md, sql-reference.md: describe parallel_copy, its two
  target kinds (single columnar table = any order; RANGE-partitioned = file
  sorted by the partition key, numeric/temporal key), atomicity via 2PC,
  privileges (pg_read_server_files + INSERT), and the max_prepared_transactions
  requirement.
- limitations.md: the constraints (text format only, target kinds, sorted-key
  requirement, core-count plateau, 2PC in-doubt window).
- benchmarks.md: a full ingest benchmark from the bench (pg18n, 20M + 100M TSBS,
  median of 3 interleaved rounds). Single table: COPY 129.8s -> 8w 20.6s (6.29x),
  16w 6.87x; 100M: COPY 644.1s -> 16w 92.8s (6.94x); partitioned 20M: 8w 4.61x,
  16w 5.27x. Identical rows/checksum; on-disk within 0.03%.
- CHANGELOG.md: the feature entry under Added.

Also fix two stale inline comments that outlived the single-table work:
- the SQL COMMENT still said 'RANGE-partitioned' only;
- a comment in columnar_parallel_copy.c claimed single-table was 'a planned
  enhancement' directly above the code that implements it.

Docs pass test/ste_check.py; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
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.

1 participant