Replace the blocking blastp -remote call with NCBI's BLAST URL API and a bounded timeout - #102
Replace the blocking blastp -remote call with NCBI's BLAST URL API and a bounded timeout#102mrubash1 wants to merge 13 commits into
Conversation
8570686 to
f10ac93
Compare
`envs/analysis.yml` pinned its seven direct dependencies but nothing
transitive, so resolving it today installs versions of matplotlib and
setuptools that are incompatible with its pinned numpy 1.23.5, and two
rules fail:
rule leiden_clustering (scanpy -> matplotlib):
ImportError: Matplotlib requires numpy>=1.25; you have 1.23.5
rule dim_reduction (umap-learn 0.5.3):
ModuleNotFoundError: No module named 'pkg_resources'
matplotlib >= 3.8 requires numpy >= 1.25, and setuptools >= 81 removes
`pkg_resources`, which umap-learn 0.5.3 imports at module scope. The
matplotlib pin matches the one already used in `plotting.yml` and
`cartography_pub.yml`.
This is not platform-specific: a fresh solve produced matplotlib 3.11.0
on osx-64 and 3.9.4 on linux-64, and the pipeline failed identically on
both. Verified by running the cluster-mode demo end to end, where all
rules now complete and every final_results output is produced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
`restore-keys` entries are matched as a prefix of `key`, but this one
began with "snakemake-conda-" while the key begins with "conda-", so it
could never match and the fallback restore never fired.
Note that this cache is also why CI did not catch the dependency drift
fixed in the previous commit: the key includes `hashFiles('envs/*.yml')`,
so as long as the env files are unchanged, CI restores a previously
solved set of conda envs and never re-resolves them against the current
package index.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
The `run_blast` rule calls `blastp -remote`, which queues on NCBI's public servers. Those queues are frequently long (NCBI's own RTOE estimate was observed at ~2.5 hours), so running the pipeline end to end was impractical for a quick sanity check or for CI. `run_blast` now copies a canned results file when the env variable `PROTEINCARTOGRAPHY_BLAST_STUB_RESULTS_FILEPATH` is set. An env variable is used, rather than a CLI flag, because snakemake rule environments inherit their env variables from the calling process, so neither the Snakefile nor the pipeline config needs to change. This replaces `tests.mocks.mock_run_blast`, which patched `blast_utils.run_blast` and so required `run_blast.py` to import the `tests` package. That import is removed here, extending the fix in an earlier commit: importing `tests.mocks` calls `find_repo_dirpath()`, which made the module unusable outside a git working tree. `make smoke-test` runs the search-mode pipeline test with the stub. That test also now asserts on snakemake's return value, which was previously discarded, so the test could pass even when the pipeline failed, and it checks that the output files are non-empty rather than merely present. Verified by running the smoke test: 26 of 26 rules complete, in about two minutes once the conda envs exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
When the remote server refuses to queue a request, `blastp -remote`
reports the error on stderr, writes an empty results file, and still
exits with a status of zero:
$ blastp -remote -db nr ... ; echo $?
Error: [blastp] bad_request: Could not queue request: DB operation
failed.(ERR:-99 )((Severe Error) DB Put Request error:
sp_NewRequestEx failed)
0
`run_blast.py` branched on `result.returncode == 0`, so it treated these
failures as successes. The word-size backoff was therefore never
attempted, which is the situation it was added for, and the empty
results file was only reported later by `extract_blast_hits.py` as a
misleading "no hits were returned" error.
Failure is now determined by `blast_call_failed`, which also treats an
error on stderr as a failure. An empty results file is deliberately not
treated as a failure, because a query that legitimately has no hits
produces one too.
Note that this does not make the remote call any faster: the calls
observed hanging were queued by NCBI, whose own estimate of the time to
completion (`RTOE`) was ~2.5 hours at the time. It does mean that a
refused request is retried and then reported, rather than silently
producing an empty results file.
Also adds `pythonpath` to the pytest configuration, so that tests can
import the modules in the `ProteinCartography` package the same way the
snakemake rules do (as top-level modules).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
`envs/cartography_test.yml` left `mamba` unpinned, so it now resolves to mamba 2.x. Mamba 2 removed the `mamba env create` CLI that snakemake 7.25.3 invokes, so `--conda-frontend mamba` fails with a `CreateCondaEnvironmentException` and no output from the frontend. Pinned to 1.4.2, matching `cartography_tidy.yml`, which is the version the pipeline has been run with successfully. CI does not hit this, because `.github/workflows/test.yml` forces the conda frontend (with a comment noting that the mamba frontend "results in errors during env creation" — this is that error, and this is its cause). Anyone running the mamba frontend locally does hit it. This is the same class of problem as the unpinned transitive dependencies fixed earlier in this branch: a dependency left unpinned in 2023 that resolves to an incompatible major version today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
Two rules built an output path from an f-string that mixed an
interpolated value with an escaped snakemake wildcard:
f"{ANALYSIS_NAME}_aggregated_features_{{plotting_mode}}.html"
The doubled braces are there so that the f-string renders the literal
`{plotting_mode}` that snakemake needs as a wildcard. That is easy to
misread, and a formatter can silently change what it means: running
`make format` under Python 3.12 or later rewrites the doubled braces to
single ones, which turns the wildcard into an interpolation of a name
that does not exist. (Python 3.12 changed how f-strings are tokenized,
see PEP 701; the versions of snakefmt and black pinned here predate it.)
Concatenating a plain string avoids the escaping entirely, produces a
byte-identical path, and cannot be rewritten this way. Verified that the
DAG still resolves the same targets, with the wildcards expanding to
`..._aggregated_features_pca_umap.html` and
`..._P60709_distribution_analysis.svg` as before, and that
`snakefmt --check` now passes under both Python 3.9 (which CI uses) and
Python 3.12+, where it previously wanted to rewrite the file.
Note that the doubled braces elsewhere in the Snakefile are not affected:
they appear in a plain (non-f) string and in a shell block, neither of
which a formatter rewrites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
…che restore-keys Three environment files declared no python at all, so each resolved to whatever the newest python conda-forge had builds for every other pin in the file. They had already drifted apart from each other and from the six environments that do pin it, which are all on 3.9.16: solved as they were, `analysis.yml` gave python 3.11 and `plotting.yml` gave 3.10.20. With this change all three solve at 3.9.16, and the `analysis` environment imports matplotlib.pyplot, pkg_resources and umap -- the three imports that were failing before the pins on this branch. This is the same class of drift as the rest of this branch, and it is what put `analysis.yml` on a python new enough to resolve a setuptools that removed `pkg_resources`, which is the failure the `setuptools<81` pin here addresses. Also remove the `restore-keys` from the conda env cache rather than correcting their prefix, which is what this branch did previously. A `restore-keys` match still leaves `cache-hit` false, so the env creation step runs regardless, and that step begins by deleting `.snakemake/conda`. Making the prefix match would therefore download a stale multi-gigabyte cache only to discard it -- strictly slower than the broken prefix that never matched anything. Finally, correct the reason given for the matplotlib pin. Recent matplotlib and `numpy=1.23.5` are not incompatible to the solver, which installs them together without complaint; matplotlib is built against numpy 2 and fails on import. The distinction matters because it means the breakage cannot be caught when the environment is built, only when it is used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
…timeout
`blastp -remote` polls NCBI's queue indefinitely and gives the caller no way to
bound how long it waits. When NCBI's public queue is busy it blocks for hours
while consuming no CPU and producing no output: a run observed while
investigating this used 0.31s of CPU over 51 minutes and wrote a 0-byte results
file, and NCBI's own time estimate for the same query was 9168s (~2.5 hours).
This reproduces identically on macOS arm64 and x86-64 Linux, and is caused by
queue load rather than by any of the arguments the pipeline passes.
Submit the search to NCBI's URL API directly instead, so that the pipeline can
give up after a configurable timeout and say why it gave up:
- `blast_timeout_seconds` (default 1800) bounds a single search, and is
plumbed through the Snakefile rule and `run_blast.py` like the other blast
parameters. On timeout the error reports both how long it waited and what
NCBI's estimate was.
- `blast_email` (overridable with the PROTEINCARTOGRAPHY_BLAST_EMAIL env
variable) and a `tool=proteincartography` parameter identify the client, as
NCBI asks automated clients to do.
- Polling follows NCBI's etiquette: the first poll happens after the returned
RTOE, and subsequent polls are at least 60 seconds apart. Both waits are
shortened when less of the timeout remains, so the timeout is honored
exactly rather than being overshot by a long RTOE.
- The `run_blast` rule declares an `ncbi_remote` resource, so that a run with
many input proteins can be stopped from submitting concurrent searches with
`--resources ncbi_remote=1`. It has no effect unless that budget is given.
The database stays `nr`: the BLAST arm contributes sequence-space hits alongside
the Foldseek structure search, and the alternatives that would avoid NCBI's
queue either lack `nr`, cap the hit list below the 3000 the config asks for, or
return UniProt rather than RefSeq accessions.
The results keep the exact `-outfmt 6` column layout that
`extract_blast_hits.py` reads back. NCBI's URL API does not accept a custom
field list, so `sacc` and `saccver` are derived from the subject sequence id and
the three fields the API cannot supply (`sgi`, `staxids`, `scomnames`) are
written as 'N/A'. Only `sacc` is read downstream, so the pipeline's results are
unaffected.
`blast_call_failed` and the word-size backoff in `run_blast.py` are unchanged:
the search still reports itself as a `CompletedProcess` whose stderr is prefixed
with "Error:" when it failed. The `PROTEINCARTOGRAPHY_BLAST_STUB_RESULTS_FILEPATH`
short-circuit is also unchanged, so `make smoke-test` still runs the pipeline
end to end without touching the network.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
Two fixes to the switch from `blastp -remote` to NCBI's URL API.
NCBI does not deliver `FORMAT_TYPE=Tabular`. Fetching a completed search with it
returns a 62-byte body carrying only the status block and no hits, with or
without `FORMAT_OBJECT=Alignment`. Probing one completed search (RID
7TJ2P2DR014) gave:
Tabular : 62 bytes (nothing but the status block)
Text : 157,935 bytes
XML2_S : 511,934 bytes
JSON2_S : 422,458 bytes
Retrieve `JSON2_S` and convert it to the pipeline's column layout instead. The
JSON carries more than the tabular format would have, so 'staxids' is now
populated from each hit's taxid rather than written as 'N/A'.
Converting rather than reading a preformatted table means the remaining fields
have to be derived, and the derivations were checked against real output:
- 'qseqid' comes from the query title. NCBI replaces the query's id with an
internal one ('Query_5706473') and keeps the FASTA defline in `query_title`,
so the id `blastp` would report is the title's first token.
- Only the first description of each hit is emitted. `nr` merges identical
sequences, so one hit can carry many accessions -- 1053 of them in one hit
of the search above -- and `-outfmt 6` reports only the representative in
'sacc' (reporting all of them is what 'sallacc' is for). Every row of the
committed `blastp -remote` results file corresponds to one hit in this way,
and always to the first description.
- 'gapopen' is counted as runs of '-' in the aligned sequences. NCBI's `gaps`
is the number of gapped positions, which is a different number whenever a
gap is longer than one residue.
- e-values and bit scores are formatted with NCBI's own thresholds, so that
the file looks like one `blastp` wrote (0.0 rather than 0, 788 rather than
787.719).
- 'sgi' is a literal 0, as `blastp` writes for retired GI numbers, and
'scomnames' stays 'N/A': the JSON has `sciname`, but that is a scientific
name and this column is defined as the common name, so filling it in would
mislabel the data.
Converting the real response for RID 7TJ2P2DR014 reproduces the row from the
committed `blastp -remote` results file exactly in 16 of 17 fields for the hit
they share (XP_052610122). The 17th is the bit score, which differs by less than
one because the two searches ran against different snapshots of `nr`. That
response is committed, trimmed from 50 hits to 4, as a test fixture so the
parser is exercised against genuine NCBI output rather than only hand-written
mocks.
The timeout is now a budget for the whole rule rather than for one attempt. It
is established once, before the retry loop, and each attempt is given only what
is left of it, so the worst case is the configured value rather than
`blast_num_attempts` times it. Exhausting the budget stops further attempts and
reports how long was spent in total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
The database was hard-coded to `nr`. Runs that are bottlenecked on NCBI's queue have a reason to want a different one, but which database is searched decides which homologs are found, so it belongs in `config.yml` alongside the other parameters that determine a map's contents rather than being changed in code. Defaults to `nr`, which is what the pipeline has always searched, so this does not change any existing run. `nr_cluster_seq` (ClusteredNR) is documented as the alternative worth trying, rather than one of the RefSeq-only databases: it is built by clustering `nr`, so it does not drop the GenBank CDS translations, PDB, PIR and PRF entries that `refseq_protein` excludes outright. Measured against NCBI on 2026-08-13, `nr` and `refseq_protein` received identical time estimates and `refseq_protein` finished no faster, so the smaller RefSeq databases do not appear to buy the speed they are usually chosen for. Also correct the request id expiry reported when NCBI returns 'UNKNOWN', from 24 hours to the 36 hours NCBI currently documents, and note in `run_blast` why the id is printed as soon as it is known. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
`wait_for_search` checked the deadline again between waiting and polling, so a search whose estimate was larger than the whole timeout was never polled at all: the first wait consumed the budget, the check fired, and the search was reported as having timed out without a single request being sent to NCBI. This was the common case rather than an edge case. NCBI's estimate tracks how busy its shared queue is rather than how long a particular search will take, and is routinely several times the default timeout: the estimate of 9168s used in the tests was measured against the real service, versus a default `blast_timeout_seconds` of 1800. A search that NCBI had already finished would wait out the whole budget and then fail. Poll after every wait instead. The timeout still bounds the waiting, and a search that turns out to be ready now succeeds. The existing test passed either way, because it asserts on how long was waited rather than on whether NCBI was asked. Add one that fails without this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
Any `requests` exception raised while polling propagated out of `wait_for_search` and ended the search. A single refused connection partway through a long wait therefore discarded the request id and everything already spent waiting, and the retry in `run_blast.py` submitted a new search to the back of NCBI's queue. The search is queued on NCBI's side, so a failure to ask about it says nothing about the search itself. Retry the poll instead, bounded both by `MAX_CONSECUTIVE_POLL_FAILURES` and by the existing timeout. Also correct the docstring, which claimed the timeout was "honored exactly". Only the waiting is bounded: a request already in flight when the timeout expires runs to completion, so a call can overrun by up to one `HTTP_TIMEOUT_SECONDS`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
…ts file Two responses were accepted that should not have been. A report with a 'BlastOutput2' key holding no search object yielded nothing, so an empty results file was written and the search reported success. The pipeline then failed in `extract_blast_hits.py` with "no hits were returned", which is the misleading failure this module exists to remove, and which is indistinguishable there from a query that genuinely has no hits. A body that parsed as JSON but was not an object (a top-level array, say) raised an `AttributeError` from the parsing code. That is not one of the exceptions `run_blast` catches, so it escaped as a traceback and was not retried. Both are now reported as failed searches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
f10ac93 to
7581c63
Compare
|
Closing in favour of #107. This PR replaced the BLAST transport wholesale. #103 has since merged its own www/QBlast implementation to #107 carries the substance across: the bounded total timeout, the budget spanning attempts, the consecutive-poll-failure cap, response validation, config parameters instead of environment variables, and a testable heartbeat. It also fixes a defect found while reading the merged implementation — What is deliberately not carried across is the JSON2_S parsing and the ~1200 lines of tests built around it. Those are validated row-for-row against real |
Stacked on #101 (which is stacked on #100). The two commits new to this PR are the last two; review from
Replace blastp -remote with NCBI's URL APIonward.The problem
run_blastshells out toblastp -remote, which blocks indefinitely polling NCBI's shared public queue. Observed during this work: 0.31 s of CPU over 51 minutes, no open socket, and a 0-byte output file. Reproduced identically on macOS arm64 and on x86-64 Linux, so it is not a local or an architecture problem.The cause is queue congestion, not the pipeline's arguments. NCBI's own time-to-completion estimate (
RTOE) came back at 7838 s (131 minutes) for a single 375-residue actin query.-word_size,-outfmt,-max_target_seqs, andshell=Truewere each ruled out by probingRTOEagainst a control: the same parameters submitted six times consecutively returnedRTOE=20every time, versus8655for those same parameters minutes earlier.RTOEtracks global server load, not the query.There is no way to bound how long
blastp -remotewill block. That is what this PR changes.The change
run_blastnow drives NCBI's BLAST URL API withrequests(already a dependency ofenvs/blast.yml), keeping thenrdatabase and the existing retry loop:CMD=PutwithPROGRAM=blastp,DATABASE=nr, mappingmax_target_seqs→HITLIST_SIZE,word_size→WORD_SIZE,evalue→EXPECT, plustoolandemailso NCBI can identify the client.CMD=Get&FORMAT_OBJECT=SearchInfo, first after the returnedRTOE, then no more often than every 60 seconds, as NCBI asks.CMD=Get&FORMAT_TYPE=JSON2_S, converted to the pipeline's existing 17-column-outfmt 6layout.run_blaststill returns aCompletedProcesswhose stderr is prefixedError:on failure, soblast_call_failed()and the word-size backoff added in #101 are unchanged.Why
JSON2_Srather thanTabularFORMAT_TYPE=Tabularwould need no conversion, but NCBI does not actually deliver it. Fetching a completed search (RID7TJ2P2DR014) returns a 62-byte body containing only the status block and no hits, with or withoutFORMAT_OBJECT=Alignment. The same RID returns real results in every other format:JSON2_Salso carries the subject accession and the taxonomy id directly, sostaxidsis populated rather than dropped.Timeout
blast_timeout_seconds(default 1800, i.e. 30 minutes) bounds the total time spent on one protein across all retry attempts, not each attempt: a single deadline is established before the retry loop and each attempt receives only what remains. A retry cannot reset the budget. On expiry the search fails with a message reporting how long it waited and what NCBI estimated.The timeout bounds the waiting, not the requests: a poll already in flight when it expires runs to completion, so a call can overrun by up to one
HTTP_TIMEOUT_SECONDS(60s). An earlier revision of this description claimed the timeout was honoured exactly, which was wrong.Database
blast_databaseconfig parameter, defaulting tonr— what the pipeline has always searched, so no existing run changes. It is a config parameter rather than a constant because which database is searched decides which homologs are found, so it belongs with the other parameters that determine a map's contents.The documented alternative is
nr_cluster_seq(ClusteredNR), not one of the RefSeq-only databases: it is built by clusteringnr, so it does not drop the GenBank CDS translations, PDB, PIR and PRF entries thatrefseq_proteinexcludes outright. Measured against NCBI on 2026-08-13,nrandrefseq_proteinreceived identical time estimates andrefseq_proteinfinished no faster, so the smaller RefSeq databases do not appear to buy the speed they are usually chosen for.Failure handling
Three failure modes that this PR originally got wrong, each now covered by a test that fails without its fix:
MAX_CONSECUTIVE_POLL_FAILURESand by the timeout. The search is queued on NCBI's side, so failing to ask about it says nothing about the search.BlastOutput2held no search, or a body that parsed as JSON but was not an object, produced an empty file andrc=0— resurfacing downstream as the misleading "no hits were returned" that this PR exists to remove. Both are now reported as failures.Other
blast_emailconfig parameter, overridable withPROTEINCARTOGRAPHY_BLAST_EMAIL. The default is a project address, not an individual's.resources: ncbi_remote=1on therun_blastrule. Inert unless snakemake is given a budget for it (--resources ncbi_remote=1), which serializes BLAST so that a run with many input proteins does not submit concurrent searches to a shared queue. Documented in the README rather than forced on.Output field fidelity
Converting the real JSON response reproduces the real
blastp -remoterow for a hit that both outputs share — 16 of 17 fields identical:bitscorediffers by less than 1 because the two searches ran against differentnrsnapshots, and the bit score depends on the effective search space. The test asserts this rather than hiding it.scomnamesis written asN/A. NCBI's JSON providessciname(a scientific name), and the column is defined as the common name, so populating it would mislabel the data.sgiis written as0, matching what the CLI writes for retired GIs. Onlysaccis read downstream (extract_blast_hits.py:50).What is and is not verified
Verified against live NCBI, end to end in a single run — on macOS arm64 and, independently, on x86-64 Linux (Modal):
RID/RTOEparsing, polling, fetch, and conversion, all in one invocationstaxidspopulated (89673,61221,9764, ...)extract_blast_hits.pyrun against that output returns 10 accessions, several of which (RLW01512,XP_007129366,NP_001009784) match the accessions in the repo's committed id-mapping fixturegapopen > 0and 444 rows with mismatches, so the gap-open counting and the mismatch calculation are exercised against real NCBI output. Note the repo's committedblastresults.tsvartifact contains no gapped rows at all, so this is broader coverage than that fixture provides.Not verified against live NCBI:
evalue=0, and the committed CLI artifact likewise contains only0.0, so there is no real-world reference to compare against.format_evalueimplements blastp's formatting thresholds and is unit-tested, but only against synthetic values. Note thatevalueis not read downstream; onlysaccis (extract_blast_hits.py:50), and that is verified above.Test results
Run on macOS arm64 and independently on x86-64 Linux (Modal), both with CI's pinned tool versions:
The 3 warnings are pre-existing deprecation notices from snakemake and stopit, not from this PR.
Unit tests mock every HTTP request and fake the clock, so multi-hour timeouts are exercised instantly and without sleeping. A trimmed 9.2 KB excerpt of a real NCBI response is committed as a fixture so the parser is tested against genuine output rather than only hand-written mocks; hits and descriptions were dropped, never edited.
Note for reviewers
This does not make BLAST faster. NCBI's queue is the bottleneck and remains so. What changes is that the pipeline waits a bounded, configurable amount of time and then fails with a clear message, instead of blocking indefinitely.
NCBI's congestion is intermittent, not permanent: the
RTOEestimate for the same query was 7838 s on one day and 20 s the next.blastp -remotewould therefore often work fine. The point of this change is the days when it does not, where it currently blocks for hours with no bound.Alternative back ends were benchmarked (EBI's REST service returned 1000 hits against UniProtKB in ~8.5 minutes while NCBI returned nothing in any configuration) but deliberately not adopted: EBI has no
nr, caps at 1000 hits against a configuredmax_blast_hits: 3000, and returns UniProt rather than RefSeq accessions. Changing which database is searched changes the results, which is a scientific decision rather than an engineering one.