Skip to content

Merge BLAST chunking, fix temp-file leak and summary-CSV bug, and improved rule resources - #12

Merged
SchistoDan merged 12 commits into
mainfrom
claude/beegees-blast-chunking-3x246z
Aug 21, 2026
Merged

Merge BLAST chunking, fix temp-file leak and summary-CSV bug, and improved rule resources#12
SchistoDan merged 12 commits into
mainfrom
claude/beegees-blast-chunking-3x246z

Conversation

@SchistoDan

Copy link
Copy Markdown
Collaborator

Summary

  • tv_local_blast.py ran one blastn process per barcode, reloading the reference database every time. Queries are now batched 50 per invocation, with up to threads chunks running concurrently. On a 159-barcode test run this went from 159 invocations to 4.
  • Fixes two pre-existing scaling bugs found in the same code path
  • Audited all 21 entries in rules: resource block
  • Added dyanmic Java heap memory scaling (set to 80%) of requested mem_mb for downsampling rules.
  • Updated README.md accordingly.
  • Updated pytests accordingly.

claude and others added 12 commits August 19, 2026 08:53
tv_local_blast.py ran one blastn process per query sequence, so a large
reference database was loaded and discarded once per barcode. For a 500-barcode
run against BOLDistilled that fixed startup cost dominates the search itself.

Queries are now batched into chunks of CHUNK_SIZE (50) sequences per blastn
invocation, with up to --processes chunks running concurrently; the final chunk
holds the remainder. A 120-sequence run drops from 120 invocations to 3.

Chunks are written with synthetic query IDs (>q1, >q2, ...) so results
demultiplex back to their source sequence by exact lookup rather than by
re-deriving the name from BLAST's qseqid, which is only the first whitespace
token of the header. The real query ID is restored in column 0 before each
per-sequence TSV is written, so output is unchanged. Verified byte-for-byte
identical per-sequence TSVs and summary CSV against the previous
implementation over 120 sequences, including sort stability across
equal-percent-identity ties (which downstream taxonomy selection depends on).

Other changes in the same path:

- Resume moves out of the worker into the input scan, so finished sequences no
  longer cost a temp file and a pool task. Per-sequence TSVs are written via a
  .part file and renamed, so an interrupted run cannot leave a truncated file
  that a later run treats as complete.
- ThreadPoolExecutor replaces ProcessPoolExecutor. The work is entirely
  subprocess-bound, and this removes the per-task pickling of the growing
  processed_sequences dict. That dict is now populated in the main thread; the
  previous in-worker mutation was silently discarded in the child process.
- A failed chunk retries its sequences individually so one bad record cannot
  lose its 49 neighbours. Sequences that still fail get no output file and cause
  a non-zero exit with no summary CSV, rather than being handed to
  tv_blast2taxonomy as a genuine no-match. Successful TSVs stay on disk, so
  retries converge. --allow-partial-failures restores the previous leniency.
- Fixed the summary CSV dropping every hit for sequences whose header carries a
  description: it is keyed on the sanitized full header but was looked up by
  qseqid, so 'SEQ1 some description' never matched 'SEQ1'.
- Removed the unused blast_options entry from the Snakefile's
  taxonomic_validation fallback and noted that the fallback is unreachable.
- Corrected the documented BLAST hit limit (100, not 500) and the config.yaml
  threads comment, which described 4 threads per BLASTn process where the script
  uses -num_threads 1.

Adds unit tests for chunk boundaries, the input scan, demux, tie stability and
the failure path, plus a blast_runner fixture that bypasses the blastn version
check so the suite still needs no BLAST binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5MNgaVXKBo7QaqBVhVqZ
…ight-size resources

run_nhmmer_on_sequence created its per-sequence query FASTA and nhmmer tabular
output with tempfile.NamedTemporaryFile(delete=False), and the script never
unlinked either. Two files per sequence were left in TMPDIR for the lifetime of
the job. That is 48 per sample, since every sample is validated across 6
MitoGeneExtractor parameter combinations and 4 input FASTAs (pre- and
post-clean consensus for both concat and merge mode) - roughly 24,000 files for
a 500-sample run. SLURM usually wipes a per-job TMPDIR, which is why this has
gone unnoticed, but it can fill node-local /tmp on a large run.

Both files now live in a single tempfile.TemporaryDirectory() per call, so they
are removed on exit including on error. An absent tabular file is treated as
"no hits" rather than falling through to the FileNotFoundError handler, which
would have misreported it as a missing nhmmer binary.

Adds --threads (default 1), plumbed through align_sequence_with_nhmmer and
analyse_fasta to nhmmer's --cpu, and passed as --threads {threads} from the
rule. The default keeps standalone behaviour identical. Note this sets threads
per nhmmer invocation, not concurrent invocations - the script still processes
sequences serially, one nhmmer call each.

