diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 87426284..c20fc154 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3092,6 +3092,12 @@ async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any: blowing the gateway read timeout (#654: opaque Caddy 502/504). budget_s <= 0 disables the guard (await directly) — escape hatch via config. + + НЕ ВКЛАДЫВАТЬ бюджеты друг в друга: защита `run_db_thread` (#3449) одноразовая + — она ловит ОДНУ отмену, а вторая, прилетевшая пока шаг БД дожидается своего + потока, вылетает из самого ожидания, и поток остаётся сиротой в чужой `Session` + (ровно то, ради чего защита и заведена). Сейчас ни один вызов `_with_budget` не + обёрнут другим — это инвариант, а не совпадение. """ if budget_s is None or budget_s <= 0: return await coro diff --git a/tradein-mvp/backend/tests/test_3449_geocoder_cancel_orphan.py b/tradein-mvp/backend/tests/test_3449_geocoder_cancel_orphan.py index 90e380f4..3f022203 100644 --- a/tradein-mvp/backend/tests/test_3449_geocoder_cancel_orphan.py +++ b/tradein-mvp/backend/tests/test_3449_geocoder_cancel_orphan.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio import os +import pathlib import threading import time from typing import Any @@ -86,3 +87,29 @@ async def test_geocode_budget_cancel_does_not_leave_orphan_in_session( "персист вошёл в сессию, пока осиротевший поток геокодера ещё работал в ней: " "два потока в одной Session → «another operation is in progress» на персисте" ) + + +def test_no_bare_to_thread_over_request_session() -> None: + """Source-гейт: в геокодере и эстиматоре не осталось голых `asyncio.to_thread(`. + + Тест выше ловит ОДНУ проводку — ту, через которую идёт сценарий. Остальные 33 + (`_cache_put`, `_fetch_anchor_comps`, персист, …) он не видит: возврат любой из + них в голый вид прошёл бы мимо CI. Оба модуля сейчас на нуле по живым вызовам, + поэтому гейт — ровно «ноль», без списка исключений. Понадобится вызов со СВОЕЙ + сессией (как `user_events.record_event`) — заводить его в отдельном модуле или + менять этот тест осознанно. + + Читаем через `module.__file__`: относительный путь зависел бы от cwd прогона. + """ + for module in (geo, est): + src = pathlib.Path(module.__file__ or "").read_text(encoding="utf-8") + bare = [ + f"{i}: {line.strip()}" + for i, line in enumerate(src.splitlines(), 1) + if "asyncio.to_thread(" in line and not line.lstrip().startswith("#") + ] + assert not bare, ( + f"{module.__name__}: голый asyncio.to_thread по сессии запроса — " + f"отмена оставит сироту в чужой Session (#3449), нужен run_db_thread:\n" + + "\n".join(bare) + )