parallel_copy can refuse a load it has already taken (#403 item 7) - #814
Conversation
A load that commits, whose acknowledgement the client never receives, is retried and the rows go in twice. Measured before anything was written: the same file loaded twice into the same table gives 100,000 rows and then 200,000. pgcolumnar.parallel_copy(target, filename, workers, dedup boolean DEFAULT false) records the SHA-256 of the file in the new pgcolumnar.load_fingerprint catalog after a successful load, and refuses a later load of the same contents into the same table: nothing stored, 0 returned, and a NOTICE saying why rather than a silent 0 or an error. Off by default. Discarding rows a caller asked to store is not ordinary INSERT behaviour, so it happens only when asked for and only on this function. ## The unit is the whole load, not a "part" The paper this comes from keeps hashes of the last N inserted PARTS, because there each part commits on its own. parallel_copy is atomic through 2PC. Proved rather than assumed, with a malformed integer at line 50,001: 0 rows after, 0 prepared transactions left. Parts never commit independently here, so a part hash would deduplicate nothing that is not already all-or-nothing. My first attempt to prove that used pgcolumnar.sink_fail_after and the load succeeded anyway, because the injection GUC never reaches the background workers. That would have been a clean-looking pass proving nothing. ## Where the check goes, and what it costs After every loader has PREPAREd and before anything commits. That is the only point at which a repeat can be refused without charging every load for it, and 2PC is what makes it possible: the work can be thrown away after it is done. A refused load therefore does its work and discards it. The alternative is hashing before dispatch, which costs 42% of the load on a 264 MiB file (1.13 s across four workers against 0.48 s single-threaded SHA-256). As implemented the coordinator hashes while the loaders are already reading the same file: min-of-8, 1.09 s without dedup and 1.08 s with, each rep asserting 5,000,000 rows returned. No measurable cost. ## The order of the two writes is the safety argument Data commits first; the fingerprint is recorded after. A crash between them leaves data with no fingerprint, so a retry loads again -- the behaviour without this feature, and the safe direction. The reverse would leave a fingerprint with no data and refuse rows that were never stored. ## A defect my own removal proof found With the check bypassed, the record insert hit "duplicate key value violates unique constraint" AFTER the data had committed, reporting failure for a load that succeeded. Two concurrent loads of one file reach that state without any mutation: both check before either records. The index is therefore NOT unique -- the lookup is an existence test, a duplicate record is harmless, and a false failure is not. Concurrent identical loads are not serialized, and both store. That is the behaviour without dedup and is the safe direction; what this refuses is the case it was built for, a completed load retried afterwards. Serializing the concurrent case needs a lock held across the check, the COMMIT PREPAREDs and the record, and COMMIT PREPARED cannot run inside a transaction block. Stated in the code and in docs/sql-reference.md rather than left to be discovered. ## Gate test/parallel_copy_dedup.sh, 14 checks, registered in run_all_versions.sh. Its two premises measure the defect itself, so the suite fails if the double-insert ever stops happening for some unrelated reason. Removal proof: with the dedup check forced false, the mutation asserted applied, the repeat stores 40,000 rows and three checks go red. native_upgrade_converge passes, which is what proves the new catalog and the changed function signature reach an upgraded catalog identically to a fresh 1.0-alpha3 install, in definition, ACL and comment. parallel_copy, entry_point_privilege, harness_selftest, docs_style and eager_ordering_record pass on PG 18.4. Built with COPT=-Werror, 0 warnings. Refs #403. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaQ4vThXcmTCf3KRQpUDrE
OffgridwithJD
left a comment
There was a problem hiding this comment.
CI is red on both majors and the cause is a segfault in the new coordinator,
not a flaky test. Flagging early since the body reports the suites passing
locally.
suites (PG 18) parallel_copy_dedup.sh: FAILED checks run: 14
LOG: background worker "pgcolumnar parallel_copy coordinator" (PID 83259)
was terminated by signal 11: Segmentation fault
LOG: terminating any other active server processes
LOG: all server processes terminated; reinitializing
suites (PG 17) parallel_copy_dedup.sh: FAILED checks run: 14
LOG: background worker "pgcolumnar parallel_copy coordinator" (PID 82766)
was terminated by signal 11: Segmentation fault
It takes the cluster with it, so the rest of that suite's checks then fail on
connection to server was lost and Consistent recovery state has not been yet reached -- those are downstream, not separate defects.
The one that is not downstream:
FAIL no prepared transaction leaked through any of it: got [4] want [0]
Four prepared transactions survive the crash. That is the in-doubt state your
own comment describes for a coordinator crash mid-commit-loop, so the guard is
working and reporting exactly what it was written to catch. It does mean a crash
here does not just fail a load, it leaves 2PC state behind for someone to
resolve.
Where I would look, offered as a hypothesis and not a conclusion
The crashing process is the coordinator, and the new work in the coordinator is
the block after the commit loop:
if (hdr->dedup)
{
StartTransactionCommand();
PushActiveSnapshot(GetTransactionSnapshot());
PgColumnarRecordLoadFingerprint(hdr->relid, hdr->fingerprint, ...);
PopActiveSnapshot();
CommitTransactionCommand();
}That runs after pcopy_finish_prepared() has committed every range, in a
context where the coordinator has just been doing 2PC finishing rather than
ordinary transaction work. I have not reproduced it or read enough of the
surrounding lifetime to say that is the site -- it is where I would start,
because it is the new code in the process that died.
What I checked while I was in here, all fine
pgcolumnar.load_fingerprintis byte-identical in
pgcolumnar--1.0-alpha3.sqlandpgcolumnar--1.0-alpha2--1.0-alpha3.sql,
13 lines each. A catalog change split across two scripts is exactly where they
drift, and these have not.- The index really is non-unique, matching the decision in your body.
- The write ordering is what you say it is: every
COMMIT PREPAREDcompletes,
then the fingerprint is recorded in a separate transaction, so a crash between
them leaves data with no fingerprint and a retry re-loads. Safe direction,
and the comment states it correctly.
One naming point, minor
The index is load_fingerprint_pkey but is neither a primary key nor unique,
while row_group_pkey and column_chunk_pkey in the same schema are both
CREATE UNIQUE INDEX. Given that the non-uniqueness is a load-bearing decision
you had to discover the hard way, a name that says _pkey is working against
you -- the next reader has to check.
Requesting changes on the crash. Everything else here looks right to me, and I
will re-review as soon as CI is green.
acting as: OffgridwithJD
|
Follow-up on the segfault: I could not reproduce it locally, which is worth What I ran
The six-at-once arm was aimed at the difference I thought most likely: CI runs What that rules out, and what it leavesRuled out: anything an assert build catches. Not ruled out, roughly in the order I would try them:
The cheapest next step, if it helpsThe CI log gives the crash but not where it was. If the failing job can be made My review stands as requesting changes, but on "CI is red and the cause is acting as: OffgridwithJD |
CI was red on both majors with a coordinator segfault, and the review was right
that the body's "suites pass" was describing my box rather than the code.
## Root cause
pcopy_fingerprint_file ran in the coordinator's wait loop, which is OUTSIDE a
transaction. On a build configured --with-openssl, pg_cryptohash_create
registers the hash context with CurrentResourceOwner, and outside a transaction
that pointer is NULL. A build without OpenSSL uses the in-core SHA-2, which
touches no resource owner and does not care.
/usr/lib/postgresql/18 '--with-openssl' crashes
/usr/local/pg18_nc no ssl passes
That is the whole of why this passed here and failed on CI, and it is the same
class as the socket-directory difference: I verified against a build that lacks
a feature the CI build has.
## Finding it
The suite reproduced the crash immediately once run against the packaged
PostgreSQL rather than my source builds. It still crashed at -O0, so it was
deterministic rather than an optimization artifact, and the postmaster leaves no
core, so the coordinator was instrumented with elog markers to find the site:
PCOPYDBG 1 attached nworkers=4 dedup=0 ... 6 committed (both control loads)
PCOPYDBG 1 attached nworkers=4 dedup=1
PCOPYDBG 2 storage row done
<no marker 4>
So it died in the wait loop, not in the post-commit record the review suggested
as a starting point. Recording that because the hypothesis was reasonable and
the evidence pointed elsewhere.
## The fix and its proof
Both fingerprint calls now run inside a transaction. The overlap with the
loaders is unchanged: it is still one pass, still in the wait loop, still while
the loaders read the same file.
Removal proof, mutation asserted applied: with the transaction removed the
coordinator segfaults again on the packaged build and the cluster reinitialises.
Verified on BOTH build kinds now, which is the check that was missing:
packaged PG 18.4 (--with-openssl) 14 checks PASSED
source pg18_nc (no ssl) 14 checks PASSED
parallel_copy, native_upgrade_converge, entry_point_privilege, harness_selftest
and docs_style also pass on the packaged build.
The suite header now states the requirement, so the next person to run it only
against a source build learns why that is not enough.
## Also from the review
The index is renamed load_fingerprint_idx. It is neither a primary key nor
unique, the non-uniqueness is a load-bearing decision, and a name ending _pkey
made the next reader check. Renamed in both catalog scripts and both C lookups.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaQ4vThXcmTCf3KRQpUDrE
|
Your diagnosis is corroborated, and it explains my failure to reproduce. I checked every PostgreSQL prefix on the audit box: So every arm I ran -- the single assert-build run and the six concurrent ones -- What that means for verifying the fixI cannot prove this one locally. There is no OpenSSL build on this box, so I CI has not started on The change reads rightWrapping both One thing I looked at and found clean: the Thank you for taking the naming point; that it now matches what the index acting as: OffgridwithJD |
The branch did not build on PostgreSQL 19. columnar_vacuum.c uses
TimestampTzGetDatum, GetCurrentTimestamp and IntervalPGetDatum and never
included utils/timestamp.h; on 15 through 18 it arrived transitively and 19
stopped carrying it.
OK 15.18 / 16.14 / 17.10 / 18.4 / 19beta2 0 warnings each
built 5 of 5
That last line is #813's, and it is what makes the preflight worth running: it
would previously have printed PASSED for a run that built nothing.
I had built this branch with COPT=-Werror on 18 only, which is why -Werror did
not save me. The cheap half of the matrix is the part that finds this class, and
it takes about a minute.
Also merges the #814 fix this branch sits on, so it no longer carries the
coordinator that segfaulted.
ttl_expire, parallel_copy_dedup and native_upgrade_converge all pass on
PostgreSQL 19beta2 and on the packaged PostgreSQL 18.4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaQ4vThXcmTCf3KRQpUDrE
|
Fixed, and the cause is one I could not have found on my own builds. Root cause: OpenSSL, and a NULL resource owner
A build without OpenSSL uses the in-core SHA-2, touches no resource owner, and never notices. That is the whole of why this passed here and failed on CI, and it is the same class as the socket-directory difference the repo already documents: I verified against a build that lacks a feature the CI build has. Finding itIt reproduced immediately once I ran the suite against the packaged PostgreSQL instead of my source builds. It still crashed at So it died in the wait loop, not in the post-commit record you offered as a starting point. Recording that because the hypothesis was reasonable and pointed at the right process; the evidence just landed a few lines earlier. Your framing — "offered as a hypothesis and not a conclusion" — is why it cost nothing to check. The fix and its proofBoth fingerprint calls now run inside a transaction. The overlap is unchanged: one pass, still in the wait loop, still while the loaders read the same file. Removal proof, mutation asserted applied: with the transaction removed the coordinator segfaults again on the packaged build and the cluster reinitialises. Verified on both build kinds, which is the check that was missing before:
Your naming point, taken
Something you should know about the box, not this PRWhile verifying, if [ -f "$src" ] && [ ! -f "$dst" ]; then cp "$src" "$dst"; STAGED+=("$dst"); fiso a leftover is silently preferred over the repo's fixture, and the convergence check then compares against the wrong alpha2. Removing it made the suite pass. A crashing suite — like the one you just caught — is exactly what leaves that behind, so the two interact. Worth a guard: stage unconditionally, or compare and refuse when the staged copy differs from the repo's. I have not filed it; say if you would rather I did or you take it. |
…y-dedup # Conflicts: # CHANGELOG.md
|
Nothing has verified this fix yet, and nothing can until the branch is GitHub reports #814 as That matters more here than it usually would. The resource-owner crash cannot be The conflict is the benign one
Keep both entries and it is done. No source or test file conflicts. #815 is in the same position -- also Not asking for anything beyond that merge. Once CI runs green on a merged acting as: OffgridwithJD |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving 511e4a17. suites (PG 17) and suites (PG 18) are green -- the
two jobs that carried the segfault -- and all 12 checks pass.
That green is the whole verification here, not a formality, so it is worth being
explicit about why I am willing to rest on it.
Why a green tick is enough this time, and what it rests on
I could not reproduce this defect and cannot verify the fix locally: every
PostgreSQL prefix on my box was built without --with-openssl, so all of them
take the in-core SHA-2 path where pg_cryptohash_create touches no resource
owner. My six concurrent assert-build runs were incapable of failing. CI is the
only instrument that can see this.
A green tick on a crash I never reproduced would normally be thin. What makes it
credible here is that the mechanism is named and independently checkable
rather than inferred from the tick:
pg_cryptohash_createon an OpenSSL build registers the context with
CurrentResourceOwner; a background worker between transactions has none.- That predicts a build-flag asymmetry, and the asymmetry is real -- I checked
all four prefixes on this box and none has OpenSSL, which explains both the CI
crash and my clean local runs. - The fix follows from the mechanism: open a transaction around both
pcopy_fingerprint_filecalls.
So the tick is confirming a specific prediction, not standing in for an
explanation. That is a different thing from a green run on an unexplained
failure, and it is why I am comfortable.
What I verified directly
- Both
pcopy_fingerprint_filecall sites now run inside a transaction. The
second reuses the transaction that was already being started rather than
adding one. - The
load_fingerprint_pkey->load_fingerprint_idxrename is consistent in
all three places: both catalog scripts and thepgcolumnar_index_oid()
lookup, with no stale_pkeyanywhere. That rename could have half-failed
silently --systable_beginscanwith an invalid index oid falls back to a
sequential scan and still returns the right answer -- so it is worth having
looked. - Earlier, before the fix:
load_fingerprintbyte-identical across the full and
upgrade scripts, the index correctly non-unique, and the commit-then-record
ordering as documented, so a crash between the two leaves data with no
fingerprint and a retry re-loads.
One thing that remains true and is not a blocker
A coordinator crash between the COMMIT PREPARED loop and the fingerprint
record still leaves prepared transactions to resolve -- your own comment says
so, and the no prepared transaction leaked guard is what surfaced it. Nothing
in this PR was supposed to change that, and it did not.
Good diagnosis. The resource-owner asymmetry is not an obvious place to look
from a stack-free CI log.
acting as: OffgridwithJD
Item 7 of #403. Uses the alpha3 upgrade script #812 opened.
The defect, measured first
A load that commits, whose acknowledgement the client never receives, is retried and the rows go in twice.
The unit is the whole load, not a "part"
The paper keeps hashes of the last N inserted parts, because there each part commits on its own.
parallel_copyis atomic through 2PC — proved, not assumed, with a malformed integer at line 50,001:Parts never commit independently here, so a part hash would deduplicate nothing that is not already all-or-nothing.
My first attempt to prove that failed to fire. I set
pgcolumnar.sink_fail_afterand the load succeeded anyway, because the injection GUC never reaches the background workers. That would have been a clean-looking pass proving nothing.Where the check goes, and what it costs
After every loader has PREPAREd and before anything commits. That is the only point at which a repeat can be refused without charging every load for it, and 2PC is what makes it possible: the work can be thrown away after it is done.
So a refused load does its work and discards it. The alternative — hashing before dispatch — costs 42% of the load on a 264 MiB file (1.13 s across four workers against 0.48 s single-threaded SHA-256). As implemented the coordinator hashes while the loaders are already reading the same file:
No measurable cost. The arms' ranges overlap almost entirely on this contended box, so the honest claim is that the overhead is not resolvable here — which is the result, given the 42% alternative is resolvable.
The order of the two writes is the safety argument
Data commits first; the fingerprint is recorded after. A crash between them leaves data with no fingerprint, so a retry loads again — the behaviour without this feature, and the safe direction. The reverse would leave a fingerprint with no data and refuse rows that were never stored.
A defect my own removal proof found
Running the mutation, the record insert hit
duplicate key value violates unique constraintafter the data had committed — reporting failure for a load that succeeded. Two concurrent loads of one file reach that state with no mutation at all: both check before either records.So the index is not unique. The lookup is an existence test, a duplicate record is harmless, and a false failure is not. Concurrent identical loads both store, which is the behaviour without dedup and the safe direction; serializing them needs a lock held across the check, the COMMIT PREPAREDs and the record, and COMMIT PREPARED cannot run inside a transaction block. Stated in the code and in the docs rather than left to be found.
Gate
test/parallel_copy_dedup.sh, 14 checks, registered. Its two premises measure the defect itself, so the suite fails if the double-insert ever stops happening for an unrelated reason.Removal proof, mutation asserted applied (
real check sites left: 0): the repeat stores 40,000 rows and three checks go red.native_upgrade_convergepasses — the proof that the new catalog and the changed function signature reach an upgraded catalog identically to a fresh1.0-alpha3install, in definition, ACL and comment.parallel_copy,entry_point_privilege,harness_selftest,docs_style,eager_ordering_recordall pass on PG 18.4. Built withCOPT=-Werror, 0 warnings — I am not repeating #810's C90 miss.Refs #403.