feat(io): accept uncompressed SAM and stdin anywhere BAM is accepted - #644
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe PR adds content-based SAM detection and streaming SAM-to-BAM normalization, integrates replayable readers into command and pipeline paths, broadens stdin support, centralizes input validation, moves consensus checks into streaming processing, and adds cross-command input-source coverage. ChangesSAM input support
Estimated code review effort: 5 (Critical) | ~90 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #644 +/- ##
==========================================
+ Coverage 93.57% 93.82% +0.24%
==========================================
Files 175 176 +1
Lines 106821 107536 +715
==========================================
+ Hits 99963 100896 +933
+ Misses 6858 6640 -218 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
0bf718f to
7b98a01
Compare
7b98a01 to
6fcbaa1
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/fgumi-bam-io/src/sam_input.rs`:
- Around line 143-155: Update the batching loop in the reader method containing
self.reader.read_record and self.writer.write_alignment_record to stop as soon
as the writer has produced the first BGZF output bytes, preventing a single read
from buffering an entire 1,024-record batch; retain EOF finalization for
exhausted input. Add a regression test that performs a one-byte read with
long-read or sufficiently large SAM input and verifies it returns promptly
without unbounded buffering.
In `@tests/integration/test_sam_input.rs`:
- Around line 65-74: Update tests/integration/test_sam_input.rs:65-74 so
read_records returns each output header together with its records, normalizing
only path-dependent generated `@PG` data. At
tests/integration/test_sam_input.rs:123-127, assert normalized header and record
parity between BAM and SAM runs. At tests/integration/test_sam_input.rs:151-155,
run the canonical BAM sort and compare the misnamed file’s complete normalized
output, including headers and records, against it.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f14ace6c-8631-4dc6-94b6-6d33c5af280b
📒 Files selected for processing (7)
crates/fgumi-bam-io/src/lib.rscrates/fgumi-bam-io/src/reader.rscrates/fgumi-bam-io/src/sam_input.rscrates/fgumi-sort/src/external.rstests/integration/helpers/bam_generator.rstests/integration/main.rstests/integration/test_sam_input.rs
6fcbaa1 to
4bf4100
Compare
4bf4100 to
66465ad
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
crates/fgumi-sort/src/external.rs (1)
3932-3948: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
advise_sequentialskipped on the default (non-async) sort input path.
advise_sequential(&file)only runs inside theasync_readerbranch here, but the two equivalent call sites incrates/fgumi-bam-io/src/reader.rs(Lines 185, 391) now call it unconditionally as part of this PR. Sort's default synchronous file read misses the kernel readahead hint that the pipeline/BGZF readers get.⚡ Proposed fix
let file = std::fs::File::open(path_ref) .with_context(|| format!("Failed to open input BAM: {}", path_ref.display()))?; + fgumi_bam_io::os_hints::advise_sequential(&file); + if async_reader { - fgumi_bam_io::os_hints::advise_sequential(&file); log::debug!( "async sort reader enabled: spawning fgumi-prefetch thread for {}", path_ref.display() ); Box::new(fgumi_bam_io::prefetch_reader::PrefetchReader::from_file(file)) } else { Box::new(io::BufReader::with_capacity(2 * 1024 * 1024, file)) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/fgumi-sort/src/external.rs` around lines 3932 - 3948, Update the file-opening flow around the async_reader branch to call fgumi_bam_io::os_hints::advise_sequential(&file) unconditionally after opening the input BAM and before selecting PrefetchReader or BufReader. Keep the async-specific debug logging and reader construction unchanged.crates/fgumi-bam-io/src/reader.rs (1)
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftExtract shared open+normalize+replay helpers into
fgumi-bam-ioinstead of re-implementing them per crate. The stdin/file-open-with-optional-prefetch pattern and the tee+BgzfReader+header-parse+replay pattern are each implemented independently at three sites; theadvise_sequentialplacement drift already flagged inexternal.rsis a direct symptom of this duplication.
crates/fgumi-bam-io/src/reader.rs#L369-434: promote this implementation (open +normalize_to_bgzf+read_header_and_replay) to apub fnpair infgumi-bam-iothat bothopen_bgzf_readerandfgumi-sort's pool-integrated reader can call.crates/fgumi-bam-io/src/reader.rs#L160-198: switchopen_bgzf_reader's stdin/file-opening block to reuse the same exported helper instead of its own copy.crates/fgumi-sort/src/external.rs#L3932-3966: replace the inlineopened-building and tee/header-replay logic increate_raw_bam_reader_pool_integratedwith the sharedfgumi-bam-iohelpers, eliminating this drift-prone duplicate.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/fgumi-bam-io/src/reader.rs` at line 1, Extract the shared open/optional-prefetch and normalize-to-BGZF/header-replay logic into two public helpers in fgumi-bam-io, based on the existing implementation near reader.rs lines 369-434. Update open_bgzf_reader to use these helpers for stdin/file input, and replace the duplicated opened-building and tee/header-replay logic in create_raw_bam_reader_pool_integrated with them, preserving advise_sequential placement and existing pool integration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/fgumi-bam-io/src/reader.rs`:
- Line 1: Extract the shared open/optional-prefetch and
normalize-to-BGZF/header-replay logic into two public helpers in fgumi-bam-io,
based on the existing implementation near reader.rs lines 369-434. Update
open_bgzf_reader to use these helpers for stdin/file input, and replace the
duplicated opened-building and tee/header-replay logic in
create_raw_bam_reader_pool_integrated with them, preserving advise_sequential
placement and existing pool integration.
In `@crates/fgumi-sort/src/external.rs`:
- Around line 3932-3948: Update the file-opening flow around the async_reader
branch to call fgumi_bam_io::os_hints::advise_sequential(&file) unconditionally
after opening the input BAM and before selecting PrefetchReader or BufReader.
Keep the async-specific debug logging and reader construction unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5ec2d38a-4b79-4bd4-b37a-777a1e8ab390
📒 Files selected for processing (7)
crates/fgumi-bam-io/src/lib.rscrates/fgumi-bam-io/src/reader.rscrates/fgumi-bam-io/src/sam_input.rscrates/fgumi-sort/src/external.rstests/integration/helpers/bam_generator.rstests/integration/main.rstests/integration/test_sam_input.rs
66465ad to
64e74d4
Compare
Addressed both outside-diff findings from the last review, plus a set of defects found while verifying them. Outside-diff findings
Defects found while verifying
Coverage Added |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/fgumi-bam-io/src/sam_input.rs`:
- Around line 207-220: Update SamToBamStream::read to return Ok(0) immediately
when buf.is_empty(), before checking staged data or calling produce(). Preserve
the existing EOF and normal-read behavior for non-empty buffers.
In `@crates/fgumi-sort/src/external.rs`:
- Around line 3941-3946: Update the stdin branch in the pool-integrated reader
to honor the async_reader option, creating the same PrefetchReader used by the
file-input async path when enabled while retaining the buffered stdin reader
when disabled. Ensure fgumi sort --async-reader - uses the prefetch thread
rather than silently falling back to BufReader.
In `@tests/integration/test_input_source_matrix.rs`:
- Around line 463-494: Strengthen the precondition in
declared_stdin_support_accepts_piped_sam by locating the matching CONTRACTS
entry and asserting its stdin support is Required, rather than only checking
that any entry exists. Keep feature-gated commands skipped, and ensure a stale
#[values(...)] entry fails as a contract-list mismatch instead of being reported
as a piped-SAM rejection.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3f8be432-4c65-40f7-92ae-990a35274fbb
📒 Files selected for processing (22)
crates/fgumi-bam-io/src/lib.rscrates/fgumi-bam-io/src/reader.rscrates/fgumi-bam-io/src/sam_input.rscrates/fgumi-sort/src/external.rscrates/fgumi-sort/src/lib.rscrates/fgumi-sort/src/reader.rscrates/fgumi-sort/src/verify.rssrc/lib/commands/compare/engines/mod.rssrc/lib/commands/compare/engines/molecule_join.rssrc/lib/commands/compare/engines/sort_verify.rssrc/lib/commands/compare/metrics.rssrc/lib/commands/duplex_metrics.rssrc/lib/commands/fastq.rssrc/lib/commands/review.rssrc/lib/commands/simplex_metrics.rssrc/lib/commands/sort.rssrc/lib/commands/zipper.rssrc/lib/validation.rstests/integration/helpers/bam_generator.rstests/integration/main.rstests/integration/test_input_source_matrix.rstests/integration/test_sam_input.rs
64e74d4 to
e4fcc17
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
e4fcc17 to
81daf3a
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/fgumi-bam-io/src/reader.rs (1)
486-498: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
read_header_and_replayparses the BGZF header unbuffered — reintroduces the syscall-per-block issue already fixed elsewhere in this file.
open_bgzf_reader(lines 258–299) was specifically reworked to wrap the normalized stream inBufReader::with_capacity(BGZF_INPUT_BUFFER_SIZE, ...)before handing it to the BGZF frame reader, closing out the prior review comment on this exact pattern.read_header_and_replaybuildsBgzfReader::new(TeeReader::new(reader))directly on itsreaderargument with no such wrap, andcreate_bam_reader_for_pipeline_with_opts(its main caller here) feeds it the raw, unbufferedopen_normalized_with_optsoutput in the default (non-async) case — a plainFile/io::stdin(). The BGZF frame reader issues small block-sized reads directly against that source during header parsing, so every BGZF block in the header pays a syscall instead of being served from a buffer.This isn't confined to this file:
fgumi_sort::open_raw_bam_record_reader_with_header(crates/fgumi-sort/src/reader.rs, lines 78-85) has the identical unbufferedopen_normalized_input→read_header_and_replaypattern, and it now backssort --verifyandcompare's molecule-join/sort-verify engines over stdin/FIFO inputs — exactly the paths this PR is adding streaming support for.RawBamRecordReader::new's internal 256 KiB buffer only kicks in for the replayed remainder, after the header has already been parsed unbuffered.Simplest fix: wrap
readerin aBufReaderinsideread_header_and_replayitself before constructing theTeeReader/BgzfReader, so every caller gets it for free (aBufReader's pre-buffered bytes survive being handed back out viainto_parts()/ChainedReader, so no data is lost on replay).🐛 Proposed fix
pub fn read_header_and_replay( reader: Box<dyn Read + Send>, path: &Path, ) -> Result<(Header, Box<dyn Read + Send>)> { - let tee_reader = TeeReader::new(reader); + let buffered = BufReader::with_capacity(BGZF_INPUT_BUFFER_SIZE, reader); + let tee_reader = TeeReader::new(buffered); let bgzf_reader = BgzfReader::new(tee_reader);Also applies to: 515-530
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/fgumi-bam-io/src/reader.rs` around lines 486 - 498, Update read_header_and_replay to wrap its reader argument in a BufReader with BGZF_INPUT_BUFFER_SIZE before constructing TeeReader and BgzfReader, ensuring header parsing is buffered for every caller while preserving all replayed bytes through the existing into_parts/ChainedReader flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/fgumi-bam-io/src/reader.rs`:
- Around line 486-498: Update read_header_and_replay to wrap its reader argument
in a BufReader with BGZF_INPUT_BUFFER_SIZE before constructing TeeReader and
BgzfReader, ensuring header parsing is buffered for every caller while
preserving all replayed bytes through the existing into_parts/ChainedReader
flow.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 27bf4fd8-d4a1-4e5f-b651-57880c5b6c45
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock,!**/*.lock
📒 Files selected for processing (38)
Cargo.tomlcrates/fgumi-bam-io/src/format.rscrates/fgumi-bam-io/src/lib.rscrates/fgumi-bam-io/src/reader.rscrates/fgumi-bam-io/src/sam_input.rscrates/fgumi-consensus/src/filter.rscrates/fgumi-consensus/src/lib.rscrates/fgumi-simd-fastq/src/reader.rscrates/fgumi-sort/src/external.rscrates/fgumi-sort/src/lib.rscrates/fgumi-sort/src/reader.rscrates/fgumi-sort/src/verify.rssrc/lib/batched_sam_reader.rssrc/lib/commands/common.rssrc/lib/commands/compare/engines/mod.rssrc/lib/commands/compare/engines/molecule_join.rssrc/lib/commands/compare/engines/sort_verify.rssrc/lib/commands/compare/metrics.rssrc/lib/commands/duplex_metrics.rssrc/lib/commands/extract.rssrc/lib/commands/fastq.rssrc/lib/commands/review.rssrc/lib/commands/shared_metrics.rssrc/lib/commands/simplex_metrics.rssrc/lib/commands/sort.rssrc/lib/commands/zipper.rssrc/lib/mod.rssrc/lib/template.rssrc/lib/unified_pipeline/bam.rssrc/lib/validation.rstests/integration/helpers/bam_generator.rstests/integration/helpers/mod.rstests/integration/main.rstests/integration/test_async_reader.rstests/integration/test_compare_bams.rstests/integration/test_input_source_matrix.rstests/integration/test_sam_input.rstests/integration/test_streaming_input.rs
💤 Files with no reviewable changes (3)
- src/lib/mod.rs
- src/lib/batched_sam_reader.rs
- src/lib/commands/compare/engines/mod.rs
8089b5e to
5650d82
Compare
|
Addressed the outside-diff finding from the latest review. It has no thread ID, so it cannot be resolved through the UI. Outside diff range — I did not apply the suggested fix as written. Leaving the Instead the buffer is scoped to the parse: it sits under the Two regression tests, both verified to fail without the change:
Also updated the doc on |
Every fgumi reader was a BGZF reader, so no command accepted uncompressed
SAM: `-i in.sam` failed with `invalid BGZF header`, which says nothing
about what to do. Input format is a property of the data, not of the
command, and it must not depend on which reader a flag happens to select.
Normalize at the boundary instead of teaching every consumer a second
record source. `sam_input::normalize_to_bgzf` sniffs the input's magic
bytes and, for SAM text, parses and re-encodes it on the fly into the
BGZF stream the consumers already expect, so `-i in.sam` and `-i in.bam`
are the same stream by the time any consumer sees them. The transcode
uses compression level NONE -- the bytes are decoded again microseconds
later in the same process.
Detection is by content, never by extension, so a `.bam` holding SAM text
and a `.sam` holding BGZF both read correctly. `zipper` chose its mapped
reader by file extension and so was the one command that could still be
fooled by a misnamed file; it now sniffs content. Only a regular file is
sniffed by path and re-opened -- stdin, FIFOs and process substitution
(`-i <(samtools view -h ...)`) cannot be re-opened, so they are opened
once and the peeked bytes replayed.
Applied at every reader surface: the typed reader, the raw-byte reader,
the pipeline reader, sort's pool-integrated reader, and the raw record
readers behind `sort --verify` and `compare`. The latter two read the
header through a format-aware reader and then re-opened the path with a
bare `File`, which put them back on the BGZF-only path and made them
reject the very files `sort` accepted; both now open once, through
`open_normalized_input`. The pipeline and pool readers parsed their
header by rewinding, which cannot work for a transcoded stream; both now
parse through a tee and replay the consumed bytes, so the input is opened
exactly once and stdin, FIFOs and SAM all take the same path.
stdin is held to the same standard: every command that reads records
streams it, and only `review`, `merge` and `compare` do not -- `review`
does BAI random access, and the other two compare or merge inputs named
against each other, which one stream cannot supply.
Getting there removed three separate double-reads. `fastq`,
`duplex-metrics` and `simplex-metrics` rejected `-i -` at validation with
`File does not exist`, because existence was checked before anything knew
`-` names a stream; `validate_input_exists` exempts stdin for streamable
inputs while leaving references and include lists strictly checked.
`sort --verify` opened its input twice, once for the header and once for
the records, and now takes both from a single open. The metrics commands
pre-scanned for a consensus BAM by opening the input separately; that
guard now runs against the first qualifying record of the pass they
already make, keeping its deliberately looser predicate (it must not skip
unmapped reads, since consensus BAMs are unaligned).
`extract` streams a single input FASTQ. It opened each input three times
-- a quality-encoding sample, a BGZF probe, and the real pass -- so stdin
was drained before the records were read, and `-i -` silently produced an
empty BAM while exiting zero. The sample now reads through a tee and
replays what it consumed, so the stream is read exactly once. `-` with
more than one `--input` is rejected outright, because one stdin cannot
supply two FASTQs.
`review` remains BAM-only: it requires BAI indexes and does random access
against them, which is inherently BGZF-only. Partial support there would
reintroduce the per-path inconsistency this change removes.
Input that is neither BGZF nor SAM is rejected rather than accepted as
headerless SAM. A SAM header parse consumes only `@` lines, so arbitrary
text would otherwise yield an empty header and transcode into a
valid-looking BAM carrying no reference sequences at all.
Errors name the stage that actually failed. The transcode is lazy, so
records were being pulled while the consumer was still reading the
header, reporting a malformed record on line 4 as a header failure; the
header is now flushed into its own BGZF block so the header read
completes before any record is touched.
Empty input is reported as empty rather than as a malformed header.
One classifier answers "what is this input" everywhere, in its own
`fgumi-bam-io::format` module. BGZF, plain gzip
and text were previously distinguished by 2-, 4- and 18-byte checks in
three places, so a plain-gzipped SAM was called BGZF by one and SAM text
by another: `sort` failed with `Failed to read header` and `zipper` with
`invalid flags`, the exact confusion the content sniff exists to prevent.
`classify_input` now serves all of them, and gzip-but-not-BGZF is named
as such with the `bgzip` fix in the message.
`zipper` no longer keeps a second, text-mode orchestration at all — not
even a format branch: the mapped input goes through the shared normalizing
reader, which handles BAM and SAM alike, so the command never learns which
arrived. Its SAM arm
decoded to `RecordBuf` because "text-mode SAM has no raw-byte path"; SAM
is now transcoded at the boundary like everywhere else, which puts it on
the raw-byte path and turns out to be *faster* — 2.9s to 2.1s on a
2.4M-record zip, because the transcode's encode replaces both the
`RecordBuf` decode and the re-encode the merge paid, and framing at
`CompressionLevel::NONE` is nearly free. `MappedReader` collapses from an
enum to a single raw reader, and zipper sheds ~170 lines.
`fgumi zipper --bwa-chunk-size` is marked as having no effect. It fed an
adaptive buffer whose batch tracking was driven only by an API the command
never called, so the value was already inert before this change; the buffer
itself is gone now that the normalizing reader does the buffering, which
also drops a full-stream copy of the SAM text and 128 MiB of reservation.
The flag is still parsed so existing command lines keep working, and hidden
so new ones do not adopt it. (`fgumi fastq` has its own `--bwa-chunk-size`,
which is live and untouched.) `batched_sam_reader.rs` had no callers left
and is deleted, as is `run_bam_pipeline_with_header`, which had none anywhere.
`run_bam_pipeline_with_grouper` moves behind a `test-utils` feature. Every
production command drives the pipeline through the `*_from_reader` family;
this path-taking wrapper's only callers are integration tests, and leaving
a generic with five type parameters in the default public API advertises it
as a supported entry point that every future I/O change has to be kept in
step with -- a cost this change already paid once. A self dev-dependency
turns the feature on for the test targets, so `cargo test` needs no extra
flag and `cargo build` never sees it. (`.cargo/config.toml` already had a
`cargo t = "test --features test-utils"` alias naming a feature the crate
did not define; it resolves now.)
`TemplateIterator` no longer copies both read names to owned `Vec`s to test
whether two records belong to the same template — two heap allocations per
record, on every zipper run, to answer a `memcmp`. It compares the name bytes
in place.
`fgumi fastq -i -` no longer fails when `--output` already exists. The
guard that refuses to truncate the input BAM canonicalises both paths
whenever the output exists, and `canonicalize("-")` is a `NotFound` error,
so accepting stdin turned the common case of overwriting a prior run's
output into an unexplained IO failure. The guard is now
`reject_output_clobbering_input`, which exempts stdin -- it names no file
and so can clobber nothing -- and is unit-tested over the stdin, distinct
-path and same-path cases.
`SharedBuffer::is_empty` no longer takes a mutex per record. The SAM
transcode asks the sink whether the encoder has emitted a block once for
every record it writes; the answer is now an atomic load against a length
that `write` and `swap_into` maintain inside the critical sections they
already hold. At a billion records the lock alone was tens of seconds
spent asking a question the encoder already knew.
The SAM/BAM contract matrix now compares results, not just exit codes.
Both runs wrote to one `{command}.out`, so the SAM run overwrote the BAM
output it was supposed to be checked against, and a transcode that
dropped or reordered records would still have passed. Each run gets its
own output directory and the BAM run is the oracle. Outputs are compared
as rendered SAM text, which normalises the two things that legitimately
differ -- the `@PG` `CL` line, and the *width* of integer aux values,
since SAM's `i` carries no width and every BAM writer picks its own
(fgumi narrows to the smallest signed type, htslib prefers unsigned,
noodles does not narrow). Refs #655.
`sort --verify` parity now covers violations as well as records checked:
a SAM-specific record-boundary bug could preserve the record total while
corrupting order detection, and a count-only comparison would pass. The
consensus-rejection guard gains its duplex cases (`aD`+`bD`, with and
without `cD`), which had no direct coverage on either metrics command.
`test_input_source_matrix.rs` pins the whole contract: every command the
binary advertises must declare whether it accepts SAM and stdin, and the
declarations are checked against `fgumi --help`, so a new command fails
the suite until it states its stance rather than silently inheriting
whichever reader it happened to pick.
Refs #642
5650d82 to
3d49b1b
Compare
…r check (#668) The topological-order gate in the publish job walks every entry of `cargo metadata`'s `dependencies` array, which includes dev-dependencies. The root crate gained a self dev-dependency in #644 -- `fgumi = { path = ".", features = ["test-utils"] }`, the only way to enable `test-utils` for the integration test targets -- so the gate reported "fgumi depends on fgumi, which must be listed earlier" and aborted the v0.5.0 release before any upload. Cargo strips path-only dev-dependencies (no version requirement) from the manifest it uploads, so they impose no publish-ordering constraint at all. Skip them in the gate. Dev-dependencies that do carry a version, such as `fgumi-raw-bam = { workspace = true, ... }`, survive packaging and are still required to appear earlier in the list.
Closes #642.
Every fgumi reader was a BGZF reader, so no command accepted uncompressed SAM.
-i in.samfailed withinvalid BGZF header, which tells the user nothing about what to do, and it failed that way regardless of--threads— verified against the built binary onmainbefore writing any code:Input format is a property of the data, not of the command, and it must not depend on which reader a flag happens to select. The same argument applies to where the data comes from, so this PR holds stdin to the same standard: every command that streams records accepts
-.Approach
Normalize at the boundary rather than teaching every consumer a second record source.
fgumi-bam-io's newsam_inputmodule sniffs the input's leading bytes and, for SAM text, parses and re-encodes it on the fly into the BGZF stream the consumers already expect — so-i in.samand-i in.bamare the same stream by the time any consumer sees them. Nothing downstream of the reader changed.The alternative — a second, text record source threaded through the single-threaded fast paths, the pipelines and the sort engine — is what produces per-command and per-flag divergence in the first place. One conversion point cannot drift.
The transcode uses
CompressionLevel::NONE: the bytes are decoded again microseconds later in the same process, so there is nothing to gain from deflating them.Detection is by content, never by extension, so a
.bamholding SAM text and a.samholding BGZF both read correctly. Only a regular file is sniffed by path and re-opened; stdin, FIFOs and process substitution cannot be re-opened, so they are opened once and the peeked bytes replayed.One classifier, not three
BGZF, plain gzip and text were previously distinguished by 2-, 4- and 18-byte checks in three different places, which disagreed with each other. A plain-gzipped SAM was called BGZF by one and SAM text by another:
sortfailed withFailed to read header,zipperwithinvalid flags— the exact confusion the content sniff exists to prevent.fgumi-bam-io::format::classify_input(Bgzf/Gzip/Text/Empty) now answers the question everywhere. Gzip-but-not-BGZF is named as such, with thebgzipfix in the message, instead of failing as a corrupt BAM.fgumi extractkeeps gzip as a supported input, since FASTQ legitimately arrives that way.Input that is neither BGZF nor SAM is rejected rather than accepted as headerless SAM — a SAM header parse consumes only
@lines, so arbitrary text would otherwise yield an empty header and transcode into a valid-looking BAM carrying no reference sequences at all. Empty input is reported as empty rather than as a malformed header.Reader surfaces
create_bam_reader_with_optscreate_raw_bam_reader_with_optscreate_bam_reader_for_pipeline_with_opts--threads Npipelinescreate_raw_bam_reader_pool_integratedopen_raw_bam_record_reader_with_headersort --verify,comparecreate_bam_reader_for_pipelineunified_pipelineentry pointszipper's mapped inputThe pipeline and pool readers parsed their header by rewinding, which cannot work for a transcoded stream. Both now parse through a
TeeReaderand replay the consumed bytes — the same trick the stdin branch already used. Besides enabling SAM, that means the input is opened exactly once: the file branches previously opened, seeked to 0 and re-read, which never worked for FIFOs (fgumi -i <(samtools view …)).sort --verifyandcompareread their header through a format-aware reader and then re-opened the path with a bareFile, which put them back on the BGZF-only path and made them reject the very filessortaccepted.zipperchose its mapped reader by file extension and so was the one command a misnamed file could still fool. Both are fixed; without them the PR's central claim was simply false.stdin
Every command that reads records streams stdin. Three deliberately do not:
reviewquery()random accessmerge-blist; one stream cannot supply Ncompareextractstreams when there is exactly one--input;-with two or more is rejected outright.Getting there removed three separate double-reads:
fastq,duplex-metricsandsimplex-metricsrejected-i -at validation withFile does not exist, because existence was checked before anything knew-names a stream.validate_input_existsnow exempts stdin for streamable inputs while leaving references and include lists strictly checked.sort --verifyopened its input twice, once for the header and once for the records; it now takes both from a single open.fgumi fastq -i -failed outright when--outputalready existed. The guard that refuses to truncate the input BAM canonicalises both paths whenever the output exists, andcanonicalize("-")is aNotFounderror, so accepting stdin turned the common case of overwriting a prior run's output into an unexplained IO failure.reject_output_clobbering_inputnow exempts stdin — it names no file, so it can clobber nothing.extractopened each input three times — a quality-encoding sample, a BGZF probe, and the real pass — so stdin was drained before the records were read and-i -silently produced an empty BAM while exiting zero. The sample now reads through a tee and replays what it consumed.zipperno longer has a text modezipperkept a second, text-mode orchestration because "text-mode SAM has no raw-byte path". With SAM transcoded at the boundary it does have one, so the mapped input goes through the shared normalizing reader and the command never learns which format arrived — there is no format branch left at all.That turned out to be faster, which refuted the reason it had been deferred. On a 2.4M-record zip:
Output is byte-identical across all five input shapes. The transcode's encode replaces both the
RecordBufdecode and the re-encode the merge used to pay, and framing atCompressionLevel::NONEis nearly free.MappedReadercollapsed from an enum to a raw reader and then disappeared;record_bufs_to_templates,file_is_bgzfandpeek_bgzfbecame dead. zipper sheds ~255 lines.SharedBuffer::is_emptyno longer takes a mutex per record either. The transcode asks the sink whether the encoder has emitted a block once for every record it writes; that is now an atomic load against a lengthwriteandswap_intomaintain inside the critical sections they already hold.Separately,
TemplateIteratorno longer copies both read names into ownedVecs to decide whether two records belong to the same template — two heap allocations per record, on every zipper run, to answer amemcmp. It compares the name bytes in place (~4.8M allocations avoided on the 2.4M-record run). The remaining per-record allocation there — oneRawRecordbuffer per record — needs a pool with a cross-thread return path and is tracked separately in #654.Deletions and API surface
src/lib/batched_sam_reader.rs(662 lines) — deleted. It fed an adaptive buffer whose batch tracking was driven only byrecord_parsed(), which has zero production callers, sozipper --bwa-chunk-sizewas already inert before this PR. The buffer also cost a full-stream copy of the SAM text and 128 MiB of reservation, for buffering the normalizing reader already does. The flag is still parsed so existing command lines keep working, and hidden so new ones do not adopt it; removing it is a CLI break and belongs in its own change. (fgumi fastqhas its own--bwa-chunk-size, which is live and untouched.)run_bam_pipeline_with_header(99 lines) — deleted; zero callers anywhere, including tests.run_bam_pipeline_with_grouper— moved behind atest-utilsfeature. Production commands all drive the pipeline through the*_from_readerfamily; this path-taking wrapper's only callers are integration tests, and leaving a generic with five type parameters in the default public API advertises it as a supported entry point that every future I/O change has to be kept in step with — a cost this PR already paid once. A self dev-dependency turns the feature on for the test targets, socargo testneeds no extra flag andcargo buildnever sees it. (.cargo/config.tomlalready had acargo t = "test --features test-utils"alias naming a feature the crate did not define; it works now.)reviewstays BAM-only, deliberatelyfgumi reviewrequires BAI indexes on--consensus-bamand--grouped-bamand doesquery()random access against them. Indexed random access is inherently BGZF-only — a plain SAM file cannot carry a BAI — soreviewis BAM-only by construction, not by omission.It also has two sequential opens that I could have converted. I did not: making half of
review's reads accept SAM while the indexed half still requires BGZF would reintroduce exactly the per-path inconsistency this PR removes. Ifreviewshould accept SAM it needs a design decision (e.g. a non-indexed full-scan mode), not a reader swap.Errors name the stage that actually failed
The transcode is lazy, so records were being pulled while the consumer was still reading the header — a malformed record on line 4 was reported as a header failure. The header is now flushed into its own BGZF block, so the header read completes before any record is touched.
Tests
Written test-first. The three reader-factory tests were watched failing with
invalid BGZF headerbefore the module existed, andsortfailed the integration table exactly as predicted until its pool reader was converted.sam_inputunit tests — transcoded stream is a readable BAM; byte-at-a-time reads match bulk reads (the staging buffer refills acrossreadcalls); header-only SAM yields a valid empty BAM; BGZF passes through byte-identical; empty input is rejected.formatunit tests — a 7-case table over BGZF / plain gzip / SAM / empty / truncated gzip magic / short text / gzip-with-a-non-BC-extra.readerunit tests — the public factories accept uncompressed SAM.tests/integration/test_sam_input.rs— for each command, the same data as.bamand as.sammust both succeed and produce the same records. Cases run with and without--threads, so no future change can make format support depend on thread count. Plus misnamed extensions in both directions, piped input, a FIFO regression case, the rejection cases, and record-vs-header error attribution.tests/integration/test_input_source_matrix.rs— aCONTRACTStable pinning the whole contract: every command the binary advertises must declare whether it accepts SAM and whether it accepts stdin, and the declarations are checked againstfgumi --help. A new command fails the suite until it states its stance rather than silently inheriting whichever reader it happened to pick. The SAM axis compares results, not exit codes: each run writes to its own directory (both previously wrote to one{command}.out, so the SAM run overwrote the BAM output it was meant to be checked against) and the BAM run is the oracle.sort --verifyparity compares violations as well as records checked — a SAM-specific record-boundary bug could preserve the record total while corrupting order detection, and a count-only comparison would pass — with a case whose input is deliberately out of queryname order.One stated limit: integer aux width
Comparing the two runs as bytes is not achievable and the matrix does not try. SAM's
itype carries no width, so acD:i:10read from SAM text is re-encoded asInt32while the same tag in the original BAM may have been stored asInt8. Every writer picks its own rule — fgumi narrows to the smallest signed type, htslib prefers unsigned, noodles does not narrow — so requiring identical aux encoding across a SAM round-trip would require something no BAM writer guarantees. The matrix therefore renders both outputs to SAM text and compares the values, which is the property that actually has to hold. Whether fgumi should narrow on the SAM path to match is tracked in #655.cargo ci-test6000 passed / 27 skipped;cargo ci-fmt,cargo ci-lint, andcargo check --workspacewith both--no-default-featuresand--all-featuresall clean.What changed since the first review
The first round of review (CodeRabbit plus an adversarial pass) found that the PR's headline claim did not hold, and the fixes cascaded well past the original scope. Summarising, since this description was first written:
sort --verify,compareandzipperdid not actually accept SAM, despite being listed as covered. Fixed; the "Reader surfaces" table above is now the real list.extract's tee-and-replay.sort/group/fastqneeded per-command fixtures and was deferred to Every command that accepts BAM must accept SAM #642. It is done:CONTRACTSis exhaustive and self-enforcing againstfgumi --help.zipperwas rearchitected rather than given a format branch, and the benchmark above refuted the deferral.fastqstdin/clobber regression above, replaced the matrix's exit-code check with an output comparison, added thesort --verifyviolation parity and the duplex cases for the consensus guard, and removed the per-record mutex from the transcode.Note on
feat-runallThat line has its own SAM support via the typed-step chain (
InputSource::open_with_opts, suffix-then-magic detection), with #547 and #549 closing its last two gaps. This PR does not touch it — it targetsmain, which has no chain at all and is where the format gate is currently total. The two lines will need reconciling; the invariant should hold on whichever survives.Summary by CodeRabbit
New Features
sort --verifynow works correctly with streamed, non-seekable stdin inputs.extract,zipper, and related pipelines.Bug Fixes
Tests
sort --verifyresult consistency.