Skip to content

perf(sort): implement N+2 worker-pool model for parallel sort I/O - #237

Closed
nh13 wants to merge 1 commit into
mainfrom
nh/sort-investigation
Closed

nh13 wants to merge 1 commit into
mainfrom
nh/sort-investigation

Conversation

@nh13

@nh13 nh13 commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

Replaces ad-hoc thread spawning in fgumi sort with a structured N+2 model: N pool workers + 1 I/O writer thread + 1 main thread. At --threads 4, this reduces the thread count from ~30 to exactly 6 and cuts wall time by 35–40% vs the previous implementation.

Worker pool (src/lib/sort/worker_pool.rs)

  • ArrayQueue everywhere: lock-free bounded queues replace crossbeam channels for all inter-step queues, eliminating blocking push/pop in the worker loop
  • generic_worker_loop pattern: held-item advancement → backpressure priorities → exclusive step ownership → adaptive backoff (10 µs min, yield_now + jitter, up to 1 ms)
  • Phase 1: Worker 0 owns ReadInputBlocks exclusively; all workers share DecompressInput and Compress
  • Phase 2: each worker owns its own disjoint chunk files for ReadChunkBlocks (no exclusivity needed); workers share DecompressChunks and Compress
  • park()/unpark(): workers call unpark() after pushing to main-thread-facing queues, eliminating polling delays between the main thread and the pool
  • Workers stay alive until SHUTDOWN (not phase completion) to prevent premature-exit deadlocks during phase transitions

