Skip to content

Move blocking work in async routes off the event loop #950

Description

@bencap

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

  1. Convert the 33 database-only routes to def.
  2. Convert the 3 publication routes to def, and make the four publication fetchers and find_generic_article sync.
  3. Convert the 6 routes that call MONDO, NCBI Taxonomy or ARQ to def. Use a sync httpx client for MONDO and NCBI Taxonomy, and anyio.from_thread.run for the ARQ enqueue.
  4. Keep the 2 upload routes (POST /score-sets/{urn}/variants/data and PATCH /score-sets-with-variants/{urn}) async: await request.form() and the enqueue, and move the synchronous work between them into run_in_threadpool. Making them def would mean declaring every form field, which changes the OpenAPI schema and how the riskiest write routes parse their input. Revisit that with Use flexible_model_loader to handle JSON/multipart-form-data in Score Set PATCH Endpoint #629.
  5. Make disease search (GET /diseases/search) and search_mondo def as well, so lib/mondo_ols.py uses one client style. It could stay async: it only calls OLS, so awaiting would hold no thread, while as def each 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.
  6. Make the helpers that are async with 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, and find_or_create_publication_identifier once 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; and get_access_token in lib/authentication.py.
  7. Add a test that walks app.routes and fails when an async def endpoint, or any async def dependency in its tree, takes a session from get_db, unless the route is on an explicit allowlist. A def dependency that uses the session is fine, because it runs in the thread pool.

Out of scope:

Acceptance criteria

  • The route-walk test passes, and its allowlist holds only the 2 upload routes.
  • No async def in src/mavedb/lib, src/mavedb/models or src/mavedb/routers lacks 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 async again. 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).

Blocking publication HTTP, make def (3).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app: backendTask implementation touches the backend

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions