Оценка не теряется, когда геокодер не уложился в бюджет (#3449) #3460

Merged
bot-backend merged 2 commits from fix/3449-geocoder-cancel-orphan into main 2026-09-12 10:11:20 +00:00
2 changed files with 33 additions and 0 deletions
Showing only changes of commit be9aa2f907 - Show all commits

View file

@ -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

View file

@ -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)
)