Оценка не теряется, когда геокодер не уложился в бюджет (#3449) #3460
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3460
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/3449-geocoder-cancel-orphan"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Оценка клиента больше не теряется из-за того, что геокодер не уложился в свои 12 секунд.
Closes #3449
Что было
asyncio.to_threadотменить нельзя. По истечении бюджета (estimator._with_budget=asyncio.wait_for) снимается только ожидание со стороны event loop'а — поток продолжает работать с ТОЙ ЖЕSession, что и весь запрос (Depends(get_db)). Вызывающий тем временем идёт дальше: следующий источник,_fetch_anchor_comps,_persist_estimate_and_commit. Два потока в однойSession→ «another operation is in progress» /InvalidRequestErrorна СЛЕДУЮЩЕМ шаге. Страдает не геокодер (его ошибку глушит_with_budget), а следующий потребитель сессии, и у персиста оценки её не ловит никто — 500 и потерянная оценка.Что сделано
app/core/db.py: run_db_thread(fn, *args, **kwargs)— ТОЛЬКО защита от сироты:ensure_future+shield, вexcept asyncio.CancelledErrorдождаться потока (asyncio.wait([step])), прочитатьstep.exception()(иначе asyncio печатает «Task exception was never retrieved» без контекста) и пробросить отмену.Commit/rollback в помощник НЕ вынесены умышленно: посреди геокодинга commit зафиксировал бы частичное состояние оценки.
estimator._db_step(образец из #3444 — он коммитит ради возврата коннекта в пул перед внешним HTTP) переписан поверхrun_db_threadи добавляет свои commit/rollback сам. Поведение прежнее, гейтtests/test_3408_db_step_cancel_orphan.pyзелёный.Заменено 34 вызова, работающих по сессии ЗАПРОСА:
app/services/geocoder.py_cache_get/_cache_put, геопортал, кадастр (house-match + forward),_local_houses_match, reverse, suggestapp/services/estimator.py_empty_estimate,match_house_readonly,_backfill_house_fias,_lookup_house_facts, 5×_fetch_analogs,_fetch_dkp_corridor,_save_yandex_history_items,_fetch_anchor_comps,_fetch_house_imv_anchor,_lookup_target_quarter_by_coords,_price_from_inputs(ему инжектятся db-резолверы_ratio_resolver/_qi_lookup),_fetch_deals,_persist_estimate_and_commit,_fetch_price_trend,_is_premium_buildingapp/api/v1/geocode.py_resolve_house_id_by_fias,_lookup_house_factsapp/api/v1/privacy_admin.pyerase_person_dataОставлены голыми (сессия СВОЯ, чужую не держат):
app/services/user_events.py:101—record_eventоткрывает свойSessionLocal(), декаплён от транзакции запроса by design.app/services/sber_index.py:429,457,518— сессия СВОЯ на прогон (app/tasks/sber_index_pull.py), чужую транзакцию осиротевший поток не портит; upsert идемпотентный (ON CONFLICT) и повторится на следующем такте. Поправка к первой редакции этого описания: отменяющий там ЕСТЬ — run-задачи спавнятся детачед (spawn_tracked, scraper-kit scheduler), и на teardownasyncio.run()отменяет оставшиеся. Вывод «оставить голым» не меняется, обоснование было неверным.Проверка
Гейт по значению —
tests/test_3449_geocoder_cancel_orphan.py:_with_budget(geocode(...), 0.05)при шаге БД в 0.3 с, следом ГОЛЫЙasyncio.to_thread(db.execute, "persist estimate")(образец персиста, а не ещё один защищённый вызов — защита, живущая только внутри обёртки, ровно того пострадавшего и не закрывает). Сессия-дублёр считает ОДНОВРЕМЕННЫЕ входы.Фальсификация: в
geocoder._geocode_resolveвременно возвращён голыйasyncio.to_thread(_cache_get, db, addr_norm)(безgit stash— руками, рядом работают другие агенты), тест краснеет:Правка возвращена, тест зелёный.
Второй гейт (коммит
be9aa2f9, по ревью): сценарный тест выше ловит ОДНУ проводку из 34 — ту, через которую сам и идёт; мутационный прогон ревьюера показал, что возврат гологоto_threadв 5 из 6 других мест он не краснит.test_no_bare_to_thread_over_request_sessionчитает исходники обоих модулей (черезmodule.__file__) и требует нуля живыхasyncio.to_thread(. Фальсификация — голыйto_threadу_fetch_anchor_comps(estimator.py:4973, сценарным тестом НЕ покрыт):Там же — предупреждение в докстринге
_with_budget: защитаrun_db_threadодноразовая (except asyncio.CancelledErrorловит ОДНУ отмену; вторая, прилетевшая во времяasyncio.wait([step]), вылетает из самого ожидания, и поток остаётся сиротой). Живых путей нет —_with_budgetнигде не вложен, Starlette не отменяет задачу на дисконнекте, uvicorn стартует без--timeout-graceful-shutdown— поэтому код не трогал, зафиксировал инвариант «не вкладывать бюджеты».Прогоны в
tradein-mvp/backend:uv run python -m pytest tests/ -q→ 5939 passed, 35 skipped за 148.72 с (rc=0; было 5938 до source-гейта).uv run ruff check app tests→ All checks passed.uv run ruff format --check app tests→ изменённые файлы отформатированы; 15 неформатных файлов в репозитории были такими ДО этой ветки и не тронуты.Чего эта правка НЕ закрывает
services/house_metadata.py: get_house_metadata(он тоже под бюджетом,estimate_house_meta_timeout_s) ходит в БД СИНХРОННО прямо на loop'е, безto_threadвообще. Сироты там нет по построению, но есть блокировка loop'а на время чекаута коннекта — это класс #3408 п.1, отдельная работа, в эту ветку не тащу.Приёмка из issue (ноль
another operation is in progress/InvalidRequestErrorв логахtradein-backendза сутки под нагрузкой) проверяется ПОСЛЕ деплоя — здесь не заявляю.