fix(io): publish an output only if its teardown succeeded (#434) - #436
Conversation
|
Thanks — this is a careful patch, and the parts I could check hold up. CI hasn't run yet (fork PRs need approval here), so I ran it locally against One thing your analysis couldn't see from inside this repo, and it changes the calculus on the framing change. nf-core/modules pins the compressed bytes in its nf-test snapshot: Our CI is immune because it compares through Could you make the fix byte-identical? Calling Otherwise I'm happy with the shape — deleting |
f855902 to
c277208
Compare
|
Done — pushed as
Last 24 bytes of 341,686 / 341,681 / 341,674 bytes. So the cost is 7 + 5 here, not 11 — the second marker is a flat 5, the first also has to terminate the open block and pad to a byte boundary, which is why the CHANGELOG's 1M-read measurement said 11 and this 10K one says 12. All three decompress to the same md5.
One thing your local run couldn't have caught, since it compared decompressed md5s: Verified against
Pinned by The CHANGELOG paragraph announcing the framing change is now the opposite paragraph: output bytes unchanged, with the reason recorded so the next person to look at that double flush knows it is load-bearing. Here: 709 tests pass, 0 failed; |
|
The double-flush finding is right and I'd have missed it — but the loop landed in Measured against Worth knowing why the 12/12 matrix missed it: Also from review, independent of the above: |
c277208 to
6d727ba
Compare
|
Done — pushed as You were right about Your explanation of why the matrix missed it is in the code now, at The panic is real, and the obvious test does not catch itI wrote the test I expected to fail, and it passed both with and without a fix. That was worth chasing rather than shipping.
The panic needs a run where no
One thing I had to fix twice. My first disarm only covered What disarming costs, stated rather than buried:
|
|
Confirmed at On CI is running on this now — it had not run since |
…er#434) FastqWriter held a Box<dyn Write + Send>, through which neither GzEncoder::try_finish nor gzp's ParCompress::finish is reachable, so each sink's trailer was written by its own Drop and the error discarded. An ENOSPC in the last few bytes of a serial-gzip run therefore left a trailerless .gz at its final name and exited 0. Replace the box with an enum over the three concrete sinks and run the teardown in finish(), returning its error before PendingOutput::commit. Two matching cases in --cores N: a single-end worker error now reaches the main thread through the result channel instead of being printed and swallowed, and both parallel paths join their workers before publishing so a worker panic cannot commit a short output on its way out. Serial-gzip .gz framing changes: the old teardown reached GzEncoder::flush, which emits a deflate sync marker; try_finish does not. Decompressed output is unchanged.
…hange The pre-FelixKrueger#434 teardown flushed the sink twice before its trailer -- once in FastqWriter::finish, once again in impl Drop for FastqWriter -- and each Z_SYNC_FLUSH is visible on the wire, so the marker count is part of the format v2.x ships. Sink::finish now reproduces both. This commit restores the serial Sink::Gz arm. Verified against dev @63f5d44 across single-end, --paired, --rrbs, --polyA, --nextera, --compression 6, --cores 2 and --cores 4: 12/12 outputs cmp-identical. That matrix does not reach Sink::ParGz, and an earlier version of this message read it as though it did -- it said --cores N "was already byte-identical at the byte level". It was not. --cores N *trimming* never constructs a Sink at all: parallel.rs compresses each batch into a Vec and writes it through a raw File, so the --cores rows above exercise the serial path. ParGz is reachable only from specialty.rs and demux.rs. It is restored in the following commit. Pinned by a test that rebuilds the reference from flate2 in the old call order rather than storing an md5, and counts the markers -- one flush is the plausible way to get this wrong and it is 5 bytes short.
…icking Sink::ParGz lost both Z_SYNC_FLUSH markers across FelixKrueger#434 for the same reason the serial arm did: ParCompress::flush is flush_last(false), which reaches FlushCompress::Sync, so the old finish-then-Drop pair put two on the wire here too. The 12/12 matrix on the previous commit could not see it -- --cores N trimming never builds a Sink, so ParGz is reachable only from specialty.rs and demux.rs, and --hardtrim5 30 --cores 2 is the shortest invocation that tells the two paths apart. Measured against dev @63f5d44, all binaries built here, cmp not md5. Before this change: --hardtrim5 30 --cores 2 is 209,305 -> 209,294 and --hardtrim3 30 --cores 4 is 209,190 -> 209,178. After it, 32 outputs across 15 invocations are cmp-identical -- the 12 rows above plus hardtrim5/3 at --cores 1, 2 and 4 and --demux at --cores 1 and 2. A failed ParGz teardown also panicked instead of returning Err. ParCompress::finish propagates a failed flush_last with ? before it takes its channels and join handle, so ParCompress::drop reads that as "never finished", calls finish() a second time and unwraps the same error. Since FelixKrueger#434 is about a failed teardown being reported rather than swallowed, that left the parallel arm half-fixed. The window is narrow: ParCompress::write takes the join handle itself on a send failure, which disarms Drop, so the panic needs a run where no write ever failed -- payload still in the compressor's buffer and the writer thread already dead. Disarming the Drop leaks a Sender pair and a JoinHandle on a path that is already returning a fatal error; the real fix belongs upstream in gzp. Adds the tests ParGz had none of -- byte-identity and the non-panicking teardown -- and corrects two claims elsewhere. close(2) is unchecked in all three arms, so FastqWriter::finish no longer says every byte "has been written successfully"; and gz_sink_finish_reports_a_failing_teardown fails on the gzip HEADER rather than the trailer its comment named, so a new pipe-based test covers the failure-after-a-successful-write case that FelixKrueger#434 is actually about. 709 -> 712 tests, fmt and clippy clean.
6d727ba to
b22a9e5
Compare
|
Rebased and pushed as The rebaseThree commits had landed on But
Tests, both sides on this box:
|
Closes #434.
Three changes, one invariant: an output file appears under its final name only if every
byte it owes was written. #433 made the name appear only after the writer was closed;
this makes "closed" mean "complete".
1. The sink is an enum, so teardown is callable
FastqWriter'sBox<dyn Write + Send>becomesSink::{Plain, Gz, ParGz}overBufWriter<File>,BufWriter<GzEncoder<File>>andParCompress<'static, Gzip, File>—the shape the issue proposes.
finish()runs the concrete teardown (BufWriter::into_inner,GzEncoder::try_finish,gzp::ZWriter::finish) and returns its error beforePendingOutput::commit. A failed teardown returns withpendingstill armed, so itsDropremoves the temporary and nothing is published.
impl Drop for FastqWriteris gone rather than adapted: its only job waslet _ = self.writer.flush(), whichBufWriter's ownDropalready does on the abandon path, andremoving it lets
finishdestructureselfinstead of theOption-take-then-drop(self)dance the old signature needed. Field order still puts
writerbeforepending, so on theabandon path the compressor is closed before the temporary is removed — the reason the old
comment gave, unchanged.
impl Write for Sinkforwardswrite_allexplicitly, becauseFastqRecord::write_toissues exactly one
write_allper record and the default would wrap it in a loop overwrite.2. A single-end parallel worker error reaches the main thread
The result channel carries
Result<SingleBatchResult>, matching the paired path; the workersends
Err(e)where it printed"Worker error: …"and broke. The main loop keeps afirst_errorand short-circuits on it.drop(result_rx)before the reader join is carriedover from the paired path for the same anti-deadlock reason its comment gives.
3. Both parallel paths join their workers before publishing
thread::scopejoins workers on the way out of the closure — after the commit — so apanicking worker committed a short output and only then re-raised, exiting 101. The joins go
after the reader join, where the reader has already dropped the work senders and every worker
is finished or about to be.
Measured on this patch
cargo testcargo fmt --all -- --checkcargo clippy --all-targets --release -- -D warnings--cores 1and--cores 2Timing, and it does not settle the hot-loop question. Two release builds of this
repository, differing only by this patch, alternating runs over 1M synthetic 100 bp
single-end reads at a 38% adapter rate, gzip output, on a 2-core VPS:
--cores 1(the sink whose type changed)dev@d2ea293--cores 2(ParCompress, unchanged path)dev@d2ea293The spread within each build is several times the gap between them, and the fastest and
the slowest run of the whole
--cores 1set both belong to the patched build. This boxcannot resolve a difference of this size — it can say that nothing catastrophic happened
and no more. I am not claiming the swap is neutral or faster; if that claim matters for
merging, it wants a
scripts/benchmark.shrun on hardware that is not a shared VPS.Three unit tests in
src/fastq.rscover the new teardown path: aSink::Gzover aread-only
Filemust returnErrfromfinish()(the bytes that fail are exactly the onesonly
try_finishwrites), the same for a bufferedSink::Plain, and a positive controlasserting the trailer really is written — magic
1f 8b 08at the head and eight zero bytesof CRC/ISIZE at the tail.
One behaviour change worth naming
On the serial-gzip path the
.gzbytes change. The old teardown reachedGzEncoder::flush, which emits a deflate sync marker before the final block;try_finishdoes not. Measured on the 1M-read output below: 101,067,014 bytes before, 101,067,003
after, identical decompressed md5.
--cores Noutput is byte-identical either way.The
validationjob compares throughgzip -dcand the two md5-stability checks in CI(
sample_trimmed.fq.gzre-run, clump-only determinism) compare a build against itself, sonothing in CI should notice — but a
.gzfrom this build will not md5-match one fromv2.3.0, and anyone holding such a hash downstream would.
What this does not claim
error is propagated rather than discarded; they do not fill a disk. As the issue says, that
needs a size-limited filesystem, and this patch does not add one.
process_single_batchstill writes into aVec, so the path stays practically unreachable— the issue says so and I did not find a way to reach it either.
body, and adding one to a hot loop for this seemed worse than the argument.
scripts/benchmark.sh. That wants the 84M-read Buckberry pair,hyperfine, and theoxyinstance. This is 1M synthetic 100 bp reads at a 38% adapter rateon a 2-core VPS, wall-clock, alternating builds, 5 runs each — enough to catch a regression
of the size a vtable-to-branch swap could cause, not enough to publish.
AI-assisted
This patch was written by an autonomous agent. Every claim above was executed on the
machine that wrote it rather than inferred: the test/fmt/clippy lines are real runs, the
before/after timing compares two release builds of this repository, and the gzip-framing
consequence was read out of
flate2'sgz/write.rs(flush→DeflateEncoder::flush,which
try_finishdoes not call) and then confirmed against the two output files.