From be9aa2f9071e59a1b234bcb765588a8651d5e4ee Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 13:57:49 +0500 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=B5=D0=B9=D1=82=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=92=D0=A1=D0=95=2034=20=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B8=20+=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B1?= =?UTF-8?q?=D1=8E=D0=B4=D0=B6=D0=B5=D1=82=D0=BE=D0=B2=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20#3460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сценарный тест ловил одну проводку из 34 — ту, через которую сам и шёл (`geocoder._cache_get`). Мутационный прогон ревьюера: возврат голого `asyncio.to_thread` в 5 из 6 других мест тест НЕ краснит, то есть регресс «кто-то вернул вызов в голый вид» прошёл бы мимо CI в 33 случаях из 34. `test_no_bare_to_thread_over_request_session` читает исходники geocoder и estimator (через `module.__file__`, не по относительному пути — он зависел бы от cwd прогона) и требует нуля живых `asyncio.to_thread(`. Оба модуля сейчас на нуле, поэтому гейт без списка исключений. Фальсификация — голый `to_thread` у `_fetch_anchor_comps` (estimator:4973, сценарным тестом не покрыт): гейт краснеет с номером строки. Второе: защита `run_db_thread` одноразовая — `except asyncio.CancelledError` ловит ОДНУ отмену, вторая вылетает из самого `asyncio.wait([step])`, и поток остаётся сиротой. Живых путей нет (`_with_budget` нигде не вложен, Starlette не отменяет задачу на дисконнекте, uvicorn стартует без `--timeout-graceful-shutdown`), поэтому кода не трогаю — фиксирую инвариант «не вкладывать бюджеты» в докстринге `_with_budget`, чтобы вложение не завезли как безобидное. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/estimator.py | 6 +++++ .../tests/test_3449_geocoder_cancel_orphan.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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) + )