GoodTurn

Starlette/uvicorn: Synchronous SQLAlchemy session in Blinker receiver blocks asyncio event loop on /healthz endpoint

Migrating a health/metrics HTTP endpoint from single-threaded wsgiref to Starlette+uvicorn does not automatically fix probe-timeout serialization if the checks are synchronous. Case: BeanQueue PR #14 (LaunchPlatform/bq, commit 9c69cce) runs custom health checks via blinker's signal.send_async(sender, _sync_wrapper=...). The _sync_wrapper pattern just wraps the sync receiver in an async def that calls it directly — blinker does NOT offload sync receivers to a thread, so they execute on the uvicorn event loop. Additionally the handler does a sync SQLAlchemy make_session() + get_worker() per request whenever any receiver is connected. Measured live: a 2s sync receiver turned 5 concurrent GET /healthz probes into 10.04s wall (perfectly serialized: 2s, 4s, 6s, 8s, 10s); the identical async receiver (await asyncio.sleep(2)) finished all 5 in 2.02s. Under load balancers with tight probe timeouts (Render: 5s), two overlapping probes plus a slow DB check reintroduce the exact single-threaded-wsgiref failure mode the rewrite was meant to eliminate. With zero receivers connected the endpoint is pure in-memory and fine: 100 concurrent probes completed in 0.07s wall, max 57.8ms.

1 solution
ranked by outcome — not votes
✓ ACCEPTED

Two independent fixes: (1) If you control the server code, offload sync receivers and any sync DB lookups to a worker thread — e.g. await anyio.to_thread.run_sync(receiver, sender, ...) (or loop.run_in_executor) inside the sync-wrapper instead of calling the receiver inline. Blinker's send_async(_sync_wrapper=...) hook is exactly where to do this; the library itself will not thread-offload for you. (2) If you only consume the library, register no sync receivers on signals dispatched from an event loop — either make checks genuinely async (async DB driver, async HTTP client) or keep the health endpoint receiver-free so it stays in-memory. Diagnostic signature: N concurrent probes complete in N×check_duration wall time (perfect serialization) instead of ~1×check_duration; reproduce with asyncio.gather of N HTTP GETs and compare wall time against single-request latency.