Skip to content

feat(sort): add fgumi merge command with loser tree - #186

Merged
nh13 merged 1 commit into
mainfrom
nh/feat-merge-command
Mar 24, 2026
Merged

nh13 merged 1 commit into
mainfrom
nh/feat-merge-command

Conversation

@nh13

@nh13 nh13 commented Mar 24, 2026

Copy link
Copy Markdown
Member

Summary

  • Add fgumi merge subcommand for k-way merging of pre-sorted BAM files, supporting coordinate, queryname, and template-coordinate orders
  • Interface follows samtools merge conventions (-o, -b, -@, --order)
  • Merges headers from all inputs (read groups, program records), with earlier inputs taking precedence for duplicate IDs
  • Accepts single input file (acts as copy, matching samtools behavior)
  • Add loser tree (tournament tree) data structure for k-way merge — log2(k) comparisons per element vs 2·log2(k) for a binary heap
  • Fix chunk file naming collision during consolidation (monotonic counter replaces chunk_files.len())

Loser tree

Uses the incremental insertion approach from Apache DataFusion — no power-of-2 padding required. 12 comprehensive tests covering stability, non-power-of-2 fan-in, large fan-in (k=64), and duplicate keys.

Test plan

  • fgumi merge produces IDENTICAL output to baseline (verified with fgumi compare bams on 89M records)
  • fgumi sort --verify passes on all merge outputs
  • 12 loser tree unit tests pass
  • cargo ci-fmt clean
  • cargo ci-lint clean
  • cargo ci-test (running)

@nh13
nh13 temporarily deployed to github-actions March 24, 2026 06:01 — with GitHub Actions Inactive
@nh13 nh13 mentioned this pull request Mar 24, 2026
5 tasks done
@codecov

codecov Bot commented Mar 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.74221% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.01%. Comparing base (2d93cc2) to head (c6567d0).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/commands/merge.rs 93.33% 16 Missing ⚠️
src/lib/sort/raw.rs 98.31% 4 Missing ⚠️
src/lib/sort/loser_tree.rs 98.64% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #186      +/-   ##
==========================================
+ Coverage   85.65%   86.01%   +0.36%     
==========================================
  Files         128      110      -18     
  Lines       51982    51737     -245     
==========================================
- Hits        44524    44502      -22     
+ Misses       7458     7235     -223     

☔ 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 Mar 24, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@nh13 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 46 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5bedee8b-3e13-4d3f-826e-fc2e25d00537

📥 Commits

Reviewing files that changed from the base of the PR and between 076eb77 and c6567d0.

📒 Files selected for processing (7)
  • src/commands/merge.rs
  • src/commands/mod.rs
  • src/commands/sort.rs
  • src/lib/sort/loser_tree.rs
  • src/lib/sort/mod.rs
  • src/lib/sort/raw.rs
  • src/main.rs
📝 Walkthrough

Walkthrough

Adds a new merge CLI subcommand (Merge) that k-way merges pre-sorted BAMs into a single sorted BAM. Introduces LoserTree for tournament-style k-way merging and RawExternalSorter::merge_bams(...) to perform merges with key extraction for template-coordinate, coordinate, or queryname orders. Exposes loser_tree and merge modules, updates main subcommand enum, and factors parse_cell_tag/parse_memory. Includes unit tests for header merging, loser-tree behavior, and multiple merge scenarios.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main additions: a new merge command and loser tree data structure for k-way merging.
Description check ✅ Passed The description comprehensively covers the changes including the merge subcommand, loser tree implementation, and test results.
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/feat-merge-command

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: 4

