Coverage for src/asgi_profiler/detectors/blocking.py: 100%
27 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Synchronous database calls made on the event loop."""
3from __future__ import annotations
5from collections.abc import Iterator
7from ..models import Problem, Profile
8from .settings import Settings
9from .utils import fingerprint, total_span_ms
11TYPE = "blocking_db_call"
14def detect(profile: Profile, settings: Settings) -> Iterator[Problem]:
15 """Statements that stopped the event loop while they ran.
17 The mistake this catches is quiet and extremely common: a synchronous
18 `Session` used inside an `async def` endpoint. Nothing raises, the tests
19 pass, and the endpoint looks fine in isolation -- because in isolation it
20 *is* fine. The damage only appears under concurrency, where every other
21 request in flight is frozen for the duration of every query, and it
22 surfaces as latency on endpoints that have nothing to do with the
23 offending one. That is close to impossible to attribute from the outside,
24 which is why nothing else reports it: a production tracer sees the symptom
25 on the wrong endpoint and the cause on none.
27 `Query.blocking` is decided at capture time, where the thread and the
28 greenlet are still visible -- see `instrument._blocks_the_loop`. What is
29 left here is arithmetic.
31 **One problem per request, not one per call site.** The first version of
32 this split by the innermost frame, and a single endpoint that blocked for
33 30 ms reported two findings of 1 ms and 0 ms -- both true, neither the
34 thing the reader needed, which is how long their event loop was actually
35 frozen. The fix is the same wherever in the request it happens (an async
36 engine, `run_in_threadpool`, or a `def` endpoint), so splitting the
37 finding only splits the number that makes the case for doing it.
38 """
39 offenders = [i for i, query in enumerate(profile.queries) if query.blocking]
40 if not offenders:
41 return
42 queries = [profile.queries[i] for i in offenders]
43 span_ms = total_span_ms(queries)
44 if span_ms < settings.blocking_ms:
45 return
47 # Attributed by total time rather than by count: one 200 ms report query
48 # is the thing to fix, not the forty 1 ms lookups around it.
49 sites: dict[str, float] = {}
50 for query in queries:
51 frame = query.stack[-1] if query.stack else ""
52 sites[frame] = sites.get(frame, 0.0) + query.duration_ms
53 worst = max(sites, key=lambda frame: sites[frame])
54 statements = "statement" if len(offenders) == 1 else "statements"
56 yield Problem(
57 type=TYPE,
58 fingerprint=fingerprint(TYPE, profile.group, worst),
59 # Counted, not timed. The duration is in the detail and in `time_ms`;
60 # leading with it would print "Blocking database call: 0.4 ms" for a
61 # sync session on a development database -- which reads as trivia, and
62 # is the exact case worth catching, because the same code against a
63 # real database is a hundred times that number.
64 title=f"Blocking database call: {len(offenders)} {statements} on the event loop",
65 detail=_detail(len(offenders), span_ms, profile.duration_ms, worst, len(sites)),
66 evidence={
67 "sql": queries[0].sql,
68 "count": len(offenders),
69 "span_ms": round(span_ms, 3),
70 "frame": worst,
71 "call_sites": len(sites),
72 },
73 offenders=offenders[:50],
74 time_ms=span_ms,
75 )
78def _detail(count: int, span_ms: float, request_ms: float, frame: str, sites: int) -> str:
79 statements = "statement" if count == 1 else "statements"
80 where = f", mostly from {frame}" if frame else ""
81 elsewhere = f" across {sites} call sites" if sites > 1 else ""
82 share = f" ({span_ms / request_ms:.0%} of the request)" if request_ms else ""
83 return (
84 f"{count} {statements}{elsewhere} ran synchronously on the event loop "
85 f"thread{where}, holding it for {span_ms:.1f} ms{share}. Every other request "
86 f"in flight waited that long. Use an async engine with `AsyncSession`, wrap "
87 f"the call in `starlette.concurrency.run_in_threadpool`, or make the endpoint "
88 f"`def` rather than `async def` -- Starlette already runs those in a worker "
89 f"thread."
90 )