Skip to content

fix(sort)!: enforce --max-temp-files on the arena spill path - #993

Merged
nh13 merged 1 commit into
mainfrom
991/nh/fix-sort-max-temp-files
Sep 29, 2026
Merged

nh13 merged 1 commit into
mainfrom
991/nh/fix-sort-max-temp-files

Conversation

@nh13

@nh13 nh13 commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Fixes #991.

What

Since fgumi sort moved onto the chain builder, --max-temp-files was resolved and logged but never enforced. SpillWrite opened a merge slot (an open file plus read-ahead) for every spilled run as it closed, and the final merge fanned in over all of them. A sort that spilled more runs than ulimit -n failed with Too many open files (os error 24).

This PR restores the bound on the arena spill path:

  • Runs stay closed until the merge. SpillWrite records closed runs as paths and opens their slots only at AllAnnounced, once the run count is bounded. The merge cannot start earlier anyway. The trade-off is that the merge starts on cold slots, without the per-run prefetch that used to happen during spilling.
  • Consolidation happens only at the limit. When the live-run count reaches the limit, RunStack merges the contiguous window of 2..=f runs (f = clamp(L/2, 2, 32)) that rewrites the fewest bytes per slot freed, and repeats until the count is back under the limit. Nothing is merged below the limit. For L <= 64, one pass just past the limit is the same single L/2-wide merge RawExternalSorter does. When the limit is hit repeatedly, it rewrites several times less than the oldest-half policy (simulated: 0.95x vs 10.3x the input at L=64 with 700 runs).
  • Cooperative merge kernel. fgumi_sort::run_consolidate is built on the arena spill kernels and shares the final merge's record-framing helpers. SpillWrite drives it a bounded batch per try_run, capped by both records and bytes, so the detached writer never blocks and the stall monitor keeps seeing progress. While a merge is in flight, no input is taken, so upstream back-pressures.
  • Key kind travels with the data. SpillBlockEvent::Block carries a SpillKeyKind, because the template-coordinate key width is only chosen at runtime.
  • Fail closed. SpillWrite errors if input drains with runs never announced, or if anything arrives after AllAnnounced.
  • Observability. The summary reports Consolidations: N (Xs) and Merge sources: M beside Spill runs:, and each merge logs Consolidating N spill runs (...). The phase-timing roll-up has a real consolidation bucket and no longer labels merge read-ahead as consolidation. SortStats::runs_written still means runs written.

Correctness

Only contiguous runs are merged, and the merged run takes the range's lowest file_id, so the final merge's tie-break (run order) is unchanged and output is byte-identical to an unbounded sort. New tests compare decompressed records, @PG aside, against an unbounded reference across:

  • all four orders × limits 2/4/8 × 1 and 4 threads;
  • every template-coordinate key lane (--key-types none|cb|mi|full);
  • a fixture heavy in ties, including unmapped records;
  • a fused runall sort -> group chain, where SpillWrite is pool-scheduled.

Unit tests cover the merge kernel (stable tie order, both codecs, truncation, the byte budget), the policy (live count < L after every push, run order preserved, no merge below the limit, legacy-equivalent single pass, rewrite volume vs the legacy policy), and the new fail-closed paths.

The issue's failure mode is pinned directly. Under ulimit -n 32, a sort that spills more than twice as many runs as the limit now completes with identical output. Before the fix, the same sort under a low ulimit -n failed with EMFILE in SpillWrite.

Breaking

In fgumi-pipeline-io:

  • SpillBlockEvent::Block gains a required key_kind field.
  • SpillWrite emits SpillReady for its surviving runs at AllAnnounced (with slot_count set to their number) rather than as each run closes.
  • RunMergerDyn::step takes a byte budget.

Risk

  • Output: none for any order. Pinned by the byte-identity tests above.
  • unsafe: none added.
  • Memory / backpressure:
    • Open spill files are now bounded by the limit rather than the run count.
    • Phase-2 read-ahead is bounded the same way, though still not charged against --max-memory.
    • Consolidation back-pressures Phase 1 while it runs.
    • Consolidation output is compressed single-threaded inside SpillWrite. Sorts that consolidate repeatedly will spend time there; measuring it on the spill-consolidation benchmarks is the next step.

Risk verdict: Output changes: none intended; integration tests compare bounded and unbounded sort records, including tied-key order. Unsafe: none added; no CLAUDE.md allowlist update is indicated. Memory/backpressure: changed; the live-run cap bounds merge fan-in, and consolidation runs in bounded cooperative steps.

Fix: Enforce --max-temp-files on the arena spill path by consolidating adjacent runs before final merge.

Spill runs stay closed until announced. Consolidation preserves run order and fails closed on incomplete or late spill input. Sort summaries now report consolidations and merge sources.