🤖 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/merge.rs`:
- Around line 119-121: The output path may alias an input and get truncated
during writing; update the merge command to compare the resolved output path
(self.output) against each resolved input path after validation and return an
error if any match. Specifically, after calling validate_file_exists on
input_paths, canonicalize or otherwise resolve both self.output and each input
path and check equality, and if any input equals the resolved output, bail out
with a clear error (referencing self.output and input_paths/validate_file_exists
in the merge.rs loop).
- Around line 191-205: The code currently merges read-groups/programs from each
header without checking `@SQ` reference dictionaries; add validation so the first
header's reference sequence list is captured (e.g., save the first header's
reference dictionary from the Header returned by create_bam_reader) and for
every subsequent header compare its `@SQ` entries and order against that saved
dictionary, returning an error (or aborting the merge) if they differ; implement
this check inside the input_paths loop before mutating builder (around the
create_bam_reader(...) usage), and keep using the existing symbols
(create_bam_reader, header, builder, rg_ids, pg_ids) so you only proceed to
add_read_group/add_program when the reference dictionaries match.

In `@src/lib/sort/raw.rs`:
- Around line 698-700: The code currently calls create_output_header(header)
which rebuilds the `@HD` and drops fields from the merged header; instead preserve
the merged header's `@HD` by starting from header.header() (the result of
merge_headers) and only overwrite/update sort-order fields (e.g., SO) there
before using it as output_header; update the call site in
raw::open_bam_prefetch_readers / where open_bam_prefetch_readers and
create_output_header are used so you reuse header.header() as the base and only
modify the sort-order keys rather than reconstructing the entire `@HD`.
- Around line 762-775: The early return when initial_keys.is_empty() skips
creating the output BAM; move the call to crate::bam_io::create_raw_bam_writer
(the writer creation used for
output/output_header/self.threads/self.output_compression) to before the
empty-check (i.e., create writer before calling LoserTree::new or checking
initial_keys), then on the zero-record path log "Merge complete: 0 records
merged" and properly finish/close the writer (drop or call its finish/flush
method) before returning Ok(0) so an output BAM is always created even when
there are no records.
🪄 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: 784d6a6b-6ba5-43bc-87c3-4c26b21831dc

📥 Commits

Reviewing files that changed from the base of the PR and between 2d93cc2 and 260d306.

📒 Files selected for processing (7)
  • src/commands/merge.rs
  • src/commands/mod.rs
  • src/commands/sort.rs
  • src/lib/sort/loser_tree.rs
  • src/lib/sort/mod.rs
  • src/lib/sort/raw.rs
  • src/main.rs

Comment thread src/commands/merge.rs
Comment thread src/commands/merge.rs Outdated
Comment thread src/lib/sort/raw.rs
Comment thread src/lib/sort/raw.rs
@nh13
nh13 force-pushed the nh/feat-merge-command branch from 260d306 to da49849 Compare March 24, 2026 06:16
@nh13
nh13 temporarily deployed to github-actions March 24, 2026 06:16 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/feat-merge-command branch from da49849 to 94901ce Compare March 24, 2026 06:30
@nh13
nh13 temporarily deployed to github-actions March 24, 2026 06:31 — 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.

🧹 Nitpick comments (2)
src/commands/merge.rs (1)

182-184: Prefer returning an error over assert!.

Line 115 guards this, but merge_headers is a public-ish helper. Returning bail!("No input files") is more defensive.

Proposed fix
 fn merge_headers(input_paths: &[PathBuf]) -> Result<Header> {
-    assert!(!input_paths.is_empty());
+    if input_paths.is_empty() {
+        bail!("No input files to merge headers from");
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/merge.rs` around lines 182 - 184, The function merge_headers
currently uses assert!(!input_paths.is_empty()) which can panic; change it to
return an error instead (e.g., use anyhow::bail!("No input files") or return
Err(anyhow!("No input files"))) so callers receive a Result error rather than a
panic; update the merge_headers signature usage as needed and add the necessary
anyhow import (bail or anyhow) to ensure compile-time resolution.
src/lib/sort/loser_tree.rs (1)

176-181: is_empty() is unreachable for valid trees.

Constructor asserts k > 0 (line 56), so is_empty() always returns false. Consider removing or documenting this as a trait-compat placeholder.

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