Raw sorter (src/lib/sort/raw.rs)

  • pool_decompress field and use_sequential_sort() helper gate worker-pool I/O; when enabled, rayon par_sort is replaced with single-threaded sort to preserve the N+2 budget
  • Bug fix: merge_chunks_with_index (coordinate sort with --write-index) was passing pool_decompress=true to build_chunk_sources but then calling next_record(…, None), which panics for PoolDisk sources. Fixed to always use pool_decompress=false in that path until the indexing path gets a pooled writer (see TODO comment)
  • Removes dead max_memory_total field (superseded by MemoryLimit::Auto from feat(sort): add --max-memory=auto with system memory detection #236); --max-memory=auto continues to work as intended

Staging buffer (src/lib/sort/bgzf_io.rs)

  • Removes dead is_spill field from CompressJob and StagingBuffer; the worker pool does not distinguish spill vs output jobs

Performance (coordinate sort, 60 M records, MacBook Pro M-series)

Threads samtools fgumi (this PR) speedup
t1 69 s 74 s −7%
t2 55 s 46 s +16%
t4 37 s 26 s +30%
t8 26 s 15 s +42%

fgumi is slower than samtools at t1 (pool overhead dominates; single-thread fast path is a follow-on). From t2 onward fgumi leads and the gap widens with thread count.

Test plan

  • All 2318 existing tests pass (cargo ci-test)
  • Format and lint clean (cargo ci-fmt && cargo ci-lint)
  • fgumi sort --verify passes for all four sort orders at t1/t2/t4/t8
  • Coordinate sort with --write-index no longer panics (regression for the merge_chunks_with_index fix)
  • Thread count at t4: confirm exactly 6 threads during sort (ps -M)

Follow-on work

  • Single-thread fast path: at --threads 1, skip the pool entirely (matches samtools t1 behavior; tracked as a separate issue)
  • Memory overuse: io_writer_loop BTreeMap reorder buffer is unbounded and can hold many in-flight compressed blocks; mimalloc arena retention also inflates RSS. Will file a separate issue.
  • Pooled indexing writer: merge_chunks_with_index currently falls back to non-pooled I/O; replacing with PooledIndexingBamWriter would extend the N+2 benefit to --write-index paths.

@nh13
nh13 temporarily deployed to github-actions April 6, 2026 18:57 — with GitHub Actions Inactive
@codecov

codecov Bot commented Apr 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.92248% with 247 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.99%. Comparing base (cbc40e9) to head (45653c6).

Files with missing lines Patch % Lines
src/lib/sort/raw.rs 86.06% 108 Missing ⚠️
src/lib/sort/worker_pool.rs 88.61% 107 Missing ⚠️
src/lib/sort/read_ahead.rs 83.65% 17 Missing ⚠️
src/lib/sort/bgzf_io.rs 93.86% 10 Missing ⚠️
src/lib/sort/pooled_chunk_writer.rs 97.39% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #237      +/-   ##
==========================================
+ Coverage   88.97%   88.99%   +0.02%     
==========================================
  Files         114      119       +5     
  Lines       55481    57682    +2201     
==========================================
+ Hits        49366    51336    +1970     
- Misses       6115     6346     +231     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai

coderabbitai Bot commented Apr 6, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a phase-aware, fixed-size SortWorkerPool and related modules to pipeline BGZF I/O and parallel compression/decompression. New items include PooledInputStream, RecordSource, StagingBuffer, io_writer_loop, PooledBamWriter, PooledChunkWriter, and worker-pool types (CompressJob/Result, BufferPool, Tagged blocks, phase constants). Raw sorter and read-ahead logic are updated to integrate the pool (new merge consumer, timing, and spill plumbing). Tests and integration scenarios for pooled I/O, writers, and end-to-end sorting were added. A .gitignore entry for /tmp/ was included.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'perf(sort): implement N+2 worker-pool model for parallel sort I/O' directly and precisely summarizes the main architectural change—replacing thread spawning with a structured worker pool model.
Description check ✅ Passed The description comprehensively covers the work: new worker pool, raw sorter changes, staging buffer cleanup, performance metrics, and test plan.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nh/sort-investigation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (1)
tests/integration/test_sort_correctness.rs (1)

61-80: Template-coordinate cases never populate the requested CB tag.

The fixture writes RG and MI, but no CB:Z: tag, while the template-coordinate tests pass --cell-tag CB. That means these assertions only cover the missing-cell-tag fallback, not the actual cell-aware sort key. Add at least two different CB values if you want this suite to exercise the --cell-tag path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_sort_correctness.rs` around lines 61 - 80, The SAM
fixture never includes the CB tag so tests that pass --cell-tag CB only hit the
missing-cell-tag fallback; update the writeln! calls that currently add
"RG:Z:rg1\tMI:Z:umi_{i}" (for paired records) and "RG:Z:rg1" (for unmapped
records) to also emit a CB:Z:... tag (e.g., "CB:Z:cellA" or "CB:Z:cellB"),
ensuring both R1 and R2 lines and the unmapped pairs include CB, and vary the CB
value across records (at least two distinct CB values) so the test exercises the
--cell-tag path and cell-aware sort key logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/sort.rs`:
- Around line 263-266: The x_pool_decompress flag uses clap's default SetTrue
action and default_value_t = true which prevents passing explicit false; change
its clap configuration for the x_pool_decompress field to use action =
clap::ArgAction::Set and value_parser = parse_bool (matching other toggles) so
the CLI can accept --x-pool-decompress=false while keeping the default true
behavior; update the attribute on the x_pool_decompress field to follow the
established pattern used elsewhere (use ArgAction::Set and parse_bool).

In `@src/lib/bam_io.rs`:
- Around line 777-795: Open the input once and reuse that same handle: replace
the two File::open calls with a single File::open(path_ref) into a mutable
variable, use that handle to construct the BgzfReader and call
noodles::bam::io::Reader::from(...) and read_header(), then seek the same handle
back to the start (e.g. using Seek::seek(SeekFrom::Start(0))) before wrapping it
in an io::BufReader and passing it to pool.set_input_file; keep
pool.set_phase(phase::PHASE1) as-is. Make sure to reference the existing symbols
File::open, BgzfReader::new, noodles::bam::io::Reader::read_header (or
read_header()), Seek::seek/SeekFrom::Start, pool.set_input_file, and
pool.set_phase in the change.

In `@src/lib/sort/bgzf_io.rs`:
- Around line 38-68: The staging API currently allows enqueuing oversized BGZF
jobs because buf() exposes direct appends and flush()/flush_if_full() forward
the entire Vec; fix by implementing a chunked-write path (similar to
InlineBgzfCompressor::write_all) so any single append larger than
BGZF_MAX_BLOCK_SIZE is split into BGZF_MAX_BLOCK_SIZE-sized CompressJob chunks
before submission, update buf()/or add a write_chunked(&mut self, data: &[u8])
helper that slices large inputs and pushes/submit multiple CompressJob entries,
ensure flush() is a no-op when the buffer is empty (i.e. do nothing if
self.buf.is_empty()) and keep buffer pool reuse and serial assignment semantics
(use self.next_serial for each chunk submitted and clone result_tx per
CompressJob).
- Around line 89-119: The current writer finalizes the BGZF stream even if there
are missing serials; after the recv() loop and the in-order draining loop, check
whether reorder_buf is empty and whether any expected serials remain missing
(i.e., next_expected did not advance to cover all produced serials); if
reorder_buf is non-empty or missing serials are detected, return an error (e.g.,
io::Error::new(io::ErrorKind::UnexpectedEof, "...")) instead of writing BGZF_EOF
and flushing; update the code paths around result_rx, reorder_buf,
next_expected, writer and BGZF_EOF to fail early on these conditions so
truncated streams are not silently emitted.

In `@src/lib/sort/pooled_chunk_writer.rs`:
- Around line 122-142: SpillWriteHandle currently allows silently detaching the
writer thread if dropped, so mark the struct with #[must_use] and implement Drop
for SpillWriteHandle to eagerly join the inner io_handle (the
JoinHandle<Result<()>>) if present: on drop call join() and handle errors by
logging any panic or write error (do not panic from Drop), e.g., convert
join/map_err/Result into a logged message; keep the existing public wait(self)
-> Result<()> method unchanged but ensure Drop uses the same join logic against
io_handle to avoid losing errors.

In `@src/lib/sort/raw.rs`:
- Around line 1398-1415: The pooled worker pool is being constructed with
self.temp_compression so PooledBamWriter jobs always use temp compression and
ignore output_compression(); change the design so compression for temporary BGZF
work and final BAM output are distinct: either add a separate field (e.g.,
temp_compression and output_compression) on the struct used to build
SortWorkerPool and use temp_compression for pool construction while passing
output_compression into PooledBamWriter, or include the desired compression
level in the job payload submitted to the pool (update the job type used by
SortWorkerPool/PooledBamWriter to carry compression), and ensure calls that
currently reference output_compression() (and the indexing writer path)
propagate that output compression into the writer creation path rather than
relying on self.temp_compression.
- Around line 895-928: poll_decompressed_blocks currently fully drains
self.decompressed_chunks into per_source.pending which removes the intended
backpressure; change the drain to respect a hard cap (e.g. num_workers * 8 or a
configurable MAX_PENDING_TOTAL) so we never transfer more than that many blocks
into per-source buffers at once. Implement this by tracking a running total of
buffered pending blocks while popping from self.decompressed_chunks (use the
existing decompressed_chunks.pop()), and when the cap is reached stop draining
and push the last-read block back onto decompressed_chunks (or stop before
popping it) instead of inserting into per_source.pending; keep the existing
checks on per_source.current_buf/next_serial and the EOF/unpark logic
(references: poll_decompressed_blocks, self.decompressed_chunks.pop,
per_source.pending.insert, per_source.current_buf/current_pos/next_serial,
all_chunks_eof, std::thread::park).

In `@src/lib/sort/worker_pool.rs`:
- Around line 1428-1446: The shutdown logic in fn shutdown(self) is only
executed when Arc::try_unwrap succeeds; to ensure workers are always signaled
and joined, implement Drop for the WorkerPool (or introduce a guard owned by the
pool) that performs the same teardown: call self.stats.log_summary() and
self.pipeline_stats.log_summary(), store phase::SHUTDOWN into self.shared.phase
(Ordering::Release), lock each sender in self.shared.chunk_file_senders and set
them to None (handling poisoned mutexes gracefully), and join all handles in
self.workers (or detach only after join) so no worker threads are left running
on early returns; reuse the same symbols (shutdown, shared.phase,
shared.chunk_file_senders, stats, pipeline_stats, workers) so behavior matches
the explicit shutdown path.
- Around line 1085-1090: The code currently treats any Err from
read_raw_blocks(...) as EOF by setting shared.input_eof and returning
StepResult::InputEmpty; instead capture and store the first read error in shared
state (e.g., a new shared.input_error / atomic/error-cell) rather than
collapsing it into EOF, and return a distinct error/failed StepResult so callers
(PooledInputStream / MainThreadChunkConsumer) can observe and surface the
underlying I/O error; apply the same change to the other read_raw_blocks() error
path around lines 1202-1208 so corrupt/truncated inputs are propagated rather
than silently treated as EOF.
- Around line 1154-1174: The terminal flag (shared.decompressed_input_done /
similar flags at the other sites) is being set while a thread may still be
holding the last block in worker.held_decompressed_input or while the downstream
queue still contains items; modify the EOF condition to also ensure
worker.held_decompressed_input is None (or empty) and
shared.decompressed_input.is_empty() (and raw_input_blocks.is_empty()) before
storing the done flag and unparking the main thread. Update the same pattern at
the other occurrences (around the input_blocks_decompressed / input_read_serial
checks and the all_chunks_eof logic) so we only mark decompressed/finished when
the held buffer and queues are drained, not just when counts/eof are reached.

---

Nitpick comments:
In `@tests/integration/test_sort_correctness.rs`:
- Around line 61-80: The SAM fixture never includes the CB tag so tests that
pass --cell-tag CB only hit the missing-cell-tag fallback; update the writeln!
calls that currently add "RG:Z:rg1\tMI:Z:umi_{i}" (for paired records) and
"RG:Z:rg1" (for unmapped records) to also emit a CB:Z:... tag (e.g.,
"CB:Z:cellA" or "CB:Z:cellB"), ensuring both R1 and R2 lines and the unmapped
pairs include CB, and vary the CB value across records (at least two distinct CB
values) so the test exercises the --cell-tag path and cell-aware sort key logic.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4053558e-e81f-4bbc-8399-4ac35d3739ef

📥 Commits

Reviewing files that changed from the base of the PR and between cbc40e9 and c6c8a1a.

📒 Files selected for processing (14)
  • .gitignore
  • crates/fgumi-bgzf/src/lib.rs
  • crates/fgumi-bgzf/src/writer.rs
  • src/commands/sort.rs
  • src/lib/bam_io.rs
  • src/lib/sort/bgzf_io.rs
  • src/lib/sort/mod.rs
  • src/lib/sort/pooled_bam_writer.rs
  • src/lib/sort/pooled_chunk_writer.rs
  • src/lib/sort/raw.rs
  • src/lib/sort/read_ahead.rs
  • src/lib/sort/worker_pool.rs
  • tests/integration/main.rs
  • tests/integration/test_sort_correctness.rs

Comment thread src/commands/sort.rs Outdated
Comment thread src/lib/bam_io.rs Outdated
Comment thread src/lib/sort/bgzf_io.rs
Comment thread src/lib/sort/bgzf_io.rs
Comment thread src/lib/sort/pooled_chunk_writer.rs
Comment thread src/lib/sort/raw.rs
Comment thread src/lib/sort/raw.rs
Comment thread src/lib/sort/worker_pool.rs
Comment thread src/lib/sort/worker_pool.rs Outdated
Comment thread src/lib/sort/worker_pool.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
src/lib/sort/worker_pool.rs (3)

1150-1151: Panic on decompression failure is aggressive but documented.

expect() will crash the worker if input is corrupted. Consider propagating the error instead of panicking, especially since read errors are already being silently swallowed upstream.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 1150 - 1151, The code currently
uses expect on decompress_block (let data = decompress_block(&block, &mut
worker.decompressor).expect(...)) which panics on corrupt input; change this to
propagate the error instead: have the containing function (the worker loop
function that calls decompress_block) return a Result, replace expect with the ?
operator or a match that converts the decompression error into your function's
error type (or a custom enum), and update callers to handle the Result rather
than silently swallowing or allowing a panic; reference decompress_block,
worker.decompressor and the worker loop function to locate and update the
signature and call sites so errors are handled and surfaced appropriately.

1262-1263: Same panic-on-decompress issue for chunk blocks.

Line 1263 uses expect() for decompression. Consistent with line 1150-1151, but both should handle errors gracefully.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 1262 - 1263, The BGZF decompression
call using decompress_block(&tagged.block, &mut worker.decompressor).expect(...)
must not panic; replace the expect with proper error handling: call
decompress_block and match or use ? to propagate a Result from the surrounding
function, or convert the error into the worker error-handling path (e.g., log
via worker.logger/worker.error, mark the job/worker failed, and return an Err).
Update the function that contains this code to return a Result if needed and
ensure symbols referenced (decompress_block, tagged.block, worker.decompressor)
propagate or handle decompression errors consistently with the earlier handling
used around lines 1150-1151.

654-665: Jitter calculation uses low-entropy seed.

subsec_nanos() as a jitter seed provides weak randomness—multiple threads calling simultaneously get similar values. Acceptable for backoff, but consider thread ID mixing for better distribution.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 654 - 665, The jitter seed
currently uses SystemTime::now().duration_since(...).subsec_nanos() which yields
low entropy across concurrent threads; modify the jitter_seed computation in
this block (symbols: jitter_seed, SystemTime::now, subsec_nanos, jitter_offset,
actual_us, MIN_BACKOFF_US) to mix in the current thread identifier before
deriving the u64 seed (e.g., hash std::thread::current().id() together with
subsec_nanos using a Hasher or use a per-thread RNG) so simultaneous threads
produce more varied jitter values while keeping the subsequent jitter_offset,
actual_us, and sleep logic unchanged.
src/lib/sort/bgzf_io.rs (1)

50-60: flush() submits empty buffers wastefully.

When buf is empty, this still allocates a serial and submits a zero-byte compress job. Add an early return.

Proposed fix
     /// Flush the staging buffer: swap it with a recycled buffer and submit for compression.
     pub(crate) fn flush(&mut self) {
+        if self.buf.is_empty() {
+            return;
+        }
         let data = std::mem::replace(&mut self.buf, self.pool.buffer_pool.checkout());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/bgzf_io.rs` around lines 50 - 60, The flush() method should
early-return when the current buffer is empty to avoid creating a serial and
submitting a zero-byte CompressJob: add a guard at the start of flush() that
checks self.buf.is_empty() and returns immediately if true; otherwise keep the
existing behavior (replace self.buf with pool.buffer_pool.checkout(), ensure
capacity, increment self.next_serial, and call
self.pool.submit_compress(CompressJob { data, serial, result_tx:
self.result_tx.clone() })). Reference symbols: flush(), self.buf,
self.next_serial, self.pool.submit_compress, CompressJob, result_tx.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/sort/read_ahead.rs`:
- Around line 382-419: The EOF race happens because input_blocks_decompressed is
incremented before a worker successfully pushes into decompressed_input (blocks
can be placed into held_decompressed_input), so decompressed_input_done can
become true while held items remain; update the logic so decompressed_input_done
is only set when there are no held items across workers or ensure
held_decompressed_input is drained into decompressed_input before marking done.
Concretely: change the producer-side flow around input_blocks_decompressed,
held_decompressed_input and the code that sets decompressed_input_done so it
checks/flushes held_decompressed_input (or decrements the counter only after
successful push), and adjust is_eof/next_block to consider any
held_decompressed_input/worker-held counts before returning EOF; locate and
modify the functions/variables input_blocks_decompressed,
held_decompressed_input, decompressed_input_done, drain_queue, is_eof, and
next_block to implement this synchronized check/flush.

In `@src/lib/sort/worker_pool.rs`:
- Around line 1477-1482: Replace the silent discard of send errors for
job.result_tx.send in the worker loop: instead of using "let _ =
job.result_tx.send(CompressResult { serial: job.serial, compressed,
recycled_buf: recycled });", handle the Result and surface failures (e.g., if
let Err(e) = job.result_tx.send(...) { /* log including job.serial and error, or
increment a dropped-results metric, or otherwise track the dropped
CompressResult */ }). Ensure you reference the CompressResult payload and
job.serial in the log/metric so dropped compressed data can be diagnosed, and
avoid panicking the worker unless intended.
- Around line 1370-1399: The distribute_chunk_files function currently panics
when opening a chunk file; change its signature to return Result<(),
std::io::Error> (or a crate error type) and propagate failures instead of
calling unwrap_or_else/panic. Replace the explicit panic in the File::open
branch by using ? (or map_err to add context) so errors bubble up, ensure
callers of distribute_chunk_files are updated to handle the Result, and keep the
rest of the logic (building per_worker Vec<ChunkFileState> and sending via
shared.chunk_file_senders) unchanged; reference distribute_chunk_files,
ChunkFileState::new, and shared.chunk_file_senders when locating the change.

---

Nitpick comments:
In `@src/lib/sort/bgzf_io.rs`:
- Around line 50-60: The flush() method should early-return when the current
buffer is empty to avoid creating a serial and submitting a zero-byte
CompressJob: add a guard at the start of flush() that checks self.buf.is_empty()
and returns immediately if true; otherwise keep the existing behavior (replace
self.buf with pool.buffer_pool.checkout(), ensure capacity, increment
self.next_serial, and call self.pool.submit_compress(CompressJob { data, serial,
result_tx: self.result_tx.clone() })). Reference symbols: flush(), self.buf,
self.next_serial, self.pool.submit_compress, CompressJob, result_tx.

In `@src/lib/sort/worker_pool.rs`:
- Around line 1150-1151: The code currently uses expect on decompress_block (let
data = decompress_block(&block, &mut worker.decompressor).expect(...)) which
panics on corrupt input; change this to propagate the error instead: have the
containing function (the worker loop function that calls decompress_block)
return a Result, replace expect with the ? operator or a match that converts the
decompression error into your function's error type (or a custom enum), and
update callers to handle the Result rather than silently swallowing or allowing
a panic; reference decompress_block, worker.decompressor and the worker loop
function to locate and update the signature and call sites so errors are handled
and surfaced appropriately.
- Around line 1262-1263: The BGZF decompression call using
decompress_block(&tagged.block, &mut worker.decompressor).expect(...) must not
panic; replace the expect with proper error handling: call decompress_block and
match or use ? to propagate a Result from the surrounding function, or convert
the error into the worker error-handling path (e.g., log via
worker.logger/worker.error, mark the job/worker failed, and return an Err).
Update the function that contains this code to return a Result if needed and
ensure symbols referenced (decompress_block, tagged.block, worker.decompressor)
propagate or handle decompression errors consistently with the earlier handling
used around lines 1150-1151.
- Around line 654-665: The jitter seed currently uses
SystemTime::now().duration_since(...).subsec_nanos() which yields low entropy
across concurrent threads; modify the jitter_seed computation in this block
(symbols: jitter_seed, SystemTime::now, subsec_nanos, jitter_offset, actual_us,
MIN_BACKOFF_US) to mix in the current thread identifier before deriving the u64
seed (e.g., hash std::thread::current().id() together with subsec_nanos using a
Hasher or use a per-thread RNG) so simultaneous threads produce more varied
jitter values while keeping the subsequent jitter_offset, actual_us, and sleep
logic unchanged.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e758347-2c68-4dce-a3ee-a0881f68e406

📥 Commits

Reviewing files that changed from the base of the PR and between c6c8a1a and 79461ac.

📒 Files selected for processing (14)
  • .gitignore
  • crates/fgumi-bgzf/src/lib.rs
  • crates/fgumi-bgzf/src/writer.rs
  • src/commands/sort.rs
  • src/lib/bam_io.rs
  • src/lib/sort/bgzf_io.rs
  • src/lib/sort/mod.rs
  • src/lib/sort/pooled_bam_writer.rs
  • src/lib/sort/pooled_chunk_writer.rs
  • src/lib/sort/raw.rs
  • src/lib/sort/read_ahead.rs
  • src/lib/sort/worker_pool.rs
  • tests/integration/main.rs
  • tests/integration/test_sort_correctness.rs
✅ Files skipped from review due to trivial changes (5)
  • .gitignore
  • crates/fgumi-bgzf/src/writer.rs
  • crates/fgumi-bgzf/src/lib.rs
  • src/commands/sort.rs
  • src/lib/sort/pooled_bam_writer.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • tests/integration/main.rs
  • src/lib/sort/mod.rs
  • src/lib/bam_io.rs
  • tests/integration/test_sort_correctness.rs
  • src/lib/sort/pooled_chunk_writer.rs
  • src/lib/sort/raw.rs

Comment thread src/lib/sort/read_ahead.rs
Comment thread src/lib/sort/worker_pool.rs Outdated
Comment thread src/lib/sort/worker_pool.rs Outdated
@nh13
nh13 force-pushed the nh/sort-investigation branch from 79461ac to 850be0b Compare April 6, 2026 20:54
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 20:54 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/sort-investigation branch from 850be0b to 48d8c4b Compare April 6, 2026 21:11
@nh13
nh13 had a problem deploying to github-actions April 6, 2026 21:11 — with GitHub Actions Failure

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
src/lib/sort/raw.rs (1)

1424-1425: ⚠️ Potential issue | 🟠 Major

output_compression is still bypassed on pooled BAM writes.

The shared pool is initialized with self.temp_compression, and every non-index final BAM path later writes through PooledBamWriter using that same pool. That means --output-compression only applies on the indexing path; --temp-compression=0 will also make ordinary output BAMs uncompressed. Split temp/output compression state or carry the level in each compress job.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/raw.rs` around lines 1424 - 1425, The shared SortWorkerPool is
being created with self.temp_compression so PooledBamWriter jobs always use the
temp level and ignore --output-compression; change the design to carry
compression level per compress job (or maintain two pools) so final BAM writes
use self.output_compression while internal/temp operations use
self.temp_compression. Concretely, update SortWorkerPool/CompressJob (or the job
submit API used by PooledBamWriter) to accept a compression_level field and
ensure PooledBamWriter submits jobs with compression_level =
self.output_compression, while internal/temp submits use self.temp_compression;
alternatively instantiate a separate pool for output writes named e.g.
SortWorkerPool::new_output(self.output_compression) and use that for
PooledBamWriter to preserve existing temp pool behavior.
src/lib/bam_io.rs (1)

781-794: ⚠️ Potential issue | 🟠 Major

Keep a streaming fallback for non-seekable inputs.

seek(SeekFrom::Start(0)) makes pooled mode fail on FIFOs, /dev/fd/*, and process substitution. The legacy path can stream those inputs, and --x-pool-decompress now defaults to true, so this becomes a user-visible regression. Fall back to the legacy reader or a tee/replay path when rewind is unsupported instead of hard-failing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/bam_io.rs` around lines 781 - 794, The current code assumes the input
File is seekable by calling file.seek(SeekFrom::Start(0)) after creating a
BgzfReader/noodles Reader which breaks pooled mode for non-seekable inputs
(FIFOs, /dev/fd/*, process substitution); change this to detect whether
rewinding is supported (attempt a small seek and catch errors or use
metadata/is_seekable helper) and if seek fails, preserve the legacy streaming
path or switch to a tee/replay path instead of hard-failing: when a seek on the
opened `file` returns an error indicating unsupported seek, skip the rewind and
instantiate the legacy streaming reader (the same code path used prior to
pooling) or create a temporary tee buffer to allow pool workers to read,
ensuring the logic around `BgzfReader` and `noodles::bam::io::Reader` uses the
streaming fallback in that case.
🧹 Nitpick comments (2)
src/lib/sort/worker_pool.rs (2)

1174-1175: Decompression failures panic the worker.

Consider surfacing decompression errors via a shared error flag instead of panicking, similar to read errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 1174 - 1175, The code currently
panics on decompression failure by calling expect on decompress_block; instead
handle the Result from decompress_block and surface errors via the same shared
error mechanism used for read errors (e.g., the Atomic/Mutex error flag your
pool uses), e.g. match decompress_block(&block, &mut worker.decompressor) {
Ok(data) => { ... } Err(e) => { set_shared_error_flag_with(e);
return/stop_this_worker_thread; } }, referencing decompress_block and
worker.decompressor and ensuring the worker returns/terminates without panicking
so the pool coordinator can observe and handle the error.

1229-1242: Chunk read errors surface indirectly.

Unlike input read errors (which set a flag), chunk read errors mark EOF and rely on io_writer_loop serial gap detection. This works but the error message will be less informative.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 1229 - 1242, The chunk read error
is currently hidden by only setting file_state.eof and logging; instead record
and propagate the actual error so callers can surface it: when read_raw_blocks
returns Err(e) set a persistent error field on the FileState (e.g.,
file_state.read_error or file_state.io_error) with e (or its string) in addition
to setting file_state.eof and calling Self::maybe_mark_all_eof(shared); then
update io_writer_loop (and any serial-gap checks) to inspect that FileState
error field and return/handle the real I/O error rather than only treating it as
EOF so the failure message is informative (refer to read_raw_blocks,
file_state.eof, maybe_mark_all_eof, and io_writer_loop).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/sort/pooled_bam_writer.rs`:
- Around line 90-100: write_raw_record currently pre-flushes only when the
current buffer would overflow but still creates a single oversized compression
job if 4 + record_bytes.len() > BGZF_MAX_BLOCK_SIZE; change write_raw_record to
handle records larger than BGZF_MAX_BLOCK_SIZE by chunking them the same way
headers are handled: if 4 + record_bytes.len() > BGZF_MAX_BLOCK_SIZE, iterate
over record_bytes and for each chunk compute chunk_len <= BGZF_MAX_BLOCK_SIZE -
4, pre-flush staging when needed, write chunk_len as little-endian u32 via
staging.buf().extend_from_slice(&chunk_len.to_le_bytes()), write the chunk bytes
via staging.buf().extend_from_slice(...), call staging.flush_if_full() after
each chunk, and repeat until the entire record is written (keeping existing
behavior for small records).

In `@src/lib/sort/pooled_chunk_writer.rs`:
- Around line 81-94: The pre-flush size check in write_record only accounts for
4 + record.len() and ignores the encoded key when K::EMBEDDED_IN_RECORD is
false, which can overflow BGZF_MAX_BLOCK_SIZE; update write_record to compute
the total bytes needed (include key's encoded size when !K::EMBEDDED_IN_RECORD)
and pre-flush based on that, or switch to streaming the entry using
write_chunked to write key, length prefix, and record without buffering the
whole entry; ensure staging.flush() is called before writing and preserve
staging.flush_if_full() behavior.

In `@src/lib/sort/read_ahead.rs`:
- Around line 404-423: In next_block(), after calling drain_queue() and before
returning None on is_eof(), perform a final drain and re-check the reorder
buffer for the needed serial: call drain_queue() again, then attempt
self.reorder.remove(&self.next_serial) (incrementing next_serial and returning
Some if found); only return None if that second check finds nothing. This avoids
the race where a block is pushed between the first drain_queue() and the
is_eof() check.

---

Duplicate comments:
In `@src/lib/bam_io.rs`:
- Around line 781-794: The current code assumes the input File is seekable by
calling file.seek(SeekFrom::Start(0)) after creating a BgzfReader/noodles Reader
which breaks pooled mode for non-seekable inputs (FIFOs, /dev/fd/*, process
substitution); change this to detect whether rewinding is supported (attempt a
small seek and catch errors or use metadata/is_seekable helper) and if seek
fails, preserve the legacy streaming path or switch to a tee/replay path instead
of hard-failing: when a seek on the opened `file` returns an error indicating
unsupported seek, skip the rewind and instantiate the legacy streaming reader
(the same code path used prior to pooling) or create a temporary tee buffer to
allow pool workers to read, ensuring the logic around `BgzfReader` and
`noodles::bam::io::Reader` uses the streaming fallback in that case.

In `@src/lib/sort/raw.rs`:
- Around line 1424-1425: The shared SortWorkerPool is being created with
self.temp_compression so PooledBamWriter jobs always use the temp level and
ignore --output-compression; change the design to carry compression level per
compress job (or maintain two pools) so final BAM writes use
self.output_compression while internal/temp operations use
self.temp_compression. Concretely, update SortWorkerPool/CompressJob (or the job
submit API used by PooledBamWriter) to accept a compression_level field and
ensure PooledBamWriter submits jobs with compression_level =
self.output_compression, while internal/temp submits use self.temp_compression;
alternatively instantiate a separate pool for output writes named e.g.
SortWorkerPool::new_output(self.output_compression) and use that for
PooledBamWriter to preserve existing temp pool behavior.

---

Nitpick comments:
In `@src/lib/sort/worker_pool.rs`:
- Around line 1174-1175: The code currently panics on decompression failure by
calling expect on decompress_block; instead handle the Result from
decompress_block and surface errors via the same shared error mechanism used for
read errors (e.g., the Atomic/Mutex error flag your pool uses), e.g. match
decompress_block(&block, &mut worker.decompressor) { Ok(data) => { ... } Err(e)
=> { set_shared_error_flag_with(e); return/stop_this_worker_thread; } },
referencing decompress_block and worker.decompressor and ensuring the worker
returns/terminates without panicking so the pool coordinator can observe and
handle the error.
- Around line 1229-1242: The chunk read error is currently hidden by only
setting file_state.eof and logging; instead record and propagate the actual
error so callers can surface it: when read_raw_blocks returns Err(e) set a
persistent error field on the FileState (e.g., file_state.read_error or
file_state.io_error) with e (or its string) in addition to setting
file_state.eof and calling Self::maybe_mark_all_eof(shared); then update
io_writer_loop (and any serial-gap checks) to inspect that FileState error field
and return/handle the real I/O error rather than only treating it as EOF so the
failure message is informative (refer to read_raw_blocks, file_state.eof,
maybe_mark_all_eof, and io_writer_loop).
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0ba1ec6a-3af2-48d9-a789-ce88c79e1e60

📥 Commits

Reviewing files that changed from the base of the PR and between 79461ac and 850be0b.

📒 Files selected for processing (14)
  • .gitignore
  • crates/fgumi-bgzf/src/lib.rs
  • crates/fgumi-bgzf/src/writer.rs
  • src/commands/sort.rs
  • src/lib/bam_io.rs
  • src/lib/sort/bgzf_io.rs
  • src/lib/sort/mod.rs
  • src/lib/sort/pooled_bam_writer.rs
  • src/lib/sort/pooled_chunk_writer.rs
  • src/lib/sort/raw.rs
  • src/lib/sort/read_ahead.rs
  • src/lib/sort/worker_pool.rs
  • tests/integration/main.rs
  • tests/integration/test_sort_correctness.rs
✅ Files skipped from review due to trivial changes (3)
  • .gitignore
  • src/lib/sort/mod.rs
  • crates/fgumi-bgzf/src/writer.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/fgumi-bgzf/src/lib.rs
  • tests/integration/main.rs
  • tests/integration/test_sort_correctness.rs

Comment thread src/lib/sort/pooled_bam_writer.rs Outdated
Comment thread src/lib/sort/pooled_chunk_writer.rs Outdated
Comment thread src/lib/sort/read_ahead.rs
@nh13
nh13 force-pushed the nh/sort-investigation branch from 48d8c4b to 42a417d Compare April 6, 2026 21:26
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 21:26 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (4)
src/lib/sort/raw.rs (1)

1401-1402: ⚠️ Potential issue | 🟠 Major

output_compression() is still bypassed on pooled BAM output.

The shared pool is initialized with self.temp_compression, and the worker compressors are fixed when that pool is built. Every non-index PooledBamWriter path therefore inherits temp compression unless the compression level is carried per job or the final BAM uses a separate pool.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/raw.rs` around lines 1401 - 1402, The pooled worker pool is
being created with self.temp_compression, which fixes compressor settings for
all PooledBamWriter jobs and bypasses output_compression(); change the pooling
logic so final BAM writers use the output compression settings (call
self.output_compression()) instead of temp_compression — either by creating a
separate SortWorkerPool for final outputs via
SortWorkerPool::new(self.threads.max(1), self.output_compression()) and passing
that to PooledBamWriter, or by making SortWorkerPool able to select compression
per job (propagate output_compression() into the job-level API used by
PooledBamWriter) so that PooledBamWriter no longer inherits
self.temp_compression.
src/lib/sort/bgzf_io.rs (1)

112-145: ⚠️ Potential issue | 🔴 Critical

Missing tail blocks still finalize a truncated BGZF stream.

This catches gaps only when a later serial is already buffered in reorder_buf. If the last submitted serial never arrives, recv() ends with reorder_buf empty and next_expected still short, and this loop still writes BGZF_EOF. Pass the expected block count into io_writer_loop (or send an explicit finish sentinel) and require an exact match before finalizing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/bgzf_io.rs` around lines 112 - 145, The io writer currently
writes BGZF_EOF even if some trailing blocks never arrived because it only
detects gaps when a later serial is buffered; modify the io_writer_loop to
require an exact final block count (or accept an explicit finish sentinel)
before writing BGZF_EOF: add an expected_total parameter (or a finish message on
result_rx), track next_expected vs expected_total, and after the recv loop
verify next_expected == expected_total+1 (or that the finish sentinel was
received) before writing BGZF_EOF; if they differ return an error instead of
finalizing. Ensure you update uses of result_rx, reorder_buf,
buffer_pool.checkin, and writer.write_all to follow this contract so missing
tail blocks are detected and the stream is not silently truncated.
src/lib/sort/worker_pool.rs (2)

1335-1342: ⚠️ Potential issue | 🔴 Critical

all_chunks_eof is raised before the chunk pipeline is drained.

This flips when every reader has hit EOF, but raw_chunk_blocks, decompressed_chunks, and other workers’ held chunk outputs can still contain tail data. MainThreadChunkConsumer treats that flag plus an empty queue as final EOF, which lines up with the record-loss failure in test_sort_many_chunks_with_semaphore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 1335 - 1342, maybe_mark_all_eof
currently sets shared.all_chunks_eof as soon as all readers hit EOF, but that
can occur while raw_chunk_blocks, decompressed_chunks, or other worker queues
still hold data; change the logic so maybe_mark_all_eof only marks
sources-as-done (increment sources_at_eof) and then calls a new check function
(e.g., check_and_mark_all_eof) which verifies that shared.raw_chunk_blocks and
shared.decompressed_chunks (and any other in-flight queues/buffers) are empty
and no worker is producing before setting shared.all_chunks_eof and unpark-ing
shared.main_thread_handle; update maybe_mark_all_eof to call that check instead
of unconditionally storing true into all_chunks_eof so MainThreadChunkConsumer
only sees EOF after the pipeline is drained.

1191-1200: ⚠️ Potential issue | 🔴 Critical

Phase 1 can still publish EOF while another worker is holding the missing serial.

count >= total only proves every block was decompressed once. Another worker can still be sitting on the next block in held_decompressed_input, which lets PooledInputStream see decompressed_input_done with an empty queue and stop early. This needs a global drained check, not just this worker’s pushed state.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/sort/read_ahead.rs`:
- Around line 489-497: The Direct branch in the iterator currently swallows
reader.read_record() errors by returning None (EOF); update the iterator/item
type used by record_source so Self::Direct(reader) yields a Result<RawRecord,
Error> (or propagates the existing error type) instead of mapping Err(e) to
None, modify the match in read_record to return Err(e) on error, and adjust
callers in src/lib/sort/raw.rs that iterate for record in record_source to
handle Result items (propagate or fail the sort) so BGZF/read/parse failures
bubble up rather than being treated as EOF.

In `@tests/integration/test_sort_correctness.rs`:
- Line 56: The test fixture uses zero-padded names (let name =
format!("read_{i:06}")) which are identical under lexicographic and natural
sorting; change the format to produce non-zero-padded names so natural vs
lexicographic order differs (e.g., replace "read_{i:06}" with "read_{i}" or
"read_{i}" plus a suffix/prefix like "read_{i}" or "read_{i}_v" in the test
where the variable name is created) so the queryname::natural cases actually
validate natural-sort behavior.

---

Duplicate comments:
In `@src/lib/sort/bgzf_io.rs`:
- Around line 112-145: The io writer currently writes BGZF_EOF even if some
trailing blocks never arrived because it only detects gaps when a later serial
is buffered; modify the io_writer_loop to require an exact final block count (or
accept an explicit finish sentinel) before writing BGZF_EOF: add an
expected_total parameter (or a finish message on result_rx), track next_expected
vs expected_total, and after the recv loop verify next_expected ==
expected_total+1 (or that the finish sentinel was received) before writing
BGZF_EOF; if they differ return an error instead of finalizing. Ensure you
update uses of result_rx, reorder_buf, buffer_pool.checkin, and writer.write_all
to follow this contract so missing tail blocks are detected and the stream is
not silently truncated.

In `@src/lib/sort/raw.rs`:
- Around line 1401-1402: The pooled worker pool is being created with
self.temp_compression, which fixes compressor settings for all PooledBamWriter
jobs and bypasses output_compression(); change the pooling logic so final BAM
writers use the output compression settings (call self.output_compression())
instead of temp_compression — either by creating a separate SortWorkerPool for
final outputs via SortWorkerPool::new(self.threads.max(1),
self.output_compression()) and passing that to PooledBamWriter, or by making
SortWorkerPool able to select compression per job (propagate
output_compression() into the job-level API used by PooledBamWriter) so that
PooledBamWriter no longer inherits self.temp_compression.

In `@src/lib/sort/worker_pool.rs`:
- Around line 1335-1342: maybe_mark_all_eof currently sets shared.all_chunks_eof
as soon as all readers hit EOF, but that can occur while raw_chunk_blocks,
decompressed_chunks, or other worker queues still hold data; change the logic so
maybe_mark_all_eof only marks sources-as-done (increment sources_at_eof) and
then calls a new check function (e.g., check_and_mark_all_eof) which verifies
that shared.raw_chunk_blocks and shared.decompressed_chunks (and any other
in-flight queues/buffers) are empty and no worker is producing before setting
shared.all_chunks_eof and unpark-ing shared.main_thread_handle; update
maybe_mark_all_eof to call that check instead of unconditionally storing true
into all_chunks_eof so MainThreadChunkConsumer only sees EOF after the pipeline
is drained.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f52d5711-3dfa-4e3d-8e6d-88944503d4b9

📥 Commits

Reviewing files that changed from the base of the PR and between 850be0b and 48d8c4b.

📒 Files selected for processing (13)
  • .gitignore
  • crates/fgumi-bgzf/src/lib.rs
  • crates/fgumi-bgzf/src/writer.rs
  • src/lib/bam_io.rs
  • src/lib/sort/bgzf_io.rs
  • src/lib/sort/mod.rs
  • src/lib/sort/pooled_bam_writer.rs
  • src/lib/sort/pooled_chunk_writer.rs
  • src/lib/sort/raw.rs
  • src/lib/sort/read_ahead.rs
  • src/lib/sort/worker_pool.rs
  • tests/integration/main.rs
  • tests/integration/test_sort_correctness.rs
✅ Files skipped from review due to trivial changes (3)
  • .gitignore
  • tests/integration/main.rs
  • crates/fgumi-bgzf/src/writer.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/fgumi-bgzf/src/lib.rs
  • src/lib/sort/mod.rs
  • src/lib/bam_io.rs
  • src/lib/sort/pooled_bam_writer.rs
  • src/lib/sort/pooled_chunk_writer.rs

Comment thread src/lib/sort/read_ahead.rs Outdated
Comment thread tests/integration/test_sort_correctness.rs Outdated
@nh13
nh13 force-pushed the nh/sort-investigation branch from 42a417d to 0814bce Compare April 6, 2026 21:43
@nh13
nh13 had a problem deploying to github-actions April 6, 2026 21:43 — with GitHub Actions Failure
@nh13
nh13 force-pushed the nh/sort-investigation branch from 0814bce to 49b4d7a Compare April 6, 2026 21:48
@nh13
nh13 had a problem deploying to github-actions April 6, 2026 21:48 — with GitHub Actions Failure
@nh13
nh13 force-pushed the nh/sort-investigation branch from 49b4d7a to fc7c1fd Compare April 6, 2026 22:11
@nh13
nh13 had a problem deploying to github-actions April 6, 2026 22:11 — with GitHub Actions Failure
@nh13

nh13 commented Apr 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 6, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13
nh13 force-pushed the nh/sort-investigation branch from fc7c1fd to d0029cf Compare April 6, 2026 22:16
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 22:17 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
src/lib/sort/raw.rs (1)

3230-3230: ⚠️ Potential issue | 🟠 Major

The new Natural cases still don't validate the natural comparator.

These additions reuse zero-padded numeric suffixes, and the merge test's final check is plain String order. A regression back to lexicographic comparison would still pass. Use non-padded names and compare adjacent records with the same natural key/comparator used in production.

Minimal fixture change
-                .name(&format!("read{i:04}"))
+                .name(&format!("read{i}"))

Also applies to: 3281-3281, 3333-3333, 3388-3388, 3652-3672

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/raw.rs` at line 3230, The test case labeled queryname_natural
currently uses zero-padded numeric suffixes and validates ordering via plain
String comparison, which won't catch regressions to lexicographic sorting;
update the fixture records used by the
SortOrder::Queryname(QuerynameComparator::Natural) cases to use non-padded
numeric suffixes (e.g., "name1", "name2", "name10") and modify the assertions in
the merge/ordering tests to compare adjacent records using the same natural
comparator (QuerynameComparator::Natural) or the production comparison function
rather than raw String ordering so the test verifies numeric-aware ordering for
QuerynameComparator::Natural.
src/lib/sort/bgzf_io.rs (1)

112-145: ⚠️ Potential issue | 🟠 Major

A missing tail block still finalizes a truncated BGZF stream.

If the last serial never arrives, reorder_buf is empty when result_rx closes, so this still appends BGZF_EOF and returns Ok(()). The current gap check only catches missing middle blocks. Emit an explicit terminal message with the final serial count, and refuse to write EOF until next_expected matches it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/bgzf_io.rs` around lines 112 - 145, The consumer currently
writes BGZF_EOF even if the final block never arrived because it has no
knowledge of the expected final serial; fix by adding a terminal signal that
communicates the final serial count and by refusing to write BGZF_EOF unless
next_expected matches that final count. Change the producer/worker protocol so
one final message is sent on result_rx (e.g. a special Result variant or a
Result with a dedicated flag/terminal_serial) that the consumer reads and stores
into a new final_serial variable; then, after the receive loop and after
draining reorder_buf, verify final_serial is Some and that next_expected ==
final_serial + 1 (or the agreed end convention) and return an Err if not,
otherwise proceed to write BGZF_EOF and flush; update references in the consumer
loop that currently use result_rx, result.serial, reorder_buf, next_expected,
and BGZF_EOF.
🧹 Nitpick comments (3)
src/lib/sort/worker_pool.rs (2)

1192-1193: Corrupt input will panic the worker thread.

expect() on decompression is common in BAM tools, but corrupted user input will crash the pool. Consider returning an error and setting an input_decompress_error flag if graceful handling is desired.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 1192 - 1193, The call to
decompress_block currently uses expect and will panic on corrupt input; replace
the expect in the worker loop where decompress_block(&block, &mut
worker.decompressor) is called with proper error handling: match the Result, on
Err set a new or existing worker.input_decompress_error flag (or create one in
the worker struct) and return/propagate an Err from the worker thread instead of
panicking, or otherwise skip/abort processing gracefully; ensure the worker's
API (thread join result or channel) surfaces the error so the caller can handle
it rather than letting the thread panic.

1252-1265: Chunk file I/O errors silently truncate that source.

Error is logged but the file is marked EOF. Downstream merge detects missing serials, but no explicit error flag is surfaced (unlike input_read_error for Phase 1). Consider adding a similar chunk_read_error flag for parity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/worker_pool.rs` around lines 1252 - 1265, The read_raw_blocks
error path currently logs and marks file_state.eof but doesn't set an explicit
error flag analogous to Phase 1's input_read_error; add a new chunk_read_error
field to the per-source state (the same struct that contains file_state.eof and
input_read_error), set file_state.chunk_read_error = Some(e) (or true) inside
the Err(e) branch in the read_raw_blocks match, and ensure any callers that
check/propagate input_read_error (e.g., io_writer_loop / merge logic and
Self::maybe_mark_all_eof/shared handling) are updated to also inspect/propagate
chunk_read_error so the truncation is surfaced explicitly rather than only via
missing-serial detection.
src/lib/sort/read_ahead.rs (1)

518-535: Caller must check take_error() after iteration to detect I/O failures.

The Direct path stores errors in the slot but returns None (EOF). Callers iterating with for record in record_source won't see errors unless they call take_error() afterward. This is documented at line 506, but easy to miss.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sort/read_ahead.rs` around lines 518 - 535, The iterator currently
swallows I/O errors in Direct by storing them in error_slot and returning None,
so callers using for record in ... never see failures unless they call
take_error(); change the iterator to surface errors directly: make
Iterator::Item a Result<RawRecord, io::Error> (or an appropriate error type),
update next() (the Direct branch in fn next()) to return Some(Err(e)) instead of
storing to error_slot and returning None, and update the ReadAhead path
(Self::ReadAhead and its r.next()) and any callers to handle Result items; keep
or remove take_error() accordingly and update docs to reflect the new contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/sort/raw.rs`:
- Around line 2377-2385: build_chunk_sources() calls
pool.distribute_chunk_files(...) which merely enqueues files; before creating
PoolDisk sources and/or calling set_phase(PHASE2) you must wait for PHASE1
workers to acknowledge they've drained held state. Modify build_chunk_sources
(around the call to Pool::distribute_chunk_files and before pushing
ChunkSource::PoolDisk entries) to perform a quiescence/ack step: invoke a new or
existing Pool/WorkerPool method (e.g. pool.wait_for_phase_ack() or
worker_pool.quiesce_workers()) that blocks until all PHASE1 workers report
drained state, then proceed to set_phase(PHASE2) and create PoolDisk sources;
ensure the same change is applied at the other spot noted (lines ~2471-2480) so
PHASE2 is never entered while PHASE1 state remains.
- Around line 928-970: The drain loop currently stops when pending_total >=
max_pending_blocks which can deadlock if the target source has its next block in
decompressed_chunks; change the logic to keep popping decompressed_chunks until
either decompressed_chunks is empty or the target source becomes runnable:
compute the cap only for buffering blocks of other sources (i.e., when updating
pending_total and new_total, exclude blocks with source_id == target_source),
and always allow installing/consuming a block if block.source_id ==
target_source or if it is immediately consumable (block.serial ==
source.next_serial && source.remaining() == 0); ensure the break condition uses
the adjusted new_total that excludes the target source so we never park while
the target has an available decompressed block in decompressed_chunks.

In `@tests/integration/test_sort_correctness.rs`:
- Around line 120-126: The helper count_records should fail hard when samtools
output cannot be parsed as a number; replace the final .parse().unwrap_or(0)
with a fallible parse that panics with a clear message (e.g.,
.parse::<u64>().expect(...)) and include the raw stdout/stderr in the error
message so a broken samtools call isn't silently treated as zero; update the
parsing step in function count_records to use expect and surface the actual
output for debugging.

---

Duplicate comments:
In `@src/lib/sort/bgzf_io.rs`:
- Around line 112-145: The consumer currently writes BGZF_EOF even if the final
block never arrived because it has no knowledge of the expected final serial;
fix by adding a terminal signal that communicates the final serial count and by
refusing to write BGZF_EOF unless next_expected matches that final count. Change
the producer/worker protocol so one final message is sent on result_rx (e.g. a
special Result variant or a Result with a dedicated flag/terminal_serial) that
the consumer reads and stores into a new final_serial variable; then, after the
receive loop and after draining reorder_buf, verify final_serial is Some and
that next_expected == final_serial + 1 (or the agreed end convention) and return
an Err if not, otherwise proceed to write BGZF_EOF and flush; update references
in the consumer loop that currently use result_rx, result.serial, reorder_buf,
next_expected, and BGZF_EOF.

In `@src/lib/sort/raw.rs`:
- Line 3230: The test case labeled queryname_natural currently uses zero-padded
numeric suffixes and validates ordering via plain String comparison, which won't
catch regressions to lexicographic sorting; update the fixture records used by
the SortOrder::Queryname(QuerynameComparator::Natural) cases to use non-padded
numeric suffixes (e.g., "name1", "name2", "name10") and modify the assertions in
the merge/ordering tests to compare adjacent records using the same natural
comparator (QuerynameComparator::Natural) or the production comparison function
rather than raw String ordering so the test verifies numeric-aware ordering for
QuerynameComparator::Natural.

---

Nitpick comments:
In `@src/lib/sort/read_ahead.rs`:
- Around line 518-535: The iterator currently swallows I/O errors in Direct by
storing them in error_slot and returning None, so callers using for record in
... never see failures unless they call take_error(); change the iterator to
surface errors directly: make Iterator::Item a Result<RawRecord, io::Error> (or
an appropriate error type), update next() (the Direct branch in fn next()) to
return Some(Err(e)) instead of storing to error_slot and returning None, and
update the ReadAhead path (Self::ReadAhead and its r.next()) and any callers to
handle Result items; keep or remove take_error() accordingly and update docs to
reflect the new contract.

In `@src/lib/sort/worker_pool.rs`:
- Around line 1192-1193: The call to decompress_block currently uses expect and
will panic on corrupt input; replace the expect in the worker loop where
decompress_block(&block, &mut worker.decompressor) is called with proper error
handling: match the Result, on Err set a new or existing
worker.input_decompress_error flag (or create one in the worker struct) and
return/propagate an Err from the worker thread instead of panicking, or
otherwise skip/abort processing gracefully; ensure the worker's API (thread join
result or channel) surfaces the error so the caller can handle it rather than
letting the thread panic.
- Around line 1252-1265: The read_raw_blocks error path currently logs and marks
file_state.eof but doesn't set an explicit error flag analogous to Phase 1's
input_read_error; add a new chunk_read_error field to the per-source state (the
same struct that contains file_state.eof and input_read_error), set
file_state.chunk_read_error = Some(e) (or true) inside the Err(e) branch in the
read_raw_blocks match, and ensure any callers that check/propagate
input_read_error (e.g., io_writer_loop / merge logic and
Self::maybe_mark_all_eof/shared handling) are updated to also inspect/propagate
chunk_read_error so the truncation is surfaced explicitly rather than only via
missing-serial detection.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 870c89f4-a9f5-4180-bc3c-4b9d0bd8f136

📥 Commits

Reviewing files that changed from the base of the PR and between 48d8c4b and fc7c1fd.

📒 Files selected for processing (13)
  • .gitignore
  • crates/fgumi-bgzf/src/lib.rs
  • crates/fgumi-bgzf/src/writer.rs
  • src/lib/bam_io.rs
  • src/lib/sort/bgzf_io.rs
  • src/lib/sort/mod.rs
  • src/lib/sort/pooled_bam_writer.rs
  • src/lib/sort/pooled_chunk_writer.rs
  • src/lib/sort/raw.rs
  • src/lib/sort/read_ahead.rs
  • src/lib/sort/worker_pool.rs
  • tests/integration/main.rs
  • tests/integration/test_sort_correctness.rs
✅ Files skipped from review due to trivial changes (5)
  • .gitignore
  • tests/integration/main.rs
  • crates/fgumi-bgzf/src/lib.rs
  • src/lib/sort/mod.rs
  • crates/fgumi-bgzf/src/writer.rs

Comment thread src/lib/sort/raw.rs
Comment thread src/lib/sort/raw.rs
Comment thread tests/integration/test_sort_correctness.rs Outdated
@nh13
nh13 force-pushed the nh/sort-investigation branch from d0029cf to a502428 Compare April 6, 2026 22:34
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 22:34 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/sort-investigation branch from a502428 to 45020cf Compare April 6, 2026 22:50
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 22:50 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/sort-investigation branch from 45020cf to 14b9b6c Compare April 6, 2026 22:56
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 22:56 — with GitHub Actions Inactive
@nh13

nh13 commented Apr 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai resume

Replace ad-hoc thread spawning in fgumi sort with a structured N+2 model:
N pool workers + 1 I/O writer thread + 1 main thread.

Worker pool (src/lib/sort/worker_pool.rs):
- ArrayQueue (lock-free bounded) replaces crossbeam channels for all
  inter-step queues; eliminates blocking push/pop in the worker loop
- generic_worker_loop pattern: held-item advancement → backpressure
  priorities → exclusive step ownership → adaptive backoff (10µs min,
  yield_now + jitter, up to 1ms)
- Phase 1: Worker 0 owns ReadInputBlocks exclusively; all workers
  share DecompressInput and Compress steps
- Phase 2: each worker owns its own disjoint chunk files for
  ReadChunkBlocks (no exclusivity needed); workers share
  DecompressChunks and Compress steps
- park()/unpark() on main thread: workers call unpark() after pushing
  to main-thread-facing queues, eliminating polling delays
- Workers stay alive until SHUTDOWN (not phase completion) to prevent
  premature exit deadlocks during phase transitions

Raw sorter (src/lib/sort/raw.rs):
- pool_decompress field and use_sequential_sort() helper gate whether
  the worker pool handles read/decompress I/O; when enabled, rayon
  par_sort is replaced with single-threaded sort to preserve N+2
- Fix: merge_chunks_with_index passes pool_decompress=false to
  build_chunk_sources since the indexing path has no pool consumer set up
- Remove dead max_memory_total field (superseded by MemoryLimit::Auto
  from #236); --max-memory=auto continues to work as intended

Staging buffer (src/lib/sort/bgzf_io.rs):
- Remove dead is_spill field from CompressJob and StagingBuffer;
  the worker pool does not need to distinguish spill vs output jobs

Correctness: all 2318 tests pass; outputs verified against baseline
@nh13

nh13 commented Apr 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@nh13
nh13 force-pushed the nh/sort-investigation branch from 14b9b6c to 45653c6 Compare April 6, 2026 23:50
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 23:50 — with GitHub Actions Inactive
@nh13

nh13 commented Apr 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@nh13

nh13 commented Apr 6, 2026

Copy link
Copy Markdown
Member Author

Closing to reopen under a more descriptive branch name.

@nh13 nh13 closed this Apr 6, 2026
@nh13
nh13 deleted the nh/sort-investigation branch April 6, 2026 23:58
@nh13
nh13 temporarily deployed to github-actions April 6, 2026 23:58 — with GitHub Actions Inactive

This branch was previously deployed

1 inactive deployment
github-actions — 45653c63 Deployed Apr 6, 2026 by nh13 via coverage #954
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.

1 participant