The fgumi-pipeline-io API changes include a required key_kind on SpillBlockEvent::Block and changed SpillReady timing. RunMergerDyn::step now accepts record and byte budgets.

Tests were added for record preservation, consolidation, multiple sort key types, fused runall, and sorting under a low file-descriptor limit. Test execution results were not provided.

@nh13
nh13 deployed to github-actions September 28, 2026 05:17 — with GitHub Actions Active
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !**/CHANGELOG.md
⚙️ Run configuration

Configuration used: Repository: fulcrumgenomics/fgumi/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 1fd25809-42d3-4234-b852-2592ea5255c7

📥 Commits

Reviewing files that changed from the base of the PR and between b524294 and c41ae4f.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !**/CHANGELOG.md

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: fulcrumgenomics/fgumi/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ead7932a-f0dd-4283-88f6-a1af181cee81

📥 Commits

Reviewing files that changed from the base of the PR and between 1896e97 and b524294.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !**/CHANGELOG.md
📒 Files selected for processing (22)
  • crates/fgumi-pipeline-io/src/sort/compress_spill.rs
  • crates/fgumi-pipeline-io/src/sort/merge.rs
  • crates/fgumi-pipeline-io/src/sort/mod.rs
  • crates/fgumi-pipeline-io/src/sort/protocol.rs
  • crates/fgumi-pipeline-io/src/sort/run_stack.rs
  • crates/fgumi-pipeline-io/src/sort/run_stack/tests.rs
  • crates/fgumi-pipeline-io/src/sort/spill_block_compress.rs
  • crates/fgumi-pipeline-io/src/sort/spill_block_compress/tests.rs
  • crates/fgumi-pipeline-io/src/sort/spill_gather.rs
  • crates/fgumi-pipeline-io/src/sort/spill_write.rs
  • crates/fgumi-pipeline-io/src/sort/spill_write/tests.rs
  • crates/fgumi-sort/src/external.rs
  • crates/fgumi-sort/src/lib.rs
  • crates/fgumi-sort/src/run_consolidate.rs
  • crates/fgumi-sort/src/run_consolidate/tests.rs
  • docs/src/guide/performance-tuning.md
  • src/lib/commands/sort.rs
  • src/lib/pipeline/chains/builder.rs
  • src/lib/pipeline/chains/commands/sort.rs
  • src/lib/pipeline/steps/sort/mod.rs
  • tests/integration/main.rs
  • tests/integration/test_sort_max_temp_files.rs

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


Walkthrough

The sort pipeline now consolidates spill runs when the configured live-run limit is reached. It carries key-kind metadata through spill events, reports consolidation and merge-source statistics, and includes tests for bounded runs and unchanged sorted output.

Changes

Sort spill consolidation