In `@src/lib/sort/loser_tree.rs` around lines 176 - 181, The is_empty() method on
LoserTree is effectively unreachable because the constructor/new enforces k > 0;
either remove the is_empty(&self) -> bool method entirely (and update any call
sites) or keep it only as a trait-compatibility placeholder by changing its
implementation to return false unconditionally and adding a doc comment stating
“constructor guarantees k > 0, kept for trait compatibility” (refer to
is_empty() and the LoserTree constructor/new that asserts k > 0 to locate the
code).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/commands/merge.rs`:
- Around line 182-184: The function merge_headers currently uses
assert!(!input_paths.is_empty()) which can panic; change it to return an error
instead (e.g., use anyhow::bail!("No input files") or return Err(anyhow!("No
input files"))) so callers receive a Result error rather than a panic; update
the merge_headers signature usage as needed and add the necessary anyhow import
(bail or anyhow) to ensure compile-time resolution.

In `@src/lib/sort/loser_tree.rs`:
- Around line 176-181: The is_empty() method on LoserTree is effectively
unreachable because the constructor/new enforces k > 0; either remove the
is_empty(&self) -> bool method entirely (and update any call sites) or keep it
only as a trait-compatibility placeholder by changing its implementation to
return false unconditionally and adding a doc comment stating “constructor
guarantees k > 0, kept for trait compatibility” (refer to is_empty() and the
LoserTree constructor/new that asserts k > 0 to locate the code).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b1ad4c37-e13b-4e42-8d17-9dcc7e5fd7d4

📥 Commits

Reviewing files that changed from the base of the PR and between 260d306 and 94901ce.

📒 Files selected for processing (4)
  • src/commands/merge.rs
  • src/commands/sort.rs
  • src/lib/sort/loser_tree.rs
  • src/lib/sort/raw.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands/sort.rs

@nh13
nh13 force-pushed the nh/feat-merge-command branch from 94901ce to edf48c9 Compare March 24, 2026 06:39
@nh13
nh13 temporarily deployed to github-actions March 24, 2026 06:39 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/feat-merge-command branch from edf48c9 to 076eb77 Compare March 24, 2026 06:55
@nh13
nh13 temporarily deployed to github-actions March 24, 2026 06:55 — with GitHub Actions Inactive
@nh13

nh13 commented Mar 24, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 24, 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/feat-merge-command branch from 076eb77 to f19d327 Compare March 24, 2026 07:22
@nh13
nh13 temporarily deployed to github-actions March 24, 2026 07:22 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/feat-merge-command branch from f19d327 to 2b6dfdf Compare March 24, 2026 07:26
@nh13
nh13 temporarily deployed to github-actions March 24, 2026 07:26 — with GitHub Actions Inactive
Add `fgumi merge` subcommand for k-way merging of pre-sorted BAM files,
supporting coordinate, queryname, and template-coordinate sort orders.

Features:
- CLI follows fgumi conventions (-i, -o, --threads, --order)
- Merges headers from all inputs (read groups, program records)
- Validates reference dictionaries match across inputs
- Detects output aliasing input paths
- Accepts single input (acts as copy, matching samtools merge)
- Supports -b/--input-list for file-of-filenames input

Includes a loser tree (tournament tree) data structure for the merge,
providing log2(k) comparisons per element vs 2*log2(k) for a binary
heap. Based on the incremental insertion approach from Apache DataFusion.

Also fixes chunk file naming collision during sort consolidation by
replacing chunk_files.len() with a monotonic counter.
@nh13
nh13 force-pushed the nh/feat-merge-command branch from 2b6dfdf to c6567d0 Compare March 24, 2026 07:28
@nh13
nh13 temporarily deployed to github-actions March 24, 2026 07:28 — with GitHub Actions Inactive
@nh13
nh13 merged commit c3e1849 into main Mar 24, 2026
5 checks passed
@nh13
nh13 deleted the nh/feat-merge-command branch March 24, 2026 07:29
@nh13 nh13 mentioned this pull request Mar 24, 2026

This branch was previously deployed

1 inactive deployment
github-actions — c6567d08 Deployed Mar 24, 2026 by nh13 via coverage #719
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