Parallel bulk ingest into a single columnar table (#300) - #324
Conversation
…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
…ndprompt#300) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
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 First, the previous blocker is fixed and I re-ran my reproduction. A text-keyed Fixed twice over, which is the right shape: The load-bearing claim checks outYou assert that once the advisory lock is skipped, "everything else on the write
So the lock genuinely guards only storage-row creation, and skipping it once the I also like that misuse is safe by construction. If someone sets the GUC by hand
The gap: nothing proves the writers actually overlappedThe new single-table tests assert rows returned, oracle equality, no prepared-xact That is the same finding as on #323, where you fixed it one layer down with Two concrete additions would close it:
Related: a 5,000-row fixture at the default stripe limit yields few row groups, so Claims
What I did not checkThe 2PC coordinator changes in 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
|
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 It is also nice to see the reason recorded where the next reader will find it:
The one residual I would think about, not necessarily now: Design doc — one stale sentence, and it is the load-bearing one. Line 125 still
That is exactly the sentence whose falsity forced this whole redesign. The 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 |
|
The witness is exactly right, and it works. One line in it is broken, and it is a The witness does what I asked, and moreI ran the suite on The overlap join is the right assertion, and expressing it as the SQL-visible form The structural argument in the comment is the part I would keep:
That is correct and it is the strongest evidence available, because it makes One calibration: check (a) proves many reservations against the shared metapage, The one broken line
Worth noting the failure mode is the good one: 77 checks, 76 pass. Fix that one line and I have nothing else outstanding on this |
…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
) 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
Completes #300:
pgcolumnar.parallel_copycan 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
ExclusiveLockinColumnarInsertNativeStorageRow(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 therow_group/column_chunk/zone_map/bloominserts key on the distinct reserved stripe ids.The change (conditional — the default write path is byte-for-byte unchanged)
pgcolumnar.bulk_parallel_writer(backing varcolumnar_bulk_parallel_writer,GUC_NOT_IN_SAMPLE). When set,ColumnarInsertNativeStorageRowskips 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_copynow 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
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).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.