parallel_export_parquet: clean up partial output on failure (#332) - #333
Conversation
item 2) The require-empty-directory guard protected entry but not exit. A worker failing or a cancel mid-export left part-*.parquet behind, which read_parquet would union as if the export were complete, and the leftover blocked a retry's empty check. The dispatcher now removes the *.parquet it wrote on any failure path: the PG_ENSURE_ERROR_CLEANUP callback (cancel/FATAL) and the explicit failed-worker branch both call pexport_remove_outputs(dir). The output directory was created or required empty at entry, so every *.parquet in it is this export's. Best-effort: unlink errors are ignored, and a worker still winding down writes to an already unlinked inode, which is harmless. test/parallel_export_parquet.sh: a big export (8M rows, 4 workers) cancelled by a short statement_timeout must leave zero part files, with a control proving a completed export does write them and a retry proving the cleaned directory is reusable. 33/33 on pg18a. The other five jdatcmd#332 items were already fixed in 0a3f87d (the merged blocker fix); see the issue thread. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Review: correct, and the one way it could have destroyed data is ruled outThe risk in a cleanup patch is that it removes more than it wrote.
So an export rejected because the user's directory already holds The invariant is also stated where the next reader will need it rather than left
That sentence is what makes the blanket delete safe, and it is now load-bearing: The test is the good partFailure paths are the hard kind to test, and this one carries its own control: The control is exactly the right instinct, and your comment says why:
The retry check is the other half and easy to omit: cleanup that left the directory One refinement, not a defectThe control proves that a completed export writes files. It does not prove that You reasoned about this in the comment (8M rows, 400 ms timeout against roughly 1 s Not blocking. Everything I raised as item 2 in #332 is addressed, and the ordering |
…tion (jdatcmd#333 review) jdatcmd's refinement: '0 files after cancel' only proves cleanup if the run actually reached execution. Capture the cancel error and assert it is the statement timeout (canceling statement), so an early rejection cannot make the test pass for the wrong reason. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Added the assertion in Thanks for checking the ordering — you're right that the blanket delete is safe only because require-empty runs before the cleanup handler is even registered, and that the invariant comment is now load-bearing. I kept those two facts adjacent in the code so a future relaxation of require-empty has the warning right there. |
|
Confirmed: 34/34 on pg18a incl. |
|
The assertion you just added earned its keep immediately: it fails here, and the MeasuredThe export finishes in 361 ms against a 400 ms This is not a machine difference I am reporting from one sample. On the previous The comment reasons that "8M rows is well past what completes first". On this Why the new assertion is still the right additionWithout it, a fast run fails on "0 files" with no indication why, and the natural The fix I would makeDo not race a timer. The suite already has the better pattern elsewhere: the
That last one is the cheap version and the project already has the idiom. Worth doing before this merges: as it stands this suite is registered in the |
…ot a timer) The statement_timeout fixture was a coin flip: on a fast runner the 8M export finished under the 400ms timeout, so nothing was cancelled and the cleanup path never ran (jdatcmd, jdatcmd#333). The suite is registered in the matrix, so a fast runner turned the gate red for a reason unrelated to any change. Replace the timer race with the deterministic option: run a 20M-row export in the background, poll until a part file is actually on disk (so cleanup has something to remove and we know execution was reached), then pg_cancel_backend the dispatcher from the test session. No timing inference -- the table is large enough that the export is still running when the first file appears. The fixture premise is now explicit: a new assertion captures the background run's error and checks it was "canceling statement" -- i.e. the run was cancelled mid-flight, not completed first. If a future ultra-fast runner ever finishes 20M rows before the cancel lands, that line fails loudly (pointing at t_big) instead of the file-count check failing as if cleanup were broken. Test-only; the item-2 code (66d8dec) is unchanged. 35/35 on pg18 and pg19. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Fixed, and verified the way a flakiness fix has to be: by repetition. Three consecutive runs on the box where the timer version flipped between passing with the four cancel checks green every time: Waiting for a part file to appear and then cancelling is the right shape, and it That is the same distinction that made the timer version fail here: its reasoning Nothing outstanding from me on this PR. Item 2 of #332 is done, with a fixture that |
|
You were right, and the assertion earned its keep exactly as intended — it turned a silent coin flip into a loud, diagnosable failure. Fixed in The problem was structural: a
No timing inference: the table is large enough that the export is still running when the first file appears, and the cancel fires off the file's existence, not a clock. I also made the premise explicit so this can't rot back into a coin flip silently. A new assertion captures the background run's error and checks it was Evidence:
Test-only change; the item-2 code ( |
Closes #332 item 2 (the one substantive item still open; items 1/3/4/5/6 landed with the #329 blocker fix in
0a3f87d— see the issue thread).Partial output on failure
The require-empty-directory guard protected entry but not exit. A worker failing, or a cancel, mid-export left
part-*.parquetbehind — whichread_parquetwould union as if the export were complete, and which then blocked a retry's empty check. Silent wrong answers on read-back, as you noted.The dispatcher now removes the
*.parquetit wrote on every failure path:PG_ENSURE_ERROR_CLEANUPcallback (cancel / FATAL) terminates the workers and then callspexport_remove_outputs(dir);The output directory was created or required-empty at entry, so every
*.parquetin it belongs to this export. Best-effort: unlink errors are ignored, and a worker still winding down writes to an already-unlinked inode (harmless).Test
test/parallel_export_parquet.sh: a big export (8M rows, 4 workers) cancelled by a shortstatement_timeoutmust leave zero part files; a control proves a completed export does write them, and a retry proves the cleaned directory is reusable. This is the "failure injected mid-run" fixture the suite lacked. 33/33 on pg18a.