In-application mode: N+1 detection per request#
| Status | Proposed |
| Author | Maciej ZieliĆski |
| Date | 2026-10-13 |
| Target release | 0.5.0 (experimental from 0.5.0rc1) |
| Extends | design.md, design-0.3-api.md |
1. Problem#
pytest-queryguard only measures inside pytest: a collector is pushed around
each test, and class-level listeners on Engine and Session feed every
active collector (a process-wide stack). That is right for a test suite, where
one test runs at a time, but it cannot run inside a development server:
- concurrent requests (asyncio tasks, worker threads) would all write into the same stack, so one request's statements would be reported for another;
- the listeners are registered on every
Enginein the process, not on the ones the application chooses; - nothing reports per request, and there is nothing to look at while clicking through the application.
2. Goals#
- Run the same detectors (relationship loads, repeated statements, column
loads, optional small repeats and write loops) on every HTTP request of a
running ASGI or WSGI application, and report findings through
logging,warningsor a callback, with optional per-route query budgets. - Statements of concurrent requests, tasks and threads never mix. The one
exception is a thread pool on builds where threads inherit the context,
used without
copy_context().run(see the context-local collector below). - Opt in per engine:
watch(engine)andunwatch(engine), forEngineandAsyncEngine. - No cost when not enabled: importing the module registers nothing.
- Never break the application: every internal error degrades to one log record and switches the in-app mode off.
- An optional development panel (JSON and a self-contained HTML page).
- The pytest plugin keeps its behaviour, its listeners and its tests.
Non-goals: production monitoring, sampling, persistence, authentication of
the panel, the missing-index check (it runs EXPLAIN on the request's
connection; that belongs in tests), and framework-specific integrations
beyond pure ASGI and WSGI.
3. API#
A new subpackage, pytest_queryguard.app, importable without pytest:
from pytest_queryguard import app as queryguard
queryguard.watch(engine) # Engine or AsyncEngine
asgi_app = queryguard.QueryGuardASGI(asgi_app, budgets={"GET /orders": 3})
wsgi_app = queryguard.QueryGuardWSGI(flask_app.wsgi_app, report="warn")
with queryguard.track("nightly-job") as report: # any unit of work
run_job()
| Name | Meaning |
|---|---|
watch(engine), unwatch(engine) |
Attach and detach the in-app listeners to one engine. Idempotent. |
track(name, *, settings) |
Context manager: everything watched that runs in this context is one unit; yields a live RequestReport, finished on exit. |
QueryGuardASGI(app, ...) |
Pure ASGI middleware: one track() per http request. |
QueryGuardWSGI(app, ...) |
WSGI middleware: the same for WSGI. |
Settings |
Thresholds, repeat_scope, budgets, default_budget, report, max_events. |
RequestReport |
Method, path, route, status, statements (Query), times, findings (Finding), budget. |
set_route(template) |
Names the route of the current request for budgets (Flask). |
internal_error() |
The error that switched the in-app mode off, or None. |
4. Decisions#
-
Context-local collector. One
ContextVarholds the currentRequestReport's collector. asyncio copies the context into each task, Starlette copies it into its thread pool, and SQLAlchemy's async greenlets share the awaiting task's context (gr_context), so a request's tasks andrun_in_threadpoolcalls see its collector while concurrent requests do not. A plainthreading.ThreadorThreadPoolExecutorwithoutcontextvars.copy_context()starts empty, so its statements are not attributed to any request: missing, never mixed. Where threads inherit the creator's context (Python 3.14 free-threaded builds, or-X thread_inherit_context=1), a pool worker keeps the context of the request that created it, so other requests' tasks can be attributed to that request while it runs. Once that request has finished, the listeners see its tracker as finished and drop the worker's statements (_core.live()). Submitting each task withcopy_context().runavoids both on every build;docs/in-app.mdsays so. -
Separate listeners, per engine. The in-app handlers live in
app/_listeners.pyand are registered withevent.listen(engine, ...)on the watched engine (thesync_engineof anAsyncEngine). The ORM events (do_orm_execute,after_begin) exist only onSession; they are registered on theSessionclass while at least one engine is watched, and return after oneContextVar.get()outside a request. A relationship or column load is only kept when its statement reaches a watched engine, so sessions on other engines add nothing. The pytest listeners inlisteners.pyare not changed; the in-app handlers reuse its pure helpers (_is_write, the scope numbers) and the sharedQueryCollector,detect()andmake_findings(). -
Own error state. The pytest listeners stop on their first error through a module global. The in-app mode has its own, so an in-app bug never turns off the measurement of a test run, and the reverse.
-
Reporting after the response. Detection runs once, when the request ends (after the last byte for ASGI, after
close()of the response iterable for WSGI), never on the statement path. -
Route templates for budgets. Budgets are keyed by
"METHOD path"or"path", withfnmatchpatterns. Starlette and FastAPI put the matched route inscope["route"], whosepathis the template (/orders/{id}). WSGI has no route, so the path is used unless the application callsset_route()(a two-line Flaskbefore_requesthook in the docs). -
Panel. Off by default;
panel=Trueserves<panel_path>/(HTML) and<panel_path>/requests.jsonfrom the middleware itself, the lastpanel_historyreports. The HTML has inline CSS and JS only and renders text withtextContent. By default it answers loopback clients only; that is a guard against accidental exposure, not access control. Bound parameters are never stored in the panel. -
Memory.
max_events(default 1000) caps the statements kept per request; past it they are only counted, as in the plugin.
5. Risks#
- Statements a request starts in a thread without a copied context are not counted (documented).
- Starlette background tasks run inside the request's ASGI call, so their statements count for the request (documented).
- The
Session-class listener costs oneContextVar.get()per ORM execution in the whole process while any engine is watched.