Оценка не теряется, когда геокодер не уложился в бюджет (#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 commits

Author SHA1 Message Date
be9aa2f907 Гейт на ВСЕ 34 проводки + запрет вложенных бюджетов (ревью #3460)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Сценарный тест ловил одну проводку из 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 <noreply@anthropic.com>
2026-09-12 13:57:49 +05:00
c251c02f1e Отмена по бюджету больше не оставляет сироту в сессии запроса (#3449)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m24s
`asyncio.to_thread` отменить нельзя: по истечении бюджета (`_with_budget` =
`asyncio.wait_for`, у геокодера 12 с) снимается только ожидание со стороны
loop'а — поток продолжает работать с ТОЙ ЖЕ `Session`, что и весь запрос.
Вызывающий тем временем идёт дальше: следующий источник, `_fetch_anchor_comps`,
`_persist_estimate_and_commit`. Два потока в одной `Session` дают «another
operation is in progress» / InvalidRequestError на СЛЕДУЮЩЕМ шаге. У источников
эту ошибку глушит `except` вокруг вызова, у персиста оценки не глушит никто —
500 и потерянная оценка клиента.

`app/core/db.py: run_db_thread` — ТОЛЬКО защита от сироты: `ensure_future` +
`shield`, на отмене дождаться потока (`asyncio.wait`), прочитать
`step.exception()` (иначе asyncio печатает «Task exception was never
retrieved» без контекста) и пробросить отмену. Commit/rollback туда НЕ вынесены:
посреди геокодинга commit зафиксировал бы частичное состояние оценки.
`estimator._db_step` переписан поверх и добавляет свои commit/rollback сам —
его поведение не меняется, гейт tests/test_3408_db_step_cancel_orphan.py
остаётся зелёным.

Заменено 34 вызова, работающих по сессии запроса: 12 в geocoder.py (кэш-чтение
и записи, геопортал, кадастр, houses, reverse, suggest), 19 в estimator.py
(в т.ч. `_backfill_house_fias`, `_save_yandex_history_items`,
`_fetch_anchor_comps`, `_price_from_inputs` с db-резолверами, персист оценки,
`_fetch_price_trend`, `_is_premium_building`), 2 в api/v1/geocode.py, 1 в
api/v1/privacy_admin.py. Не тронуты вызовы со СВОЕЙ сессией:
`user_events.schedule_event` (внутри `record_event` свой `SessionLocal`) и
`sber_index` (сессия задачи планировщика, отменять её некому).

Гейт по значению — tests/test_3449_geocoder_cancel_orphan.py: отмена по бюджету
во время шага БД геокодера, следом ГОЛЫЙ `to_thread(db.execute, ...)` (образец
персиста); проверяется, что он не вошёл в сессию, пока сирота ещё в ней.
На исходном коде тест краснеет: conflicts == 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:20:44 +05:00