Right-sizes the rule's resources from 32 GB / 1 thread / himem to 4 GB /
8 threads / medium. Peak memory scales with sequence count, not read depth:
every result retains two Biopython SeqRecords until clean_results_for_csv_output
runs at the very end, measured at 4.67 KB per sequence, so roughly 110 MB at
1,000 samples and 550 MB at 5,000. The himem partition and the 32 GB request
were over-provisioned by about two orders of magnitude.

Adds five tests covering the --cpu wiring and temp-file cleanup on both the
success and failure paths. Three of them fail against the previous
implementation, which is what makes them regression tests; they use a fake
subprocess.run so CI still needs no nhmmer binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5MNgaVXKBo7QaqBVhVqZ
… one

Audited every entry in the rules: block against its Snakefile rule and the script
or tool it invokes, then rewrote the block with per-rule guidance.

Two structural facts now recorded in the file:

Only fastp_qc, clean_headers_merge, fastq_concat, quality_trim, downsample and
MitoGeneExtractor scatter over samples. Every other rule - including all the
fasta_cleaner filters - is a single job for the whole run. The block is split
into PER-SAMPLE and AGGREGATE sections accordingly, because the first group costs
the same for a 1-sample run as for a 500-sample one and only the second needs
headroom as runs grow.

Eleven rules reserved CPUs their commands never consume: gene_fetch (NCBI
API-bound), downsample (reformat.sh is invoked without a thread parameter),
gzip_merged_clean (a serial gzip loop in a run: block), blast2taxonomy,
extract_stats_to_csv, download_taxdump, multiqc_plots, multiqc, clean_headers_merge
and fastq_concat (no {threads} in the shell), and MitoGeneExtractor (Exonerate is
single-threaded; {threads} appears only in an echo). Those drop to 1. Conversely
reference_filter was left at 1 despite having the same process pool as the other
filters, so it ran serially for no reason; it goes to 4.

Memory figures are measured rather than assumed where they scale with run size:
blast2taxonomy holds every hit for every barcode at ~44 KB per barcode (~340 MB at
500 samples), and structural_validation at ~110 MB per 1000 samples. The
fasta_cleaner filters parallelise over per-sample files, so their peak is
threads x largest single alignment - it tracks read depth, not sample count, which
is the guideline most likely to be misread.

Net effect is 249 GB -> 145 GB of reserved memory and 73 -> 55 threads, with
nothing raised except reference_filter. The header explains that mem_mb * attempt
retry scaling is the intended flexibility mechanism, so base values are sized for a
typical run rather than the worst imaginable case, and warns that the four
partition keys the Snakefile indexes directly must not be deleted.

Not changed, but flagged in passing: downsample sets no -Xmx, so the BBTools JVM
sizes its heap from visible node RAM rather than the cgroup and can overshoot its
request; and MitoGeneExtractor inherits runtime=720 from the SLURM profile, which
is a profile setting rather than a rules: one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5MNgaVXKBo7QaqBVhVqZ
config.yaml deliberately no longer declares threads for the rules whose commands
never consume it, but test_rules_have_threads asserted every rule declares one,
so it enforced the opposite of the intent and CI was red. It is replaced by
test_rules_threads_valid_when_present, which checks the value is a positive int
only where the key exists. test_rules_have_mem_mb is unchanged - mem_mb really is
required by every rule.

Also adds test_snakefile_resource_reads_resolve, covering the failure mode that
removing those keys actually caused: MitoGeneExtractor_se and downsample_se kept
reading rule_resources[...]["threads"] after the config entries were deleted.
Snakemake evaluates those reads at parse time, so the MitoGeneExtractor_se one
(defined at column 0, unconditional) raised a KeyError on every run, PE or SE,
before any job was scheduled. Nothing in the suite caught it: the Snakemake dryrun
test self-skips when snakemake is absent, which is the normal state in CI.

The new test parses the Snakefile for rule_resources["<rule>"]["<key>"] and asserts
each resolves against config.yaml. It needs no snakemake and reports the offending
line numbers. Verified against the pre-fix Snakefile, where it fails naming both
Snakefile:1188 and Snakefile:3257.

The rule-name character class is [A-Za-z_] rather than [a-z_] on purpose:
MitoGeneExtractor is the only rule with uppercase in its name, and a lowercase-only
pattern silently skips exactly the read that broke every run. That is asserted in
the docstring so the next person does not "tidy" it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5MNgaVXKBo7QaqBVhVqZ
@SchistoDan
SchistoDan merged commit eb38100 into main Aug 21, 2026
3 checks passed
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.

2 participants