Parallel bulk ingest: pgcolumnar.parallel_copy (#300) - #323
Conversation
Capture the measured basis (parallel ingest scales ~7.4x to physical cores; parallel-8 100M load 74.9s, beating a single-COPY heap load) and a concrete design for pgcolumnar.parallel_copy: N background workers each running core COPY over a line-aligned byte range of the file (so parse semantics stay core COPY's, no looser parser), with two admin-selectable all-or-nothing modes -- 'atomic' (2PC prepare/commit-all) and 'staging' (load partitions, ATTACH). Includes file-splitting, crash-safety, an exhaustive heap-oracle + failure-injection test plan, a phased build, and the open mechanism/combine questions for review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
pgcolumnar.file_split_offsets(path, workers) returns workers+1 ascending byte offsets partitioning a text file into that many record-aligned ranges, so a parallel load can hand [off[i], off[i+1]) to each worker without splitting a record. Placed at the first byte after the newline following each even split point; ranges may be empty when the file has fewer records than workers. Requires pg_read_server_files (as COPY FROM file does). Text format only for now; CSV quote-aware splitting is a later phase per design/PARALLEL_COPY_PLAN.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
f02d25c to
20e5058
Compare
test/parallel_copy.sh proves pgcolumnar.file_split_offsets two ways: structural (offsets bracket the file, non-decreasing, every interior boundary sits right after a newline) and end-to-end against a heap oracle (the union of per-range COPYs equals a single COPY of the whole file). Covers workers 1..16, more workers than rows (empty ranges), a single row, and a file with no trailing newline. Registered in the matrix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
20e5058 to
5e81d9f
Compare
|
Phase 1 landed: the file range splitter ( Given a text file and N workers it returns N+1 record-aligned byte offsets that partition the file — the coordinator will hand Phase 1 is mechanism-independent. I've paused before the coordinator (phases 2–4) pending a steer on the two questions above — chiefly bgworkers vs dblink — since that choice determines how each worker runs its COPY and I'd rather not build it twice. Happy to proceed on the bgworker default if you're good with it. |
Review: strong design, one blocker that invalidates the default mode before it is builtReviewed as what it is: a design doc plus phase 1. I am not reporting "incomplete" Two things I reproduced myself are marked verified; the rest came from a Blocker (verified): the coordinator cannot commit a prepared transaction
Measured on PG17 with So atomic mode fails at the commit step, after every worker has durably prepared, It is fixable, and it bears directly on your open question 1: the commit step has Blocker (reported, with a repro): no regular-file check
Core COPY guards exactly this ( Staging mode: the split and the combine do not fit togetherReported, and I think it is the most important design issue after the 2PC one. Workers get byte ranges of the input file. Partitions need key ranges. On Related: ATTACH is not metadata-only here. Attaching a freshly loaded table runs Also: the design never says the N ATTACHes are one transaction. If they are not, a Test suite: the gate cannot see a splitter that stops splittingCredit first: the alignment predicate is the right one, and both off-by-one mutants But nothing asserts that a split actually happened. A splitter degraded to a single One assertion fixes it: for a fixture with many more lines than workers, require the Two smaller ones: the Smaller, phase-1 code
The shipped SQL surfaceThe C header says "Text format only for now" and explains why, which is right. But Your three open questions
Happy to be wrong on the reported items; the two verified ones I would want fixed |
pgcolumnar.parallel_copy(target, filename, workers) fans a text-file load across N dynamic background workers. The coordinator lays out one DSM segment (control header + per-worker slot array), computes line-aligned byte ranges, launches the workers, waits, and returns the total rows; each worker attaches the segment, connects, and runs core COPY over its byte range via BeginCopyFrom with a bounded data source, so parse and write semantics are exactly core COPY's. Workers report success/failure through a shared status word (a worker's shutdown status alone cannot tell success from crash); on any error or cancellation the coordinator terminates every worker it launched, so none are orphaned. This slice commits each range independently; the all-or-nothing (2PC) and staging modes follow. Test proves the N-worker load is byte-identical to a single COPY at 1/2/4 workers, that a missing file errors cleanly with nothing loaded and no worker crash, and that a non-columnar target is rejected. APIs are stable across PG15-19 (only BGWORKER_BYPASS_ROLELOGINCHECK and the PG16 RTEPermissionInfo split need guards). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
…commandprompt#300) Verified COMMIT PREPARED / ROLLBACK PREPARED cannot run inside a transaction block, and a SQL function always is one, so the function cannot finish 2PC. Atomic mode runs the coordinator as its own background worker that owns the transaction loop, spawns the loaders, and issues the prepared-transaction finish; staging mode (ATTACH) stays function-driven. Phase-2 plumbing is reused unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Phase 2 landed: the background-worker coordinator + bounded COPY. One architecture correction for the atomic mode (design doc updated): I'd assumed the |
…igor (commandprompt#300) Addresses the review on commandprompt#323. Code: - Reject non-regular files (fstat + S_ISREG) in the splitter, coordinator, and worker, matching core COPY. A directory no longer reports as an 8-exabyte splittable file; /dev/zero and FIFOs are rejected before any scan or blocking open(). - Rewrite the splitter as a single forward O(filesize) pass with CHECK_FOR_INTERRUPTS, shared by the SQL helper and the coordinator, replacing the per-boundary seek-and-scan (O(filesize*workers), uncancellable). - Bound the range count (PCOPY_MAX_WORKERS) inside the C helper, not only the coordinator, so a huge workers value can't preallocate gigabytes. - Default workers derive from max_parallel_workers (max(1, budget/2)) when the caller passes none, rather than grabbing the whole background-worker pool; the SQL default is now NULL so this actually fires. - Remove the STRICT-dead NULL check; state text-format-only in the C and SQL comments (CSV is unsafe to split this way until the quote-aware phase). Tests: - Assert a split actually happened: on a many-records fixture the interior offsets must be strictly increasing and inside (0, size), catching a splitter degraded to {0, size, ..., size}. - Add a >64kB line fixture to exercise the multi-chunk read. - Add a directory-rejection regression; demote the non-discriminating 1-row case to an edge smoke test. Design: - Staging/ATTACH removed from v1: byte ranges are not key ranges, ATTACH is not metadata-only (validation scan + AEL on a default partition), and the N ATTACHes were never one transaction. Deferred to separate future work. - Open questions resolved (mechanism = coordinator bgworker; default workers from the parallelism budget via a GUC). Gated: 36 checks green on pg18a and pg19a (assert) and pg18_san (ASan/UBSan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Thanks — this is a genuinely useful review; several of these are things I would not Blockers2PC — coordinator cannot No regular-file check. Fixed. Added Staging — removed from v1You're right, and it's the design issue I'm most glad you caught. Byte ranges are Test suite — the gate can now see a stopped splitterFixed, and this is the same vacuous-premise class I've been burned by before.
Smaller phase-1 code
SQL surfaceBoth the C header and the SQL Your three open questions
Rebuilt clean and re-ran |
|
Phase 2 does not build on PostgreSQL 15 or 16. CI reports four red jobs under
PG15. The PG16. Neither is deep, but both are the class the five-major preflight exists to catch, Two smaller notes while you are in there:
The review points from the earlier round I have not re-checked yet, since the build |
Pivot after a measured architectural finding: pgColumnar serializes concurrent writers to ONE table on a per-storage advisory lock held to transaction end (columnar_metadata.c:1489). So same-table parallel load gets no speedup (measured 2.01x for 2 concurrent vs 1.04x into separate tables), and atomic 2PC into one table DEADLOCKS -- a PREPARE'd loader retains the lock, peer loaders block on it, and the coordinator only COMMIT PREPAREDs after all finish. Parallelism requires each worker to write DISTINCT storage. This commit delivers that: parallel_copy now loads a RANGE-partitioned target, each worker owning a distinct set of partitions (distinct storage id -> no shared lock -> parallel AND 2PC-atomic, no deadlock; validated: 4 concurrent PREPAREs into distinct tables = 1.04x, atomic, 0 leak). - New partition-aligned splitter (pcopy_partition_aligned_offsets): single forward pass parsing each row's partition key, bucketed against the catalog's sorted RANGE bounds, recording the byte offset at each partition boundary, then grouping contiguous partitions into balanced worker ranges. Verifies the input is sorted ascending by the key. v1 restrictions (all checked): single-column RANGE key, no DEFAULT partition, non-expression key, COPY text format, key field free of escapes. - The 2PC machinery (coordinator bgworker spawns N loaders that PREPARE, then COMMIT PREPARED all or ROLLBACK PREPARED on any failure) is reused unchanged -- it is correct for distinct storage. Loaders COPY their byte range into the partitioned parent; tuple routing sends each worker's rows to its partitions only. - Function requires a partitioned target (a plain columnar table is rejected with a clear error pointing to the planned single-table columnar-core enhancement), guards max_prepared_transactions >= effective workers, derives default workers from max_parallel_workers. - Tests rewritten for partitioned targets: N-worker load == single-COPY oracle (no leak), loader-failure rolls back PREPARED siblings (gapped target), unsorted input rejected, DEFAULT partition rejected, non-partitioned rejected, bad key rejected, guard fires. test/lib.sh gains a PGC_EXTRA_CONF hook (2PC capacity). - Design doc records the finding, the measured numbers, and the two-deliverable pivot (partition-parallel now; columnar-core bulk for single tables later). Gated: 51 checks green on pg18a and pg19a (assert) and pg18_san (ASan/UBSan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Bench (pg18n, TSBS, warm): - 20M slice, interleaved: single COPY ~126.8s vs parallel_copy(8) ~25.6s = ~5.0x; scaling N=1/2/4/8 = 137/73/42/27s; byte-identical, 0 prepared leaked. - 100M / 17GB full file: single COPY 640.6s vs parallel_copy(16) 118.6s = 5.40x; both 100M rows, identical sum(usage_user), 0 prepared leaked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Update: pivoted to partition-parallel after a measured architectural findingBuilding the atomic (2PC) coordinator surfaced a hard constraint, so the design of #300 changed. Recording it here. The finding (measured, not reasoned)pgColumnar serializes concurrent writers to one table on a transaction-scoped, per-storage-id advisory
So (1) same-table parallel load gets no speedup, and (2) atomic 2PC into one table deadlocks — a The pivot: partition-parallel
v1 restrictions (checked): single-column RANGE key, no DEFAULT partition, input sorted ascending by the key, COPY text format. Validation
Design doc updated with the finding + numbers. Pushed on this branch (no force-push). |
# Conflicts: # test/run_all_versions.sh
An independent adversarial review of the partition-aligned splitter found three defects; all fixed and gated. 1. CRITICAL — pcopy_partition_bucket ignored MINVALUE/MAXVALUE bound kinds. A RANGE bound of MINVALUE/MAXVALUE stores an UNDEFINED datum (bound info is palloc0'd -> reads as 0); the real meaning is in kind[]. Comparing the key against that 0 mis-buckets a signed key (int, or timestamp[tz] which is negative before 2000-01-01) straddling 0 under an unbounded first/last partition -- the normal time-series layout -- planting a worker boundary INSIDE one partition and reintroducing the write-lock deadlock this feature avoids. Now checks kind[mid][0] first, mirroring core's partition_rbound_datum_cmp. Positive-only keys hid this, so added a signed-key regression test (keys straddling 0 under [MINVALUE,100)). 2. In-doubt prepared-transaction leak: the coordinator's error path read loader slot state without waiting for the loaders to exit, so a loader that had made its transaction durable but not yet stored PCOPY_PREPARED could be skipped by the rollback loop (e.g. RegisterDynamicBackgroundWorker exhausting max_worker_processes after siblings prepared). The CATCH now WaitForBackgroundWorkerShutdown()s every launched loader before rolling back. 3. Memory leak: the splitter's getline() buffer (malloc'd, not palloc'd) leaked on an implicit throw from InputFunctionCall on an unparseable key field. The scan loop is now wrapped in PG_TRY/PG_CATCH that frees the buffer and the AllocateFile handle on every exit path. Gated: 54 checks green on pg18a and pg19a (assert) and pg18_san (ASan/UBSan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Review of the pivot: the direction is right, one reproduced crash blocks itThe pivot is well-founded and the finding behind it is the good kind: you measured Build is fixed: I verified clean on all five majors, 0 warnings, 0 errors. Both Method: 6-dimension review with per-dimension adversarial refutation, 43 findings Blocker, reproduced:
|
Addresses jdatcmd's review of the partition-parallel pivot. The reproduced crash was already fixed (the MINVALUE/MAXVALUE kind-dispatch, prior commit); this commit adds a text-key regression for it and closes the remaining findings. Blockers: - Cancel/SIGTERM no longer leaks prepared transactions. The coordinator used `die` as its SIGTERM handler, so a cancel proc_exit'd past its 2PC cleanup and orphaned every loader's prepared xact (pinning the cluster xmin). It now uses a flag-setting handler and a latch-driven wait loop; on cancel it terminates the loaders, waits, and ROLLBACK PREPAREDs every prepared range in normal backend context. - INSERT privilege is now checked. The function does pg_class_aclcheck(ACL_INSERT) on the target up front, matching COPY FROM, so a caller cannot load into a table they cannot write. Splitter / parser divergences (each mis-buckets a row, and a wrong bucket is a same-partition write, i.e. the hang the pivot removes): - Skip generated columns (not just dropped) when locating the key field, matching COPY's default column list. - Restrict the key to numeric/date-time types, whose COPY text form is escape-free, so reading the raw field without de-escaping is exact; other key types are rejected rather than silently mis-parsed (a planned enhancement). - Stop the key field at CR as well as tab/newline (CRLF no longer leaves a \r). - Free the parsed key Datum per row (by-reference types, e.g. numeric, otherwise leaked one Datum per row across the whole pre-scan). Docs/claims: the SQL comment now warns that the load commits independently of the caller's transaction (survives ROLLBACK) and must not be called while the caller holds a lock on the target (loaders would block, invisible to the deadlock detector); the file header describes the partition-parallel design, not phase 1; the design doc no longer claims the machinery is reused "unchanged". Tests: text-key rejection (jdatcmd's crash repro, now a clean rejection + server still up), key not in column 1 with a generated column before it, signed keys straddling 0 under [MINVALUE,100); tightened the bad-key grep to the exact reason. Gated: 58 checks green on pg18a and pg19a (assert) and pg18_san (ASan/UBSan); clean preflight compile on all five majors (15-19). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Thanks — this is a strong review and the crash is exactly the kind of thing that had to be caught before merge. All of it is addressed on the branch (through The reproduced crash — converged, fixed, now testedWe found the same bug independently: my own adversarial pass flagged that Blockers
Majors
Tests
Claims / mechanics
Re-gated: 58 checks green on pg18a and pg19a (assert) and pg18_san (ASan/UBSan), clean preflight compile on all five majors. The single-table (non-partitioned) case remains the tracked columnar-core-bulk follow-up. |
jdatcmd
left a comment
There was a problem hiding this comment.
Approved
Partition-parallel is correct and complete for v1: each worker owns a distinct set of partitions (distinct storage id), so the load is parallel and atomic (2PC) with none of the same-table write-lock deadlock.
The pivot review's findings are all addressed on the branch: the MINVALUE/MAXVALUE crash (kind-dispatch, plus a text-key regression), cancel/SIGTERM no longer leaking prepared transactions (flag handler + latch wait + rollback in normal context), the INSERT-privilege check, and the splitter parser divergences (generated-column skip, CRLF, per-row Datum free, key restricted to escape-free numeric/temporal types). Rows-survive-ROLLBACK and the lock-hang are documented.
- Gated: 58 checks green on pg18a + pg19a (assert) and pg18_san (ASan/UBSan); clean preflight compile on all five majors (15-19).
- Benched: ~5.0x on a 20M slice and 5.40x on the full 100M / 17GB file vs single COPY, results identical (suite asserts via pgc_set_hash), zero prepared-xact leaks.
Single-table (non-partitioned) parallel load is the tracked follow-up (columnar-core bulk path).
…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
) 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
Parallel bulk ingest — design + incremental build (#300)
Follows the measurement on #300 (parallel ingest scales ~7.4× to the physical core count; a real 8-way 100M load is 74.9s, beating a single-COPY heap load). This draft opens with the design doc (
design/PARALLEL_COPY_PLAN.md) and will grow the implementation in the phased slices it lists, each a compiling, gated commit.pgcolumnar.parallel_copy(target, path, workers, mode => 'atomic'|'staging', ...)fans coreCOPYacross N background workers, each over a line-aligned byte range of the file — so parse/NULL/quote/encoding semantics stay exactly core COPY's, no looser parser to get wrong (the correctness risk the issue flags). Two admin-selectable all-or-nothing modes, per the maintainer's steer:atomic— workersPREPARE TRANSACTIONinto the target; coordinator commits-all or rolls-back-all (needsmax_prepared_transactions). True COPY-like atomicity into a populated table.staging— workers load partitions of a partitioned target; coordinatorATTACHes them (metadata-only). No 2PC requirement.Open questions I'd like a steer on before building the mechanism-dependent parts (details in the doc):
dblinkorchestrator (simpler, but a contrib dependency + self-connections).stagingcombine: ATTACH PARTITION (metadata-only, needs a partitioned target) vs a columnar-native stripe splice into a non-partitioned target (deferred — needs format surgery + its own crash-safety proof).workers: physical-core count vs a fixed 8.The file range splitter (phase 1) is mechanism-independent and I'm building it now regardless of the above.
🤖 Generated with Claude Code