Skip to content

Pace and count the mapper's writes, not just the sort's - #52

Merged
JamesKane merged 2 commits into
mainfrom
fix/pace-the-mapper
Aug 16, 2026
Merged

Pace and count the mapper's writes, not just the sort's#52
JamesKane merged 2 commits into
mainfrom
fix/pace-the-mapper

Conversation

@JamesKane

Copy link
Copy Markdown
Owner

The gap

The write pacing and byte accounting that came out of the 2026-08-13 WindowServer teardown shipped as a module inside navigator-analysis. That crate holds the revert, sort, markdup and CRAM stages — but not the mapper, which lives in navigator-align, a leaf crate that cannot depend on it and should not.

So half the pipeline was covered. Unpaced and uncounted:

Writer Size on a 30x WGS
mapped.bam ~60 GB
minimizer index (.mmi) 8.93 GB
final CRAM tens of GB

The phase-5 run log reads 0 MB/s straight through the longest stage in the job for exactly that reason — not a quiet stage, an unmeasured one. And pacing the sort while the mapper wrote unpaced left the original failure mode reachable, because the resource notice macOS filed was against the process, not against a stage. Fixing half the writers was closer to no fix than it looked.

The change

navigator-resource, a new leaf crate holding PacedFile, the process-wide byte counter, and ResourceWatch, depended on by both halves of the pipeline. It has to be a crate rather than a module: a counter only means something if there is exactly one of it. navigator-app takes it directly rather than reaching through navigator-analysis, since neither half is the owner. The move itself is a git mv — 89% similarity, doc changes only, no logic.

Both CRAM encoders were taking build_from_path, which opens the file themselves; build_from_writer makes the writer ours to wrap.

Two things from reading the same write path

  • Buffers. Every one of those writers sat behind BufWriter's 8 KB default while the encoders above hand down 64 KB BGZF blocks and multi-MB CRAM containers, so the buffer was coalescing nothing. Now 1 MB, matching bamio.
  • Sync at the end of each stage output. This matters most for mapped.bam: its BGZF end-of-file block is precisely what a resumed run reads to decide the file can be trusted, and trusting a marker that was still only a page-cache promise is how 59 GB was destroyed during phase 5. Also the .mmi, whose atomic rename otherwise publishes contents the disk has not acknowledged.

Test

navigator-align carries the regression test — write a BAM through AlignmentWriter, assert the shared counter moved. Greater-than rather than an exact figure, since the counter is shared with anything else in the test binary; the claim under test is that these bytes are counted at all. It would have failed before this change.

984 pass, 0 fail (983 + the new one). Clippy clean under -D warnings, fmt clean.

Deliberately not done

  • finalize.rs's .bai is left unpaced — a few MB, where the syscalls and log noise would buy nothing.
  • Writers outside the realignment pipeline were not audited.
  • NAVIGATOR_SORT_MB still defaults to 512 MB (688 spill runs on a 128 GB machine).

🤖 Generated with Claude Code

JamesKane and others added 2 commits August 16, 2026 04:36
…n their own items

A four-angle review of the realignment surface, prompted by two bugs that both
traced to the same rule existing in several copies.

**The duplication that caused those bugs was still there.** `DEFAULT_TARGET_BUILD`
was exported so the UI could name the build it offers, and then only the Simple
mode modal used it: both Advanced cards still carried the literal, and one
compared builds with `eq_ignore_ascii_case` where the job uses `builds_match`,
which trims. A stored " chm13v2.0" was offered by the card and refused by the
job. `is_target_build` is now `pub` and the UI asks it instead of spelling out a
comparison. The per-alignment card was also missing the `bam_path` condition, so
it offered to re-map rows with no file.

**Four doc comments documented the wrong item** — a new item inserted between an
existing doc block and its function, four times, twice on public API. The worst
had one sentence severed across two items and its tail orphaned twenty lines
below.

**Measured wins, benchmarked rather than guessed.** The revert's FASTQ writer
issued seven `write_all` calls per read into the gzip encoder, paying the
encoder's per-call overhead seven times: 1,882 ns/record against 539 for the same
bytes assembled once, ~13 minutes of CPU per realignment. `realignable_in_project`
was an N+1 over members where the grouped query already existed: 2.7 ms against
17.7 ms on a 2,504-member project, twice per batch.

**And one bug, fixed at its own altitude.** Simple mode's running card matched on
alignment id, and once the offer was gone it had nothing to match against — so it
claimed *any* running job, and a page open on one person announced another
person's realignment as theirs. `RealignState` now carries the owning subject,
resolved once per job by `App::subject_of_alignment`, and the card matches on
that. Tightening the old predicate would have broken the Done card instead; the
ownership question needed answering where the mapping lives.

Also: `preflight`/`resume_preflight` were the same function twice, `discard` had
two implementations, the project card cloned a Vec per frame to read its length,
finished states carried progress fields nobody reads, and a user-visible string
held an 18-space run.

983 tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The write pacing and byte accounting that came out of the 2026-08-13
WindowServer teardown shipped as a module inside navigator-analysis. That
crate holds the revert, sort, markdup and CRAM stages — but not the mapper,
which lives in navigator-align, a leaf crate that cannot depend on it and
should not.

So half the pipeline was covered. The mapper's ~60 GB mapped.bam, the 8.93 GB
minimizer index, and the final CRAM all went straight to the page cache,
unpaced and uncounted. The phase-5 run log reads 0 MB/s through the longest
stage in the job for exactly that reason: not a quiet stage, an unmeasured
one. And pacing the sort while the mapper wrote unpaced left the original
failure mode reachable, because the resource notice macOS filed was against
the process, not against a stage.

Move PacedFile, the byte counter and ResourceWatch into navigator-resource, a
leaf crate both halves depend on. A counter only means something if there is
exactly one of it. navigator-app takes it directly rather than reaching
through navigator-analysis, since neither half is the owner.

Also, from reading the same write path:

- Every one of those writers sat behind BufWriter's 8 KB default while the
  encoders above hand down 64 KB BGZF blocks and multi-MB CRAM containers, so
  the buffer coalesced nothing. Now 1 MB, matching bamio.
- Each stage output is synced once at the end. This matters most for
  mapped.bam, whose BGZF end-of-file block is what a resumed run reads to
  decide the file can be trusted — trusting a marker that was still a
  page-cache promise is how 59 GB was destroyed during phase 5 — and for the
  .mmi, whose atomic rename otherwise publishes contents the disk has not
  acknowledged.
- The CRAM encoders took build_from_path, which opens the file themselves.
  build_from_writer, so the writer is ours to wrap.

navigator-align carries the regression test: write a BAM through
AlignmentWriter, assert the shared counter moved. It would have failed before
this change.

finalize.rs's .bai is left unpaced on purpose — a few MB, where the syscalls
and the log noise would buy nothing.

984 tests pass (983 + the new one); clippy and fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JamesKane
JamesKane merged commit 37a9b19 into main Aug 16, 2026
3 checks passed
@JamesKane
JamesKane deleted the fix/pace-the-mapper branch August 16, 2026 13:16
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