Layer / File(s) Summary
Key-aware spill merging
crates/fgumi-pipeline-io/src/sort/protocol.rs, crates/fgumi-pipeline-io/src/sort/spill_gather.rs, crates/fgumi-pipeline-io/src/sort/spill_block_compress.rs, crates/fgumi-sort/src/run_consolidate.rs, crates/fgumi-sort/src/run_consolidate/tests.rs, crates/fgumi-sort/src/external.rs, crates/fgumi-sort/src/lib.rs
Spill block events carry a key kind. The new merger reads and merges spill runs incrementally, preserving stable input order for equal keys and supporting the configured output codec.
Run selection policy
crates/fgumi-pipeline-io/src/sort/run_stack.rs, crates/fgumi-pipeline-io/src/sort/run_stack/tests.rs
RunStack selects contiguous runs for consolidation using bytes per eliminated run slot, with fan-in limits and defined tie-breaking.
SpillWrite consolidation and announcements
crates/fgumi-pipeline-io/src/sort/spill_write.rs, crates/fgumi-pipeline-io/src/sort/spill_write/tests.rs, crates/fgumi-pipeline-io/src/sort/mod.rs, crates/fgumi-pipeline-io/src/sort/compress_spill.rs
SpillWrite consolidates closed runs when configured, then announces the surviving runs after AllAnnounced. Tests cover event ordering, run limits, key-type validation, and record preservation.
Sort wiring, reporting, and validation
crates/fgumi-pipeline-io/src/sort/merge.rs, src/lib/pipeline/chains/builder.rs, src/lib/pipeline/chains/commands/sort.rs, src/lib/commands/sort.rs, docs/src/guide/performance-tuning.md, tests/integration/*, src/lib/pipeline/steps/sort/mod.rs
Standalone sort passes the run limit and shared statistics to the spill writer and final merge. Summaries and phase timings report consolidation and merge sources. Integration tests compare bounded and unbounded sort output, including under a file-descriptor limit.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant SpillGather
  participant SpillWrite
  participant RunMergerDyn
  participant SortMerge
  SpillGather->>SpillWrite: Send spill blocks with key kind
  SpillWrite->>RunMergerDyn: Consolidate selected runs
  RunMergerDyn-->>SpillWrite: Return merge progress
  SpillWrite->>SortMerge: Announce surviving runs
Loading

Suggested labels: fgumi sort

Merge Risk: ⚪ Minimal · up to b5242

The reported temporary-file durability concern does not block merging. Normal checks can proceed.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commit format, uses the valid fix type and sort scope, includes the breaking-change marker, and accurately describes enforcing --max-temp-files on the arena spill …
Linked Issues check ✅ Passed PR #993 satisfies the coding requirements in [#991]. SpillWrite applies the configured --max-temp-files limit through RunStack, consolidates spill runs, and keeps only surviving runs for the fin…
Out of Scope Changes check ✅ Passed The changes remain within [#991]'s implementation scope. RunStack, cooperative merge support, spill-event key metadata, fail-closed input handling, statistics, reporting, documentation, and API upda…

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

@nh13

nh13 commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai pause

@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.96486% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.27%. Comparing base (c88703f) to head (c41ae4f).

Files with missing lines Patch % Lines
crates/fgumi-pipeline-io/src/sort/spill_write.rs 93.06% 14 Missing ⚠️
crates/fgumi-sort/src/run_consolidate.rs 97.53% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #993      +/-   ##
==========================================
+ Coverage   96.26%   96.27%   +0.01%     
==========================================
  Files         294      296       +2     
  Lines      148266   148827     +561     
==========================================
+ Hits       142725   143283     +558     
- Misses       5541     5544       +3     

☔ 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 Sep 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 28, 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 added this pull request to the merge queue Sep 29, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 29, 2026
Since the sort command moved onto the chain builder and the legacy engine
was removed from it, --max-temp-files was resolved and logged but never
enforced: SpillWrite opened a merge slot for every spilled run as it
closed, so a sort held one descriptor and a slot's read-ahead per run
until the end and merged all runs in one pass. A sort spilling more runs
than `ulimit -n` failed with EMFILE (#991).

- SpillWrite keeps closed runs as paths and opens slots only at
  AllAnnounced, after the run count is bounded. It fails closed if input
  drains with runs never announced, or if anything arrives after
  AllAnnounced, so a truncated upstream cannot finish "successfully"
  without those runs' records.
- When the live-run count reaches the limit, a contiguous window of
  runs is merged into one, chosen to minimise bytes rewritten per slot
  freed. Nothing is merged below the limit, and one pass just past it
  (for L <= 64) matches RawExternalSorter's single L/2-wide merge.
- The merge (fgumi_sort::run_consolidate) uses the arena spill kernels
  and shares the final merge's record-framing helpers. It runs
  cooperatively, bounded per try_run by both records and bytes, so the
  detached writer never blocks and the stall monitor keeps seeing
  progress even on long reads.
- Spill blocks carry their SpillKeyKind, since the template-coordinate
  key width is only chosen at runtime.
- The sort summary reports consolidations and merge sources beside the
  runs written; SortStats::runs_written keeps meaning runs written. The
  phase-timing roll-up reports consolidation time and no longer labels
  merge read-ahead as consolidation.

Merging only contiguous runs, placed at their range's position, keeps
the final merge's tie-break order, so output is byte-identical to an
unbounded sort: tested across all four orders, limits 2/4/8, 1 and 4
threads, every template-coordinate key lane, unmapped records, and a
fused runall sort -> group chain. A sort spilling several times more
runs than `ulimit -n` now completes with identical output.

BREAKING CHANGE: fgumi-pipeline-io's SpillBlockEvent::Block gains a
required key_kind field, and SpillWrite now emits SpillReady for its
surviving runs at AllAnnounced (with slot_count set to their number)
instead of as each run closes. RunMergerDyn::step takes a byte budget.
@nh13
nh13 force-pushed the 991/nh/fix-sort-max-temp-files branch from b524294 to c41ae4f Compare September 29, 2026 07:09
@nh13
nh13 deployed to github-actions September 29, 2026 07:09 — with GitHub Actions Active
@nh13

nh13 commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

No files to review.

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 added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit ba88abc Sep 29, 2026
17 checks passed
@nh13
nh13 deleted the 991/nh/fix-sort-max-temp-files branch September 29, 2026 16:49

This branch was successfully deployed

1 active deployment
github-actions — c41ae4fe Deployed Sep 29, 2026 by nh13 via coverage #4671
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.

fix(sort): --max-temp-files is not enforced on the chain sort path (no spill consolidation)

1 participant