Skip to content

docs: document parallel_copy + full bench benchmark (#300) - #326

Merged
jdatcmd merged 2 commits into
commandprompt:mainfrom
ChronicallyJD:docs/300-parallel-copy
Aug 2, 2026
Merged

docs: document parallel_copy + full bench benchmark (#300)#326
jdatcmd merged 2 commits into
commandprompt:mainfrom
ChronicallyJD:docs/300-parallel-copy

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

parallel_copy (#300, shipped in #323 and #324) had no user-facing documentation — it existed only in the SQL COMMENT. This documents it and adds a full ingest benchmark from the bench.

Docs added

  • features.md — a "Parallel bulk ingest" section.
  • user-guide.md — a "Parallel bulk load" subsection under Load data, with the max_prepared_transactions requirement and the sorted-key rule for partitioned targets.
  • sql-reference.md — the full pgcolumnar.parallel_copy(target, path [, workers]) entry: the two target kinds (single columnar table = any row order; RANGE-partitioned = file sorted ascending by a numeric/temporal partition key), atomicity via 2PC, and the pg_read_server_files + INSERT privileges.
  • limitations.md — the constraints: COPY text format only, the accepted target kinds, the sorted-key requirement, the core-count plateau, and the 2PC in-doubt window.
  • CHANGELOG.md — an Added entry.

Full bench benchmark (benchmarks.md)

Bench, PostgreSQL 18.4 non-assert, 16 vCPU / 8 physical cores / 62 GB. TSBS cpu, 21 columns, sorted by time. Median of three interleaved rounds, source file warm. Baseline is one server-side COPY.

Single columnar table, 20M rows:

workers seconds speedup
1 (COPY) 129.8 1.00x
2 67.4 1.93x
4 36.1 3.60x
8 20.6 6.29x
16 18.9 6.87x

Plateau at the 8 physical cores; the encode is CPU-bound. All runs load 20,000,000 rows with an identical sum(usage_user); on-disk size varies 0.03% as the split shifts a few stripe boundaries.

100M rows: one COPY 644.1 s vs parallel_copy(16) 92.8 s = 6.94x; both 2.67 GB (within 0.004%). Row counts match; the float sum matches to nine figures and differs in the last, because parallel summation adds in a different order.

RANGE-partitioned, 20M rows, 24 hourly partitions: COPY 134.0 s vs 8w 29.1 s (4.61x), 16w 25.4 s (5.27x). Partition routing costs a little more than the single-table split.

Also: two stale inline comments

Found while auditing. Both outlived the single-table work in #324:

  • the SQL COMMENT still described the target as RANGE-partitioned only;
  • a comment in columnar_parallel_copy.c said single-table parallel load was "a planned columnar-core enhancement" directly above the code that implements it.

Validation

  • test/ste_check.py passes on every changed doc.
  • Full gate green on the container: preflight 0-warning on all five majors (15–19), matrix ALL VERSIONS PASSED on pg18a and pg19a (assert), including docs_style and parallel_copy.
  • No behavior change: the only non-doc edits are inline comments.

…ommandprompt#300)

parallel_copy (commandprompt#300, shipped in commandprompt#323 + commandprompt#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
@jdatcmd

jdatcmd commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Review: accurate and honest. One missing restriction, and it is a surprising one.

I checked the documented restrictions against the code rather than reading them for
plausibility, since the failure mode for a docs PR is documenting behaviour the code
does not have.

Everything stated checks out. In particular the line I most expected to be
wrong is right:

The caller needs membership in the pg_read_server_files role and INSERT on the
target.

pg_class_aclcheck(relid, GetUserId(), ACL_INSERT) with aclcheck_error is there,
so this is enforced, not aspirational. That also closes the privilege finding I
raised on #323, which I had not gone back to re-verify. Good.

The rest matches too: text format only, the two accepted target shapes, sorted input
and numeric/date-time key types for the partitioned path, the up-front
max_prepared_transactions check, and the core-count bound on speedup.

The 2PC paragraph is the part I would have been tempted to soften and you did not:

A coordinator crash during the final commit step can leave some ranges committed
and some prepared. This is the ordinary two-phase-commit in-doubt case. A DBA
resolves it from pg_prepared_xacts.

That is the honest description of the window, and naming pg_prepared_xacts gives
the reader the actual next step.

Also right to fix the stale code comment and the SQL COMMENT in the same change:
both still said single-table parallel load was "a planned columnar-core
enhancement", which stopped being true when #324 merged.

The gap: parallel_copy is not part of the caller's transaction

Nothing in the docs says this, and it is the most surprising thing about the
feature:

BEGIN;
SELECT pgcolumnar.parallel_copy('t', '/data/f.txt', 4);
ROLLBACK;
-- the rows are still there

The coordinator and loaders run their own transactions in background workers
(StartTransactionCommand/CommitTransactionCommand at
columnar_parallel_copy.c:792, 881, 924, 1024), and there is no
PreventInTransactionBlock guard, so the call is permitted inside a transaction
block and its effects survive the caller's ROLLBACK.

"Atomic" is doing a lot of work in the current text. It is accurate about what it
means -- all workers commit or none do -- but a reader who has just been told a bulk
load is atomic will reasonably assume it participates in their transaction, and it
does not. One sentence in the limitations list would cover it, something like: the
load commits on its own, so it is not rolled back by the calling transaction.

Whether to also add PreventInTransactionBlock is a separate call and not a docs
question. Documenting it is the minimum.

Everything else is fine by me. test/docs_style.sh passes on the branch.

…on (commandprompt#300)

jdatcmd's commandprompt#326 review: 'atomic' can mislead. The load runs in background
workers and commits independently, so it is NOT part of the calling
transaction -- a caller ROLLBACK does not undo it, and there is no
PreventInTransactionBlock guard. Verified on pg18a assert: BEGIN;
parallel_copy(...5000 rows); ROLLBACK; leaves all 5000 rows, 0 prepared
leaked. Document it where atomicity is discussed (limitations, sql-reference,
user-guide). The atomicity is across the workers, not with the caller.

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

Confirmed and fixed in 7a772e2. You are right, and I verified it on pg18a assert rather than reason about it:

BEGIN; SELECT pgcolumnar.parallel_copy('t', '/…/f.txt', 2);   -- returns 5000
ROLLBACK;
SELECT count(*) FROM t;                                        -- 5000
SELECT count(*) FROM pg_prepared_xacts;                        -- 0

So the rows survive the caller's ROLLBACK, and the call is permitted inside a transaction block, exactly as you traced from the background-worker Start/CommitTransactionCommand sites.

Documented where "atomic" appears, so a reader meets the caveat at the claim rather than later:

  • limitations.md: "The load commits on its own. It runs in background workers, so it is not part of the calling transaction. A ROLLBACK in the caller does not undo the loaded rows. The atomicity is across the workers, not with the caller."
  • sql-reference.md and user-guide.md: the same clarification next to the atomicity sentence.

test/docs_style.sh passes on the branch.

On PreventInTransactionBlock: agreed it is a separate, non-docs call, so I only documented the behavior. If you want the guard, I can add it as its own change. It would turn the in-transaction case into an up-front error instead of a silent surprise, at the cost of rejecting callers who deliberately fire a load from inside a larger script. Your call.

@jdatcmd
jdatcmd merged commit 9920e82 into commandprompt:main Aug 2, 2026
11 checks passed
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.

2 participants