Skip to content

refactor(sort): pick the compressor from the job, not the pipeline phase - #658

Merged
nh13 merged 1 commit into
mainfrom
nh/compress-target-per-job
Jul 26, 2026
Merged

nh13 merged 1 commit into
mainfrom
nh/compress-target-per-job

Conversation

@nh13

@nh13 nh13 commented Jul 25, 2026 •

Copy link
Copy Markdown
Member

Follow-on to #633 and #653, which each fixed one symptom of the same root cause. This fixes the cause.

The coupling

One compress_queue carries both spill and output blocks, and CompressJob recorded only the codec (BGZF vs zstd) — never which of the two kinds it was. So a worker popping a job re-derived the compression level from shared.phase, a mutable atomic that advances independently of what is already sitting in the queue. Every block that outlived a phase transition was compressed at the wrong level, silently.

That one coupling produced the same bug twice, in opposite directions:

  • Output blocks popped outside Phase 2 went through the spill compressor at temp_compression, so --compression-level was silently discarded. Fixed twice at the symptom layer: by entering Phase 2 even for an in-memory sort (fix(sort): honour --compression-level when the sort fits in memory #633), then by keeping the pool in Phase 2 across the writer's finish() (fix(sort): honour --compression-level for the tail of a spilling sort #653).
  • Phase 1 spill blocks still queued when begin_phase2 fired went through the output compressor at output_compression. This was never a live bug — it was held off only by drain_pending_spill happening to sit immediately before enter_output_phase, and was written down as a caller obligation on begin_phase2 rather than enforced. Its cost is wasted CPU and disk, not wrong bytes, since any BGZF level decodes.

The fix

The identity was already known for certain at submit time, by the one caller who cannot be wrong about it: there is a single production submit site, StagingBuffer::flush, and its two constructors are statically distinct — the output writer and the spill writer. So record it rather than infer it.

CompressJob gains a target: CompressTarget { Spill, Output } beside the existing codec; StagingBuffer carries it and stamps every job it submits; try_compress selects compressor or output_compressor from job.target. Nothing about the choice reads shared mutable state, so a block is compressed at the level its writer asked for however long it waited in the queue and whatever phase the pool is in when a worker gets to it.

This is the second form of that selection. 5df7e176, which introduced the worker pool, read it from shared.phase at pop time and then narrowed it to the dispatched SortStep to close the window where set_phase fired between the pop and the choice. But a step is itself chosen from the phase, so blocks that outlived a transition were still mis-compressed — the window was narrowed, not closed.

With the level travelling on the job, SortStep::CompressSpill and CompressOutput did the same thing, so they collapse into one Compress. There was only ever one queue; two steps implied two, and the per-step stats buckets they fed (CmpSpl / CmpOut) could not be trusted to mean what they said. SortStep::COUNT drops from 5 to 4. SortStep is pub but worker_pool is pub(crate), so this is not an API change.

Also drops begin_phase2's caller obligation, which no longer exists, and pins zstd's spill-only invariant now that it is expressible: there is one zstd compressor per worker, fixed at temp_compression, because the output BAM is always BGZF. That one asserts unconditionally rather than in debug only — an Output job reaching it would be the same silent wrong-level output this mechanism exists to prevent, and the check is one compare inside the zstd arm, which the BGZF path never evaluates.

The ordering #653 introduced — release the drained sources, finalize, then leave Phase 2 — is kept, but for the reason that survives: not holding the merge's file descriptors and 2 MiB-per-chunk reorder buffers across a slow finish. Its compression rationale is gone, and its docs and tests say so.

Testing

Reverting #653's ordering now leaves test_temp_compression_does_not_reach_the_output_bam passing — that is the check that this fixes the cause rather than adding a third symptom fix.

test_compress_target_decides_level_regardless_of_phase sweeps the phase across LEGACY / PHASE1 / PHASE2 and asserts a Spill job stays stored at temp_compression = 0 while an Output job compresses at output_compression = 9. All three cases fail if the compressor is selected from the phase, including the mirror direction that had no coverage before.

cargo ci-fmt, cargo ci-lint, cargo ci-doc (with -D warnings, since this adds intra-doc links), and cargo ci-test (6597 tests) all pass.

Performance

No measurable cost. The job grows by one byte in a struct that already owns a Vec<u8>, and the per-block work is one match on a Copy enum replacing one match on the step. Scheduling is untouched: the two collapsed steps shared an eligibility predicate, and neither was ever exclusive (only ReadInputBlocks is).

Measured on a spilling coordinate sort of a 3,689,310-record BAM (idt-cfdna, 162 MB, 4 spill chunks, 8 threads, -m 256m, output level 6, temp level 1), 10 interleaved reps per binary, baseline being #653's merge commit ef0f6f8b:

metric baseline this branch delta
CPU (user+sys) 11.497 s ± 0.312 11.326 s ± 0.462 −1.5%
wall 5.663 s ± 0.941 5.910 s ± 1.044 +4.4%

Welch t on CPU time is −0.97, so the difference is not significant. Wall clock on this host is too noisy (σ ≈ 1 s) to resolve anything under ~15%, which is why CPU time is the reported metric. All 20 runs exited 0 and wrote exactly 3,689,310 records. The two binaries produce equivalent output: the sorted BAMs differ only in the @PG CL: field, which records the binary's path, and the record streams have identical checksums.

Reading order

crates/fgumi-sort/src/worker_pool.rs is the change — CompressTarget, the CompressJob field, SortStep, and try_compress. Everything else follows from it: bgzf_io.rs threads the field, the two pooled writers each stamp their constant, and external.rs plus the integration test are documentation that had asserted the old coupling.

Summary by CodeRabbit

  • Bug Fixes

    • Ensured temporary spill data and final BAM output are compressed using the correct, phase-independent settings.
    • Prevented compression behavior from changing incorrectly during worker-pool phase transitions.
    • Improved correctness during both disk-spill and in-memory-only processing.
  • Tests

    • Added/updated coverage verifying compression targets stay consistent across legacy and phased execution.
    • Updated lifecycle and output-compression test messaging to reflect the refined teardown/output invariants.
  • Documentation

    • Refreshed comments describing phase transition and compression-selection behavior.

@nh13
nh13 temporarily deployed to github-actions July 25, 2026 19:07 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ff729c6e-87cb-434c-98a3-46fe6c5e7888

📥 Commits

Reviewing files that changed from the base of the PR and between 05c0001 and 72d0e2b.

📒 Files selected for processing (6)
  • crates/fgumi-sort/src/bgzf_io.rs
  • crates/fgumi-sort/src/external.rs
  • crates/fgumi-sort/src/pooled_bam_writer.rs
  • crates/fgumi-sort/src/pooled_chunk_writer.rs
  • crates/fgumi-sort/src/worker_pool.rs
  • crates/fgumi-sort/tests/integration/test_output_compression.rs

Walkthrough

BGZF compression selection now comes from each queued job’s CompressTarget instead of pool phase. The worker pool uses one compression step, staging and writers assign spill/output targets, and validation covers phase-independent routing and lifecycle ordering.

Changes

Compression routing

Layer / File(s) Summary
Job target and compressor selection
crates/fgumi-sort/src/worker_pool.rs
CompressJob carries CompressTarget, and try_compress selects compression from that field.
Unified compression scheduling
crates/fgumi-sort/src/worker_pool.rs
Separate spill/output steps are replaced by SortStep::Compress across eligibility, dispatch, priorities, statistics, and tests.
Staging and writer target propagation
crates/fgumi-sort/src/bgzf_io.rs, crates/fgumi-sort/src/pooled_*_writer.rs
StagingBuffer forwards targets to jobs; chunk writers use Spill and BAM writers use Output.
Compression and lifecycle validation
crates/fgumi-sort/src/worker_pool.rs, crates/fgumi-sort/src/external.rs, crates/fgumi-sort/tests/integration/test_output_compression.rs
Tests and explanatory comments cover target-based compression, phase transitions, and teardown ordering.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PooledWriter
  participant StagingBuffer
  participant SortWorkerPool
  participant BGZFCompressor
  PooledWriter->>StagingBuffer: assign Spill or Output target
  StagingBuffer->>SortWorkerPool: enqueue CompressJob
  SortWorkerPool->>BGZFCompressor: compress using job.target
Loading

Possibly related PRs

Suggested reviewers: tfenne

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the core refactor: compression is chosen from each job instead of pipeline phase state.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nh/compress-target-per-job

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

@codecov

codecov Bot commented Jul 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.91%. Comparing base (e10785b) to head (72d0e2b).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #658      +/-   ##
==========================================
+ Coverage   93.80%   93.91%   +0.11%     
==========================================
  Files         177      177              
  Lines      107658   107779     +121     
==========================================
+ Hits       100990   101224     +234     
+ Misses       6668     6555     -113     

☔ View full report in Codecov by Harness.
📢 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.

@nh13

nh13 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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.

@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: 1

🤖 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-sort/src/worker_pool.rs`:
- Around line 2200-2214: Add a test near the worker-pool compression tests that
constructs a SortWorkerPool with Zstd, submits a CompressJob using
SpillCodec::Zstd and CompressTarget::Output through submit_compress, and asserts
the worker panics with the existing “zstd is spill-only” message. Ensure the
test reliably allows the worker to process the job while preserving the
unconditional invariant in the Zstd arm.
🪄 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: 57e2bbdb-724e-436a-a51a-5992937d345e

📥 Commits

Reviewing files that changed from the base of the PR and between ef0f6f8 and 05c0001.

📒 Files selected for processing (6)
  • crates/fgumi-sort/src/bgzf_io.rs
  • crates/fgumi-sort/src/external.rs
  • crates/fgumi-sort/src/pooled_bam_writer.rs
  • crates/fgumi-sort/src/pooled_chunk_writer.rs
  • crates/fgumi-sort/src/worker_pool.rs
  • crates/fgumi-sort/tests/integration/test_output_compression.rs

Comment thread crates/fgumi-sort/src/worker_pool.rs
@nh13
nh13 force-pushed the nh/compress-target-per-job branch from 05c0001 to 1d38610 Compare July 26, 2026 16:41
@nh13
nh13 temporarily deployed to github-actions July 26, 2026 16:41 — with GitHub Actions Inactive
@nh13

nh13 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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.

One `compress_queue` carries both spill and output blocks, and `CompressJob`
recorded only the *codec* (BGZF vs zstd), never which of the two it was. So a
worker popping a job re-derived the compression level from `shared.phase`, a
mutable atomic that advances independently of what is already sitting in the
queue. Every block that outlived a phase transition was compressed at the wrong
level, silently.

That single coupling produced the same bug twice, in opposite directions:

  - Output blocks popped outside Phase 2 went through the spill compressor at
    `temp_compression`, so `--compression-level` was silently discarded. Fixed
    twice at the symptom layer — by entering Phase 2 even for an in-memory sort
    (#633), then by keeping the pool in Phase 2 across the writer's `finish()`
    (#653).
  - Phase 1 spill blocks still queued when `begin_phase2` fired went through the
    output compressor at `output_compression`. This one was never a live bug: it
    was held off only by `drain_pending_spill` happening to sit immediately
    before `enter_output_phase`, and was written down as a caller obligation on
    `begin_phase2` rather than enforced. Its cost is wasted CPU and disk, not
    wrong bytes, since any BGZF level decodes.

The identity was already known for certain at submit time, by the one caller who
cannot be wrong about it: there is a single production submit site,
`StagingBuffer::flush`, and its two constructors are statically distinct — the
output writer and the spill writer. So record it rather than infer it.

`CompressJob` gains a `target: CompressTarget { Spill, Output }` beside the
existing `codec`; `StagingBuffer` carries it and stamps every job it submits;
`try_compress` selects `compressor` or `output_compressor` from `job.target`.
Nothing about the choice reads shared mutable state, so a block is compressed at
the level its writer asked for however long it waited in the queue and whatever
phase the pool is in when a worker gets to it.

This is the second form of that selection. `5df7e176`, which introduced the
worker pool, read it from `shared.phase` at pop time and then narrowed it to the
dispatched `SortStep` to close the window where `set_phase` fired between the pop
and the choice. But a step is itself chosen from the phase, so blocks that
outlived a transition were still mis-compressed — the window was narrowed, not
closed.

With the level travelling on the job, `SortStep::CompressSpill` and
`CompressOutput` did the same thing, so they collapse into one `Compress`. There
was only ever one queue; two steps implied two, and the per-step stats buckets
they fed ("CmpSpl" / "CmpOut") could not be trusted to mean what they said.
`SortStep::COUNT` drops from 5 to 4. `SortStep` is `pub` but `worker_pool` is
`pub(crate)`, so this is not an API change.

Also drops `begin_phase2`'s caller obligation, which no longer exists, and pins
zstd's spill-only invariant now that it is expressible: there is one zstd
compressor per worker, fixed at `temp_compression`, because the output BAM is
always BGZF. That one asserts unconditionally rather than in debug only — an
`Output` job reaching it would be the same silent wrong-level output this
mechanism exists to prevent, and the check is one compare inside the zstd arm,
which the BGZF path never evaluates.

The ordering #653 introduced — release the drained sources, finalize, then
leave Phase 2 — is kept, but for the reason that survives: not holding the
merge's file descriptors and 2 MiB-per-chunk reorder buffers across a slow
`finish`. Its compression rationale is gone, and its docs and tests say so.
Reverting that ordering now leaves `test_temp_compression_does_not_reach_the_
output_bam` passing, which is the check that this commit fixes the cause rather
than a third symptom.

`test_compress_target_decides_level_regardless_of_phase` sweeps the phase across
`LEGACY`/`PHASE1`/`PHASE2` and asserts a `Spill` job stays stored at
`temp_compression = 0` while an `Output` job compresses at `output_compression =
9`. All three cases fail if the compressor is selected from the phase, including
the mirror direction that had no coverage before.

No measurable throughput cost. The job grows by one byte in a struct that
already owns a `Vec<u8>`, and the per-block work is one `match` on a `Copy` enum
replacing one `match` on the step; scheduling is untouched, since the two
collapsed steps shared an eligibility predicate and neither was ever exclusive
(only `ReadInputBlocks` is).

Measured on a spilling coordinate sort of a 3,689,310-record BAM (idt-cfdna,
162 MB, 4 spill chunks, 8 threads, `-m 256m`, output level 6, temp level 1),
10 interleaved reps per binary, baseline being #653's merge commit (`ef0f6f8b`,
identical content in these files):

    CPU (user+sys)  baseline 11.497s ± 0.312   candidate 11.326s ± 0.462   -1.5%
    wall            baseline  5.663s ± 0.941   candidate  5.910s ± 1.044   +4.4%

Welch t on CPU time is -0.97, so the difference is not significant; wall clock
on this host is too noisy (σ ≈ 1 s) to resolve anything under ~15%, which is why
CPU time is the reported metric. All 20 runs exited 0 and wrote exactly
3,689,310 records. Byte-for-byte, the two binaries produce the same output: the
sorted BAMs differ only in the `@PG` `CL:` field (it records the binary's path),
and the record streams have identical checksums.
@nh13
nh13 force-pushed the nh/compress-target-per-job branch from 1d38610 to 72d0e2b Compare July 26, 2026 18:17
@nh13
nh13 temporarily deployed to github-actions July 26, 2026 18:17 — with GitHub Actions Inactive
@nh13

nh13 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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 merged commit a5dfbe9 into main Jul 26, 2026
14 checks passed
@nh13
nh13 deleted the nh/compress-target-per-job branch July 26, 2026 23:27
@nh13 nh13 mentioned this pull request Jul 26, 2026

This branch was previously deployed

1 inactive deployment
github-actions — 72d0e2b4 Deployed Jul 26, 2026 by nh13 via coverage #3114
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