fix(tradein/proxy): операции пула прокси уходят с event loop публичного /estimate — acurl_proxy_url через to_thread (#3398 п.1) #3407

Merged
bot-backend merged 2 commits from fix/3398-pool-ops-off-event-loop into main 2026-09-06 11:55:10 +00:00
Collaborator

Часть #3398 (п. 1). Не закрывает issue.

Дефект. На /estimate (1 воркер uvicorn, #3083) вход в curl_proxy_url синхронный и до первого await (IMV imv.py:533, yandex valuation.py:200 __aenter__, cian valuation.py:196): acquire/mark_health/mark_banned/release через RealProxyProvider ходят в БД на event loop — 3 источника на cache-miss; _with_budget не может прервать синхронный вход; при исчерпанном пуле коннектов checkout ждёт до 30 с, держа loop для всех запросов.

Фикс (scraper_kit/providers/_proxy.py): async-обёртка acurl_proxy_url над тем же синхронным curl_proxy_url (он не тронут): __enter__/__exit__ через asyncio.to_thread; выход тоже в потоке (иначе mark_health/release держали бы loop так же); except BaseException + cm.__exit__(type, exc, tb) сохраняет #3397 (отмена → ok=Falsemark_health(False) + release). Новое по построению: отмена может прийти ВО ВРЕМЯ acquire (раньше невозможно) — поток всё равно выдаст аренду, поэтому вход под asyncio.shield, а в except дожидаемся его и делаем __exit__ в потоке, иначе wait_for-таймаут при исчерпанном пуле оставлял бы lease до reap_stale_leases.

Переключены три async-сайта /estimate: avito/imv.py::evaluate_via_imv (AsyncExitStack), yandex/valuation.py::YandexValuationScraper.__aenter__/__aexit__, cian/valuation.py::estimate_via_cian_valuation (async with). Оставлены на sync: cian/detail.py::fetch_detail, cian/newbuilding.py::resolve_cian_zhk_url_via_search — async, но под scheduler'ом в tradein-scraper (loop не публичный); прямых вызовов из backend/app нет.

Thread-safety: RealProxyProvider stateless, SessionLocal() на операцию; to_thread копирует contextvarscurrent_run_id из #3405 виден в acquire; NoProxyAvailableError/ProxyBanError пробрасываются тем же типом (caused_by_no_proxy работает).

Тесты tests/test_3398_pool_ops_off_event_loop.py (6, через настоящий curl_proxy_url и настоящий YandexValuationScraper.__aenter__; провайдер-дублёр time.sleep(0.3)): loop не блокируется во время acquire (соседняя корутина тикает >100 раз); lease ровно один раз на успехе / ошибке / таймауте внутри тела / отмене во время acquire; NoProxyAvailableError переживает to_thread.

Фальсификация (git apply -R kit-файлов): loop простоял всё время acquire: тиков всего 0 и DID NOT RAISE TimeoutError — 2 failed по значению; четыре release-теста зелёные и на main (регресс-гейт обёртки).

Прогоны: полный backend 5617 passed, 35 skipped (rc=0); ruff OK.

Приёмка на проде: p95 /estimate на cache-miss не растёт; те же leased/released proxy строки (3 на запрос), без роста reap_stale_leases; атрибуция узла (#3404) у прогонов scheduler'а проставляется.

Часть #3398 (п. 1). Не закрывает issue. **Дефект.** На `/estimate` (1 воркер uvicorn, #3083) вход в `curl_proxy_url` синхронный и до первого `await` (IMV `imv.py:533`, yandex `valuation.py:200` `__aenter__`, cian `valuation.py:196`): `acquire`/`mark_health`/`mark_banned`/`release` через `RealProxyProvider` ходят в БД на event loop — 3 источника на cache-miss; `_with_budget` не может прервать синхронный вход; при исчерпанном пуле коннектов checkout ждёт до 30 с, держа loop для всех запросов. **Фикс** (`scraper_kit/providers/_proxy.py`): async-обёртка `acurl_proxy_url` над тем же синхронным `curl_proxy_url` (он не тронут): `__enter__`/`__exit__` через `asyncio.to_thread`; выход тоже в потоке (иначе `mark_health`/`release` держали бы loop так же); `except BaseException` + `cm.__exit__(type, exc, tb)` сохраняет #3397 (отмена → `ok=False` → `mark_health(False)` + `release`). Новое по построению: отмена может прийти ВО ВРЕМЯ `acquire` (раньше невозможно) — поток всё равно выдаст аренду, поэтому вход под `asyncio.shield`, а в `except` дожидаемся его и делаем `__exit__` в потоке, иначе `wait_for`-таймаут при исчерпанном пуле оставлял бы lease до `reap_stale_leases`. **Переключены** три async-сайта `/estimate`: `avito/imv.py::evaluate_via_imv` (`AsyncExitStack`), `yandex/valuation.py::YandexValuationScraper.__aenter__/__aexit__`, `cian/valuation.py::estimate_via_cian_valuation` (`async with`). **Оставлены** на sync: `cian/detail.py::fetch_detail`, `cian/newbuilding.py::resolve_cian_zhk_url_via_search` — async, но под scheduler'ом в `tradein-scraper` (loop не публичный); прямых вызовов из `backend/app` нет. **Thread-safety:** `RealProxyProvider` stateless, `SessionLocal()` на операцию; `to_thread` копирует `contextvars` → `current_run_id` из #3405 виден в `acquire`; `NoProxyAvailableError`/`ProxyBanError` пробрасываются тем же типом (`caused_by_no_proxy` работает). **Тесты** `tests/test_3398_pool_ops_off_event_loop.py` (6, через настоящий `curl_proxy_url` и настоящий `YandexValuationScraper.__aenter__`; провайдер-дублёр `time.sleep(0.3)`): loop не блокируется во время `acquire` (соседняя корутина тикает >100 раз); lease ровно один раз на успехе / ошибке / таймауте внутри тела / отмене во время `acquire`; `NoProxyAvailableError` переживает `to_thread`. **Фальсификация** (`git apply -R` kit-файлов): `loop простоял всё время acquire: тиков всего 0` и `DID NOT RAISE TimeoutError` — 2 failed по значению; четыре release-теста зелёные и на main (регресс-гейт обёртки). **Прогоны:** полный backend `5617 passed, 35 skipped` (rc=0); ruff OK. **Приёмка на проде:** p95 `/estimate` на cache-miss не растёт; те же `leased/released proxy` строки (3 на запрос), без роста `reap_stale_leases`; атрибуция узла (#3404) у прогонов scheduler'а проставляется.
bot-backend added 1 commit 2026-09-06 11:08:46 +00:00
fix(tradein/proxy): операции пула прокси уходят с event loop публичного /estimate (#3398)
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 11s
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 5m8s
e4feadefe6
Вход в `providers/_proxy.py::curl_proxy_url` синхронный и стоял ДО первого await во
всех трёх async-сайтах `/estimate` (avito/imv, yandex/valuation, cian/valuation).
`RealProxyProvider.acquire/mark_health/release` ходят в БД синхронно (своя SessionLocal
на операцию), а публичный backend крутится на ОДНОМ воркере uvicorn (#3083): на
cache-miss это 3 источника x (acquire + mark_health + release) блокирующих вызовов
прямо на loop'е. `_with_budget(asyncio.wait_for)` синхронный вход прервать не может, а
при исчерпанном пуле коннектов (5+10) checkout ждёт до 30 с — весь инстанс молчит.

`acurl_proxy_url` — async-обёртка над тем же синхронным контекстом: вход и выход через
`asyncio.to_thread`, выход тоже (иначе mark_health/release держали бы loop на выходе).
Семантика прежняя: `NoProxyAvailableError` до запроса, BaseException-ветка (#3397:
отмена → health=False), release в finally. `to_thread` копирует contextvars, поэтому
`current_run_id` (#3404) виден в потоке как раньше.

Новое по сравнению с sync-путём: отмена может прийти ВО ВРЕМЯ acquire (раньше это было
невозможно по построению). Вход держится через `asyncio.shield` и добирается в except —
иначе выданная в потоке аренда висела бы до reap_stale_leases.

Переключены только три сайта `/estimate`. Sync-вызывающие и async-сайты под scheduler
(cian/detail.py::fetch_detail, cian/newbuilding.py::resolve_cian_zhk_url_via_search)
остаются на `curl_proxy_url` — там loop не обслуживает публичные запросы.
Author
Collaborator

Deep-ревью (06.09): ⚠️ minor. Исполнением: отмена во время acquireTimeoutError, release ровно один раз; гипотеза про RuntimeError неверна — генератор без raise дал бы __exit__ → True и ТИХОЕ подавление отмены (raise на месте, контракт цел); двойной __exit__ невозможен и безвреден; current_run_id (#3405) виден в потоке ([4242]); executor 16 потоков при спросе ≤12; AsyncExitStack корректен.

Докатываю до мержа: M1 — тест отмены-на-входе зелёный и без ветки except BaseException (аренду возвращал рефкаунт генератора при shutdown_default_executor ДО assert) → срез released внутри корутины (мутация: [] != [7]); M2 — при второй отмене во время await enter __exit__ не вызывается → while not enter.done(): await shield(enter); M3 — потолок бюджета в докстринге (ждём поток целиком). Follow-up #3408: пул коннектов 15 против пикового спроса до 28 (пул-операции больше не сериализуются loop'ом), sync db.execute в estimator.py:973/1062/1093, cian_price_history.py:140 на публичном loop'е через админ-ручку, бюджет на ветке acquire.

Deep-ревью (06.09): ⚠️ minor. Исполнением: отмена во время `acquire` → `TimeoutError`, release ровно один раз; гипотеза про `RuntimeError` неверна — генератор без `raise` дал бы `__exit__ → True` и ТИХОЕ подавление отмены (raise на месте, контракт цел); двойной `__exit__` невозможен и безвреден; `current_run_id` (#3405) виден в потоке (`[4242]`); executor 16 потоков при спросе ≤12; `AsyncExitStack` корректен. Докатываю до мержа: **M1** — тест отмены-на-входе зелёный и без ветки `except BaseException` (аренду возвращал рефкаунт генератора при `shutdown_default_executor` ДО `assert`) → срез `released` внутри корутины (мутация: `[] != [7]`); **M2** — при второй отмене во время `await enter` `__exit__` не вызывается → `while not enter.done(): await shield(enter)`; **M3** — потолок бюджета в докстринге (ждём поток целиком). Follow-up #3408: пул коннектов 15 против пикового спроса до 28 (пул-операции больше не сериализуются loop'ом), sync `db.execute` в `estimator.py:973/1062/1093`, `cian_price_history.py:140` на публичном loop'е через админ-ручку, бюджет на ветке acquire.
Light1YT added 1 commit 2026-09-06 11:49:32 +00:00
fix(#3398): acurl_proxy_url — вторая отмена не бросает lease; тест меряет наш release, а не teardown; потолок бюджета в докстринге
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
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 5m11s
7d3c0eab51
bot-backend merged commit f52a39e3ad into main 2026-09-06 11:55:10 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3407
No description provided.