Tags: dj-bolt/django-bolt
Tags
fix(auth): load request.user on the bounded ORM pool (#275) * fix(auth): load request.user on the bounded ORM pool `user_loader` built its own 4-thread pool and ran Django queries on it, so every `request.user` resolution in an async handler escaped the framework's ORM connection budget. That budget is not incidental. `concurrency._get_orm_executor()` is vendor-aware precisely because SQLite throughput scales inversely with connection count — the module documents a measured ~2.5x regression from a wide pool contending on the database file lock — and each pool thread holds its own long-lived connection. A private pool opens connections outside that accounting and ignores `DJANGO_BOLT_ORM_THREADS`, which users set expecting it to cover their queries. User loading now goes through the shared ORM pool: - `concurrency` grows `get_orm_executor()`, a sync accessor for callers that must block rather than await. `request.user` is a `SimpleLazyObject`, so it is forced from code that cannot await. - Contextvars are carried across the hand-off, matching `run_in_orm_executor`, so a request-scoped database router stays visible while the user loads. - The eager `load_user` path moves off `asyncio.to_thread` (the generic default pool) onto `run_in_orm_executor` for the same reason. Consolidating introduces a reentrancy hazard the separate pool masked: a lazy user forced from work already running on the ORM pool would submit back into it and wait on a slot the caller itself holds — with the SQLite default of one thread, forever. `in_orm_executor_thread()` marks pool workers via the executor's initializer, and a load already on one runs inline. Tests cover both loader paths, the thread budget under DJANGO_BOLT_ORM_THREADS=1, and the reentrant case. The reentrancy test asserts thread identity rather than blocking on a timeout: a regression there deadlocks, and a test that hangs CI is worse than one that fails. * fix(concurrency): synchronize ORM pool creation and guard reentry at the pool Two defects in the consolidation, both introduced by widening who can reach the ORM pool. Lazy construction was check-then-create with no synchronization. That was safe while `run_in_orm_executor` was the only caller, since it ran on the event loop thread. User loading forces `request.user` from arbitrary threads, so two racing first callers could each build a pool — and the loser's four threads, holding four database connections, would leak outside the budget this change exists to enforce. The reentrancy guard sat in the user loader, one level too high. `load_via_async` drives a custom async `get_user` with `asyncio.run` on a pool worker, so any ORM work that coroutine awaits re-enters `run_in_orm_executor` from inside the pool and submits back into it — the same deadlock, one frame down. The guard now lives in `run_in_orm_executor` itself, which is where the hand-off happens. The race test widens the construction window deliberately. Unsynchronized lazy init is a race whether or not CPython happens to switch threads inside those few bytecodes; without the widening the test passed against the unlocked version on every run, which would have made it worthless. * fix(concurrency): keep async get_user off the ORM pool and fix reentrant sync-ORM breakage Review follow-up, three connected fixes: 1. run_in_orm_executor's inline reentry branch was broken for real ORM work: it is only ever reached under load_via_async's asyncio.run, so the pool worker has a running event loop and Django raises SynchronousOnlyOperation on any sync query. The existing test passed because its callable only read the thread name. Reentrant ORM work now crosses to the generic default pool — the worker's own slot is held and its thread cannot host sync ORM, so a transient out-of-budget connection on this rare path is the only correct option. 2. The asyncio.run shim for a custom async get_user is hosted on the default pool instead of an ORM slot. The coroutine may await non-database work (an external identity provider); pinning a bounded ORM slot for its full lifetime queues every QuerySet evaluation behind it — on the SQLite one-thread default, all of them. When the load is forced from an ORM worker the shim still runs inline there, otherwise the coroutine's own ORM hand-off would deadlock against the slot the caller holds. 3. The blocking hand-off logic moves from user_loader into concurrency.run_orm_blocking, replacing the raw get_orm_executor accessor: one place owns the guard + contextvars dance, and no caller can submit past the reentrancy guard. The inline branch now runs in a copied context so ContextVar writes stay scoped identically on both branches, and an invalid DJANGO_BOLT_ORM_THREADS value is reported instead of silently replaced with the default. Test fixture drains every pool it builds with shutdown(wait=True) before the transactional teardown truncates tables, and the parked user load asserts on release.wait instead of discarding a timeout. * fix(imports): reorder user_loader imports for clarity * fix(concurrency): synchronize default-pool lazy construction The user-load shim and the reentrant ORM hand-off made _get_default_executor() reachable from arbitrary threads; its check-then-create had the same first-use race the ORM pool's did, where the losing builder leaks a full pool of threads. Same double-checked lock, and an invalid DJANGO_BOLT_EXECUTOR_THREADS value is now reported instead of silently replaced, matching the ORM pool. --------- Co-authored-by: yeakiniqra <developers@telegramsignalcopier.com> Co-authored-by: Farhan <www.mfarvirus@gmail.com>
PreviousNext