Repository navigation
Move blocking work in async routes off the event loop #950
Description
Summary
Most async def routes run synchronous SQLAlchemy work, and some run synchronous HTTP calls, directly on the event loop. Prod runs four uvicorn processes, so one slow request stalls every in-flight request on a quarter of the API. #881 added database limits and moved authentication into the threadpool; this issue converts the routes themselves and adds a test that stops new ones.
Where things stand
Counted on release-2026.4.0 at 7c482dd, which matches main. The routers have 47 async def routes:
| Group | Routes | What to do |
|---|---|---|
| Query the database and await nothing that is really asynchronous | 33 | Make def |
| Make blocking HTTP calls to publication services on the loop | 3 | Make def, and make the publication fetchers sync |
| Use the database and also await real I/O (an HTTP client, Redis) | 6 | Make def |
| Use the database and also read the multipart form | 2 | Keep async, and run the synchronous body in the threadpool |
| Await real I/O and don't touch the database | 3 | Keep alphafold and ORCID async; make disease search def (step 5) |
The publication fetchers (fetch_pubmed_article, fetch_biorxiv_article, fetch_medrxiv_article, fetch_crossref_work) are declared async, but they call eutils, Rxiv and Crossref, which make synchronous HTTP requests with no timeout. Any route that resolves a publication therefore blocks its loop for as long as NCBI, bioRxiv or Crossref takes to answer.
Scope
- Convert the 33 database-only routes to
def. - Convert the 3 publication routes to
def, and make the four publication fetchers andfind_generic_articlesync. - Convert the 6 routes that call MONDO, NCBI Taxonomy or ARQ to
def. Use a synchttpxclient for MONDO and NCBI Taxonomy, andanyio.from_thread.runfor the ARQ enqueue. - Keep the 2 upload routes (
POST /score-sets/{urn}/variants/dataandPATCH /score-sets-with-variants/{urn})async: awaitrequest.form()and the enqueue, and move the synchronous work between them intorun_in_threadpool. Making themdefwould mean declaring every form field, which changes the OpenAPI schema and how the riskiest write routes parse their input. Revisit that with Useflexible_model_loaderto handle JSON/multipart-form-data in Score Set PATCH Endpoint #629. - Make disease search (
GET /diseases/search) andsearch_mondodefas well, solib/mondo_ols.pyuses one client style. It could stayasync: it only calls OLS, so awaiting would hold no thread, while asdefeach search holds a thread for up to OLS's 10-second timeout. That cost is small because only the calibration editor calls it, debounced and cancelled on each keystroke. Revisit if it moves onto a high-traffic page or slow OLS responses start exhausting the thread pool. - Make the helpers that are
asyncwith no real await sync, and update their callers:lib/identifiers.py:find_or_create_doi_identifier,find_or_create_raw_read_identifier,find_or_create_external_gene_identifier,create_external_gene_identifier_offset, andfind_or_create_publication_identifieronce step 2 lands.lib/contributors.py:find_or_create_contributor.routers/score_sets.py:fetch_score_set_by_urn,parse_score_set_variants_uploads.models/:ScoreSet.set_legacy_keywords,Experiment.set_keywords,Experiment.set_legacy_keywords,User.set_roles,AccessKey.set_role, and their private_find_*helpers.lib/authorization.py:require_current_user,require_current_user_with_email,get_principal,require_role; andget_access_tokeninlib/authentication.py.
- Add a test that walks
app.routesand fails when anasync defendpoint, or anyasync defdependency in its tree, takes a session fromget_db, unless the route is on an explicit allowlist. Adefdependency that uses the session is fine, because it runs in the thread pool.
Out of scope:
refresh_variant_urns's per-variant UPDATE on publish. Derive variant URNs from the score set so publishing doesn't rewrite every variant #940 removes it by deriving variant URNs.- Timeouts on the publication, ORCID and NCBI Taxonomy calls. That's Add timeouts to request-path external calls and reuse per-process clients #886. Making the fetchers sync here moves them into the threadpool but doesn't bound them.
Acceptance criteria
- The route-walk test passes, and its allowlist holds only the 2 upload routes.
- No
async definsrc/mavedb/lib,src/mavedb/modelsorsrc/mavedb/routerslacks a real await, directly or through what it awaits. - The route-walk test fails on the code before this change, and when a converted route or a session-taking dependency is made
asyncagain. A timing test isn't practical here: the test fixtures share one database session across requests. - The OpenAPI schema is unchanged, so the ui needs no types update.
Route list
All links are at 7c482dd.
Database only, make def (33).
access_keys.py:POST /users/me/access-keys/{role}collections.py:POST /collections/,PATCH /collections/{urn},POST …/score-sets,DELETE …/score-sets/{score_set_urn},POST …/experiments,DELETE …/experiments/{experiment_urn},POST /collections/{urn}/{role}s,DELETE /collections/{urn}/{role}s/{orcid_id},DELETE /collections/{urn}experiments.py:DELETE /experiments/{urn}permissions.py:GET /user-is-permitted/{model_name}/{urn}/{action}publication_identifiers.py:GET /search/{identifier},GET /search/{db_name}/{identifier}score_calibrations.py:GET /score-set/{score_set_urn},GET …/primary,DELETE /{urn},POST /{urn}/promote-to-primaryscore_sets.py:GET /score-sets/,GET /score-sets/{urn},GET …/variants,GET …/variant-details,DELETE /score-sets/{urn},GET …/clinical-controls,GET …/clinical-controls/options,GET …/gnomad-variantsusers.py:GET /users/,GET /users/me,GET /users/{id:int},GET /users/{orcid_id},PUT /users/me,PUT /users/me/has-logged-in,PUT /users//{id}
Blocking publication HTTP, make def (3).
experiments.py:POST /experiments/,PUT /experiments/{urn}publication_identifiers.py:POST /search-external
Database plus real async I/O (8). The two upload routes stay async; the other six become def.
| Route | Real awaits |
|---|---|
POST /score-calibrations/ |
MONDO (httpx) |
PUT /score-calibrations/{urn} |
MONDO |
POST /score-sets/ |
MONDO, NCBI Taxonomy |
POST /score-sets/{urn}/variants/data |
data.form(), ARQ enqueue |
PATCH /score-sets-with-variants/{urn} |
request.form(), NCBI Taxonomy, ARQ enqueue |
PUT /score-sets/{urn} |
NCBI Taxonomy, ARQ enqueue |
POST /score-sets/{urn}/publish |
ARQ enqueue |
POST /taxonomies/search |
NCBI Taxonomy |
No database (3). GET /alphafold-files/version and POST /orcid/token stay async. GET /diseases/search becomes def (step 5).
How the list was derived. Each async def in src/mavedb outside worker/ and scripts/ was parsed, and its awaits were followed through local async def helpers. A route counts as having real async I/O only if some chain ends in an httpx.AsyncClient call, an ARQ or Redis call, a request body read or run_in_executor. The four publication fetchers end in synchronous library calls, so they count as blocking.
Activity