fix(tradein): пустой пул на старте батча пишет no_proxy_stop у cian/domclick/avito detail-бэкфиллов (#3384) #3388

Merged
bot-backend merged 2 commits from fix/3384-no-proxy-at-batch-start into main 2026-09-06 02:24:38 +00:00
Collaborator

Closes #3384.

Дефект. BrowserFetcher._acquire_lease() живёт в __aenter__; при пустом пуле до первого объявления NoProxyAvailableError вылетает из async with BrowserFetcher(...) (cian :218, domclick :403) и из прямого await browser_fetcher.__aenter__() у avito (avito_detail_backfill.py:427) — мимо стоп-механики внутри цикла. Общие except Exception писали mark_failed со стухшими нулевыми counters без no_proxy_stop → SQL-разбор простоя по counters.no_proxy_stop (#3288/#3367) такие прогоны не находил. У avito дыра та же — стоп #3288 живёт внутри цикла, до которого дело не доходит.

Фикс (вариант B, три правки по ~6 строк в точках, которые и так зовут mark_failed):

  • app/services/scheduler.py:209-218 (cian-исполнитель) — caused_by_no_proxy(exc)counters["no_proxy_stop"] = 1 перед mark_failed;
  • app/tasks/domclick_detail_backfill.py:645-654 — то же через уже живущий _caused_by_empty_pool;
  • app/tasks/avito_detail_backfill.py:1091-1100 — то же.
    Вариант A (try/except вокруг async with) потребовал бы переиндентировать сотни строк в трёх файлах и ранний return с ручной финализацией у avito; B ловит ещё и любой путь мимо цикла (ре-acquire при ротации, блок домов у cian).

Тест tests/test_3384_no_proxy_at_batch_start.py (3 теста): подделка одна — провайдер, чей acquire() поднимает NoProxyAvailableError; BrowserFetcher настоящий; httpx.AsyncClient.postassert_not_called; mark_done/mark_banned/mark_backfill_finished не вызваны; status='failed', no_proxy_stop=1, attempted=0.

Фальсификация (источники откачены, тест на месте): KeyError: 'no_proxy_stop' × 3 — при этом в логе BrowserFetcher: proxy_pool acquire(avito) empty in production — refusing (no HTTP request) и трасса __aenter__ → _acquire_lease → raise NoProxyAvailableError — отказ родился в настоящем фетчере, mark_failed вызван без диагноза. Восстановлено — зелёное.

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

Приёмка на проде (событийная): прогон любого из трёх источников при пустом пуле на старте — status='failed', counters.no_proxy_stop=1, attempted=0 (cian: listings_processed=0), в логах proxy_pool acquire(<source>) empty in production — refusing без POST в сайдкар; попадает в выборку разбора простоя по no_proxy_stop.

Closes #3384. **Дефект.** `BrowserFetcher._acquire_lease()` живёт в `__aenter__`; при пустом пуле до первого объявления `NoProxyAvailableError` вылетает из `async with BrowserFetcher(...)` (cian `:218`, domclick `:403`) и из прямого `await browser_fetcher.__aenter__()` у avito (`avito_detail_backfill.py:427`) — мимо стоп-механики внутри цикла. Общие `except Exception` писали `mark_failed` со стухшими нулевыми counters без `no_proxy_stop` → SQL-разбор простоя по `counters.no_proxy_stop` (#3288/#3367) такие прогоны не находил. **У avito дыра та же** — стоп #3288 живёт внутри цикла, до которого дело не доходит. **Фикс (вариант B, три правки по ~6 строк в точках, которые и так зовут `mark_failed`):** - `app/services/scheduler.py:209-218` (cian-исполнитель) — `caused_by_no_proxy(exc)` → `counters["no_proxy_stop"] = 1` перед `mark_failed`; - `app/tasks/domclick_detail_backfill.py:645-654` — то же через уже живущий `_caused_by_empty_pool`; - `app/tasks/avito_detail_backfill.py:1091-1100` — то же. Вариант A (`try/except` вокруг `async with`) потребовал бы переиндентировать сотни строк в трёх файлах и ранний return с ручной финализацией у avito; B ловит ещё и любой путь мимо цикла (ре-acquire при ротации, блок домов у cian). **Тест** `tests/test_3384_no_proxy_at_batch_start.py` (3 теста): подделка одна — провайдер, чей `acquire()` поднимает `NoProxyAvailableError`; `BrowserFetcher` настоящий; `httpx.AsyncClient.post` — `assert_not_called`; `mark_done`/`mark_banned`/`mark_backfill_finished` не вызваны; `status='failed'`, `no_proxy_stop=1`, `attempted=0`. **Фальсификация** (источники откачены, тест на месте): `KeyError: 'no_proxy_stop'` × 3 — при этом в логе `BrowserFetcher: proxy_pool acquire(avito) empty in production — refusing (no HTTP request)` и трасса `__aenter__ → _acquire_lease → raise NoProxyAvailableError` — отказ родился в настоящем фетчере, `mark_failed` вызван без диагноза. Восстановлено — зелёное. **Прогоны:** полный backend `5512 passed, 35 skipped` (rc=0); ruff OK. **Приёмка на проде (событийная):** прогон любого из трёх источников при пустом пуле на старте — `status='failed'`, `counters.no_proxy_stop=1`, `attempted=0` (cian: `listings_processed=0`), в логах `proxy_pool acquire(<source>) empty in production — refusing` без POST в сайдкар; попадает в выборку разбора простоя по `no_proxy_stop`.
bot-backend added 1 commit 2026-09-06 00:50:50 +00:00
fix(tradein): пустой пул ДО первого объявления теряет диагноз в записи прогона (#3384)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 4m55s
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
caa3b8b056
Lease берётся один раз в BrowserFetcher.__aenter__, поэтому на проде с пустым пулом
NoProxyAvailableError вылетает из самого `async with` — ДО первой карточки и мимо
стоп-механики внутри цикла (no_proxy_stop = True; break), которая и пишет
counters.no_proxy_stop. Общий `except Exception` ловил его и делал mark_failed с
нулевыми counters без ключа: прогон, который к площадке не ходил вообще, в SQL-разборе
простоя по counters.no_proxy_stop (#3288/#3367) не находится.

Дыра одинаковая у всех трёх бэкфиллов (avito её тоже не обрабатывал: __aenter__
вызывается напрямую строкой 427, отказ уходит в тот же общий except). Правка — в трёх
уже существующих обработчиках, которые и так зовут mark_failed: ключ no_proxy_stop=1
при caused_by_no_proxy(exc). Оборачивать `async with` в try/except пришлось бы с
переносом ~200 строк тела под новый отступ в каждом файле, и покрывало бы только
падение на входе; здесь ловится любой путь мимо цикла.

Тест — через настоящий BrowserFetcher: подделан только провайдер прокси (его acquire
поднимает NoProxyAvailableError), отказ рождается там же, где в проде. Проверяется
failed + no_proxy_stop=1 + attempted=0 (у циана listings_processed=0) и ноль POST'ов
в сайдкар.

Closes #3384
Author
Collaborator

Code-review (07.09): — трассировка всех трёх except по коду (промежуточных перехватчиков нет; у avito own_browser=True ставится ПОСЛЕ __aenter__, BrowserFetcher.__aenter__ сам закрывает клиент при отказе — течь нечему); контракты #3196/#2764/#3367 не задеты (гейт типовой по цепочке причин); no_proxy_stop не входит в _RESULT_COUNTER_KEYS → свежесть на failed не обновляется; тест не тавтологичен — исключение рождает боевой browser_fetcher.py:712.

Докатываю до мержа один реальный минор: у cian counters в этом except — предынициализированный нулевой словарь, а mark_failed мержит через jsonb || → пул, опустевший на стадии домов, затёр бы нулями уже записанное heartbeat'ом (и SQL-разбор прочитал бы «к площадке не ходили» про прогон, который ходил). В mark_failed из этой ветки уходит только флаг. Плюс стаб провайдера приводится к проду (acquire → None, отказ рождается в _acquire_lease).

Code-review (07.09): ✅ — трассировка всех трёх `except` по коду (промежуточных перехватчиков нет; у avito `own_browser=True` ставится ПОСЛЕ `__aenter__`, `BrowserFetcher.__aenter__` сам закрывает клиент при отказе — течь нечему); контракты #3196/#2764/#3367 не задеты (гейт типовой по цепочке причин); `no_proxy_stop` не входит в `_RESULT_COUNTER_KEYS` → свежесть на `failed` не обновляется; тест не тавтологичен — исключение рождает боевой `browser_fetcher.py:712`. Докатываю до мержа один реальный минор: у cian `counters` в этом `except` — предынициализированный нулевой словарь, а `mark_failed` мержит через jsonb `||` → пул, опустевший на стадии домов, затёр бы нулями уже записанное heartbeat'ом (и SQL-разбор прочитал бы «к площадке не ходили» про прогон, который ходил). В `mark_failed` из этой ветки уходит только флаг. Плюс стаб провайдера приводится к проду (`acquire → None`, отказ рождается в `_acquire_lease`).
Light1YT added 1 commit 2026-09-06 01:29:02 +00:00
fix(#3384): cian — в mark_failed уходит только измеренное, нули heartbeat не затираются; стаб пула как прод
All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
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 5m0s
47dabec678
Ревью нашло у цианa (в отличие от avito/домклика с живым counters.to_dict()) старый
словарь в общем except: реальные значения присваиваются уже ПОСЛЕ возврата из
backfill_cian_history, а отказ бывает и посреди неё — пул опустел между стадиями, упал
SELECT домов. Тогда поверх измеренного в запись прогона уезжали нули, и SQL-разбор
простоя (#3288/#3367) читал «к площадке не ходили» про прогон, который ходил.

Механизм оказался хуже описанного в ревью: mark_failed мержит counters (`counters ||
:counters`) только в kit-копии, а cian/avito/домклик зовут app.services.scrape_runs, где
UPDATE counters ЗАМЕНЯЕТ (scrape_runs.py:738). Поэтому «отдать только {no_proxy_stop: 1}»
стёрло бы измеренное начисто; вместо этого _heartbeat кладёт свой снимок в те же
counters (nonlocal), и в mark_failed уезжает последнее измеренное + флаг.

Тест по значению: heartbeat записал listings_processed=5, дальше пул пуст → в jsonb-
payload mark_failed должно остаться 5, а не 0 (проверяется сам payload UPDATE'а,
runs_mod настоящий). На HEAD ветки красный: `counters={'listings_processed': 0, ...,
'no_proxy_stop': 1}: нули поверх измеренных 5`.

Стаб пула приведён к проду: RealProxyProvider.acquire при пустом пуле ВОЗВРАЩАЕТ None
(scraper_adapters.py:230), а не поднимает, — исключение из провайдера глотал
`except Exception` в _acquire_lease и приходило к тому же отказу другим путём. Теперь
NoProxyAvailableError рождается там же, где в проде (browser_fetcher.py:712, ветка
`lease is None and use_pool and production`) — проверено прогоном против до-#3384
исходников: все три теста красные, трейс из _acquire_lease.

_prod_pool патчит app.core.config.settings явно + assert, что все три задачи держат тот
же синглтон: раньше патч через chb.settings выглядел настройкой одного циана.
Author
Collaborator

Доработка (47dabec6): рецепт ревью «отдать в mark_failed только флаг» на этом пути потерял бы данныеscheduler.py:50 импортирует app.services.scrape_runs, где mark_failed counters ЗАМЕНЯЕТ (scrape_runs.py:738), мерж || только в kit-копии (scraper_kit/orchestration/runs.py). Вывод ревью (нули не должны ложиться поверх замера) верен, обоснование — нет; проверено по импорту. Сделано: _heartbeat держит последний снимок в тех же counters, в mark_failed уезжает измеренное + no_proxy_stop=1. Тест краснел на HEAD assert 0 == 5 (нули поверх измеренных 5). Стаб провайдера приведён к проду (acquire → None, отказ рождается в _acquire_lease:712, в логе прод-warning proxy_pool acquire(cian) empty in production — refusing). Две копии одной функции с противоположной семантикой — #3390.

Доработка (47dabec6): рецепт ревью «отдать в `mark_failed` только флаг» на этом пути **потерял бы данные** — `scheduler.py:50` импортирует `app.services.scrape_runs`, где `mark_failed` counters ЗАМЕНЯЕТ (`scrape_runs.py:738`), мерж `||` только в kit-копии (`scraper_kit/orchestration/runs.py`). Вывод ревью (нули не должны ложиться поверх замера) верен, обоснование — нет; проверено по импорту. Сделано: `_heartbeat` держит последний снимок в тех же `counters`, в `mark_failed` уезжает измеренное + `no_proxy_stop=1`. Тест краснел на HEAD `assert 0 == 5` (нули поверх измеренных 5). Стаб провайдера приведён к проду (`acquire → None`, отказ рождается в `_acquire_lease:712`, в логе прод-warning `proxy_pool acquire(cian) empty in production — refusing`). Две копии одной функции с противоположной семантикой — #3390.
bot-backend merged commit 6444ff7e37 into main 2026-09-06 02:24:38 +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#3388
No description provided.