Пул прокси МЕРА: оплаченный выделенный узел снова выдаётся, бан не выбивает последний узел, прогон знает свой прокси #3565

Merged
bot-backend merged 11 commits from fix/proxy-pool-guards into main 2026-09-17 10:49:04 +00:00
Collaborator

Четыре дефекта пула прокси МЕРА, по коммиту на каждый, плюс правки по ревью отдельными коммитами. Миграций нет.

#3299 — выделенный узел не выдавался никому

Было. Запасной заход acquire() и защита последнего узла в mark_banned() спрашивали, есть ли у выделенной привязки ВТОРОЙ узел той же привязки. Выделенный узел штучный, поэтому ответ почти всегда «нет». Итог: такой узел не получал ни один источник, хотя «свой» источник обслуживали узлы 'any'.

Улики (прод, только чтение, 17.09). Включены узлы 1 (yandex), 13 и 14 (any), 15 (avito). Узел 14 забанен Цианом до 18.09 01:42, узел 1 забанен Цианом до 30.09, узел 13 арендован прогоном 7344. Значит, для cian основной заход пуст, а свободный и здоровый узел 15 fallback не отдавал: EXISTS искал другой узел с provider_affinity = 'avito', а такого нет. 30.08 по той же причине легли прогоны добора Домклика 5449–5459.

Сделано. tradein-mvp/backend/app/services/proxy_pool.py. Резервом теперь считается узел той же привязки или 'any'. Он должен быть здоров (consecutive_fails), с живой арендой порта и без бана от источника выделенной привязки. Предикат одинаковый в acquire и в mark_banned. leased_by намеренно не проверяется: аренда временная, иначе защита срабатывала бы при каждом параллельном прогоне. Этот довод уже записан у mark_banned.

Тесты. tests/test_3299_fallback_counts_any_nodes.py на живом Postgres: схема из миграций, внешняя транзакция с откатом. 10 случаев:

  • три из приёмки issue;
  • резерв в карантине по consecutive_fails;
  • два зеркальных случая для mark_banned: бан записан, и источник действительно получает узел / защита держит;
  • по ревью: резерв с истёкшей арендой в acquire, резерв в карантине и резерв с истёкшей арендой в mark_banned (каждое условие предиката резерва теперь краснеет само, см. «Правки по ревью»);
  • по ревью: внешний отбор mark_banned не засчитывает источнику узел с истёкшей арендой (дефект был и на main, исправлен здесь же).

В моке tests/services/test_proxy_pool.py primary и fallback различаются по полному фрагменту IN (:provider, 'any'): с этой правкой короткий IN есть и в подзапросе. Новый предикат в моке включается по подстроке, как остальные. test_acquire_fallback_prefers_non_expired_over_expired получил третий узел-резерв. Без него живой узел оказывается последним для cian: просроченный узел резервом больше не считается, и это правильно.

Фальсификация.

  • proxy_pool.py из origin/main:
    E       AssertionError: домклику выдан None
    E       AssertionError: assert 'protected' == 'banned'
    2 failed, 4 passed
    
  • Из подзапроса убрана только проверка consecutive_fails:
    E       AssertionError: assert ProxyLease(id=42, ...) is None
    FAILED ...::test_unhealthy_any_node_is_not_a_backup
    1 failed, 5 passed
    

Изменение поведения — решение владельца. Новый предикат пускает в fallback не только узел 15. Read-only SELECT предиката на проде 17.09 09:04 UTC: узел 1 (asocks-residential-1, affinity yandex) проходит fallback для avito, cian и domclick (старый предикат — нет), потому что у yandex есть здоровые 'any'-узлы 13/14; узел 15 (avito) проходит его для cian, domclick и yandex. Сейчас узел 1 забанен всеми тремя источниками до 30.09, так что сразу ничего не изменится. Если residential-трафик узла 1 оплачивается по объёму, пускать ли его под чужие источники после 30.09 — решать владельцу.

Приёмка на проде после деплоя.

  1. Тот же read-only SELECT: для узла 15 при source = cian/domclick fallback возвращает true.
  2. В логах tradein-scraper при занятых 13/14 появляется FALLBACK affinity с id=15.
  3. Известная цена компромисса leased_by (резерв аренду не проверяет): на раскладе 17.09 (13 арендован прогоном, 14 забанен avito до 16:45) fallback отдаст 15 Циану, и у avito не будет свободного узла, пока 13 не освободится. Если после деплоя в этом окне появятся падения avito с «pool empty», это эта цена, а не регрессия.

#3310 — бан на curl-пути копил счётчик отказов и выводил последний узел

Было. Защита в mark_banned не пишет бан последнему узлу и обещает «узел продолжит выдаваться». Но curl_proxy_url на том же ProxyBanError вслед за баном звал mark_health(ok=False). После трёх 403 или капч подряд consecutive_fails = 3, и acquire отсекал узел для всех источников. Браузерный путь это исправил в #3288 (PR #3357, report_platform_banhealth=False). Тем же способом проверено на проде: 155 срабатываний защиты за 07.09–17.09, у узлов 13/14/15 consecutive_fails 0. Curl-путь остался с дефектом, через него ходят cian detail, резолв ЖК, история цен Циана и оценщик.

Сделано. tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py: на ProxyBanError зовётся mark_banned ВМЕСТО mark_health(False). Транспортные сбои по-прежнему засчитываются узлу.

Тесты. tests/test_3310_curl_ban_keeps_last_node.py на живом Postgres, настоящим трактом curl_proxy_urlRealProxyProviderproxy_pool. Сессии адаптера привязаны к транзакции теста.

  • Единственный узел и три CianBlockedError: consecutive_fails == 0, бан-строк 0 (сработала защита), acquire('cian') выдаёт узел. Это п.1–3 приёмки: воспроизведение, двусторонний тест по значению, обещание из лога подтверждено выдачей.
  • Обратная сторона: OSError даёт consecutive_fails == 1.

Пять старых проверок в test_proxy_pool_curl_paths.py, test_2700_*, test_3402_* и test_2830_* (2 шт.) ждали mark_health(False) на бане, то есть фиксировали сам дефект. Они поправлены.

Фальсификация (_proxy.py из origin/main):

E       assert 3 == 0
E        +  where 3 = _fails(<Session>, 63)
E       assert [(7, False)] == []
6 failed, 47 passed

Приёмка на проде после деплоя. После строки «бан не записан: это последний узел» из curl-пути (Циан/оценщик) у этого узла не растёт consecutive_fails, и он продолжает выдаваться. П.4 issue не выполнен, поэтому Refs #3310, а не Closes. П.4: avito_detail_backfill не падает в «pool empty» при enabled-узле. Прод, только чтение, 17.09: прогоны 7360 (07:46), 7328, 7315, 7292, 7242, 7203, 7172 и 7104 упали с no proxy available for provider='avito' (pool empty in prod, refusing env/direct fallback — #2616). Расклад на момент 7360: узел 13 арендован domclick_city_sweep_moskva 7344 (06:12–08:36), узлы 14 и 15 забанены avito (до 16:45 и до 10:46). Свободного незабаненного узла для avito не было. Это нехватка узлов (#2638), а не дефект этого PR, и эта правка такие падения не лечит. #3310 закрыть после прод-приёмки выше; п.4 остаётся за #2638.

#3404 — у egress-пути прогон не знал свой узел

Было. PR #3405 писал scrape_runs.proxy_id только из proxy_pool.acquire(). Прогоны, которые берут прокси через proxy_egress.resolve_proxy_url без аренды, атрибуцию не получали. Это yandex_detail_backfill, yandex_address_backfill и curl-ветка avito_detail_backfill. Прод, 7 суток до 17.09:

source прогонов с proxy_id
yandex_detail_backfill 56 0
yandex_address_backfill 1 0
avito_detail_backfill 56 39
cian_detail_backfill 28 26

Сделано. resolve_proxy_url, если узел выбран и current_run_id выставлен, зовёт attribute_run_proxy. Вызов идёт своей короткой сессией: db вызывающего — долгоживущая сессия прогона посреди работы, а атрибуция коммитит и на сбое откатывает. current_run_id доходит до задачи: _claim_run выставляет его до asyncio.create_task, и тот же канал уже работает у acquire-пути. Часть B #3404 (гашение бана вместо DELETE) была на проде и раньше: строки узла 13 погашены 16.09 с cleared_reason.

Тесты. tests/test_3404_egress_run_attribution.py на живом Postgres:

  • внутри прогона пишутся proxy_id и counters.proxy_ids;
  • вне прогона scrape_runs не тронут;
  • незакоммиченное изменение вызывающего не коммитится.

Фальсификация.

  • proxy_egress.py из origin/main:
    E       assert None == 70
    2 failed, 1 passed
    
  • Атрибуция через db вызывающего вместо своей сессии:
    E       AssertionError: атрибуция закоммитила чужую транзакцию
    E       assert 't3404' not in {'t3404': 1}
    

Приёмка на проде через сутки после деплоя.

SELECT source, count(*), count(proxy_id) FROM scrape_runs
WHERE source IN ('yandex_detail_backfill','yandex_address_backfill') AND started_at > '<время деплоя>'
GROUP BY 1;

count(proxy_id) больше 0 и почти равен числу прогонов. NULL допустим только у прогонов, упавших до выбора прокси.

#3408 п.3 — детальная Циана держала event loop операциями пула

Было. providers/cian/detail.py::fetch_detail без session/browser_fetcher входил в синхронный curl_proxy_url. Вызывающий — админ-ручка истории цен Циана в публичном tradein-backend (один воркер uvicorn): пара блокирующих походов в БД на каждый листинг батча. #3398 так уже перевёл три сайта /estimate, этот остался.

Сделано. async with acurl_proxy_url(...): вход и выход идут в потоке, ProxyBanError/CianBlockedError изнутри блока доходят до пула как раньше (test_2700/test_3402 зелёные). В докстринге acurl_proxy_url поправлен потолок: pool_timeout теперь 5 с (#3444), а не 30.

Тест. tests/test_3408_cian_detail_pool_ops_off_loop.py, по образцу test_3398: acquire спит 0,3 с, соседняя корутина тикает. Фальсификация (detail.py из origin/main):

E       AssertionError: loop простоял всё время acquire: тиков всего 0

Приёмка на проде после деплоя. Вызвать админ-ручку истории цен Циана на маленьком батче. В логе на каждый листинг должны быть leased/released, строк QueuePool — 0.

Пп.1, 2, 4 #3408 по триажу закрыты PR #3444 (прод-приёмка в комментарии к issue). Этот PR делает только п.3, поэтому issue не закрывает: закрытие — после приёмки п.3.

Правки по ревью

Каждую находку сначала проверил сам. Опровергать не пришлось: всё, что проверялось, подтвердилось.

1. Закрывающая ссылка на #3310 заменена на Refs #3310 (блокирующая, исправлено). Проверил на проде: восемь падений avito_detail_backfill с «pool empty» подтвердились, п.4 приёмки не выполнен. Улики и расклад узлов на момент 7360 записаны в разделе #3310.

2. Две части предиката резерва без проверки по значению (неблокирующая, исправлено, нашлась ещё одна). Мутационный прогон на локальном Postgres со схемой из всех миграций, test_3299_* + services/test_proxy_pool.py:

снято из предиката до правки после правки
(a) other.expires_at в acquire 72 passed, rc=0 1 failed: test_expired_any_node_is_not_a_backup
(b) other.consecutive_fails в mark_banned 72 passed, rc=0 1 failed: test_mark_banned_unhealthy_backup_does_not_count
(c) other.consecutive_fails в acquire 1 failed 1 failed (тест уже был)
(d) other.expires_at в mark_banned (ревью не называло) 72 passed, rc=0 1 failed: test_mark_banned_expired_backup_does_not_count

Утверждение «предикат одинаковый в обоих местах» было верно по тексту, но по значению проверялась одна часть из четырёх. Теперь каждая проверена своим узлом.

3. Найдено при проверке п.2: внешний отбор mark_banned засчитывал узел с истёкшей арендой (исправлено). Докстринг обещает считать доступность «тем же правилом, что и acquire()». Но внешний EXISTS («есть ли у источника другой узел») не проверял expires_at, а acquire такой узел не выдаёт. Проба: у cian остались банимый 'any'-узел и 'any'-узел с истёкшей арендой, и mark_banned писал бан ('banned'), то есть cian оставался без прокси. Дефект был на main и раньше, а сработать может между истечением аренды и третьим проваленным healthcheck. Добавлена строка AND (sp.expires_at IS NULL OR sp.expires_at > now()) и тест test_mark_banned_expired_node_does_not_save_the_source (бан не пишется, cian получает узел). Фальсификация (строка снята):

E       AssertionError: assert 'banned' == 'protected'
FAILED tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_backup_does_not_count
FAILED tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_node_does_not_save_the_source
2 failed, 74 passed

Тексты красных прогонов (a), (b), (d):

(a) E       AssertionError: assert ProxyLease(id=193, url='http://t3299-…', kind='http', rotate_url=None) is None
    E        +  where ProxyLease(id=193, …) = acquire(<Session>, 'domclick', run_id=None)
(b) E       AssertionError: assert 'banned' == 'protected'
    FAILED …::test_mark_banned_unhealthy_backup_does_not_count
(d) E       AssertionError: assert 'banned' == 'protected'
    FAILED …::test_mark_banned_expired_backup_does_not_count

Исходник восстанавливался копией, diff -q пустой.

4. Изменение поведения #3299 (подтверждено, записано в разделе #3299). Проверил read-only SELECT'ом предиката на проде: узел 1 (yandex) теперь проходит fallback для avito/cian/domclick. Ревью этого не называло, но так же узел 15 теперь проходит fallback и для yandex.

5. Компромисс leased_by (подтверждено, записано в приёмку #3299, п.3).

Без правок. Закрывающие ссылки на #3299 и #3404 остаются. По #3404 сам проверил, что resolve_proxy_url зовётся один раз на прогон и до цикла (yandex_detail_backfill.py:332, yandex_address_backfill.py:138, avito_detail_backfill.py:456 при сборке сессии), так что лишняя сессия нагрузки не даёт. Отсутствие самоблокировки не перепроверял, принято по доводу ревью (update_heartbeat коммитит раньше, внутри lock_timeout 2s). По #3408 п.3 синхронный with curl_proxy_url остался только в providers/cian/newbuilding.py:932 (resolve_cian_zhk_url_via_search). Его зовёт задача newbuilding_enrich_backfill в scraper, а не публичный loop, поэтому Refs #3408 верно.

Прогоны

Все прогоны локально, rc снят у самого pytest.

После правок по ревью и merge origin/main (миграция 308 из #3567, схема пересобрана):

  • МЕРА backend, весь сьют, DSN-заглушка (как в инструкции): DATABASE_URL=postgresql+psycopg://test:test@localhost:5432/test uv run python -m pytest tests/ -q -p no:cacheprovider6226 passed, 57 skipped, rc=0. Первый прогон после правок снова дал rc=1 при «6226 passed» в сводке: гейт нашёл 4 необъявленных пропуска новых live-тестов. Они внесены в tests/skip_allowlist.txt отдельным коммитом, прогон повторён. (До ревью та же ловушка сработала на 11 пропусках.)
  • МЕРА backend, весь сьют на Postgres со схемой из всех миграций (как в ci-tradein.yml, одноразовый локальный контейнер): 6282 passed, 1 skipped, rc=0 (до ревью было 6278, +4 новых теста).
  • Proxy-тесты на живой БД (test_3299_*, test_3310_*, test_3404_*, test_3408_*, services/test_proxy_pool.py, services/test_proxy_egress.py, services/test_proxy_rotation.py, test_3404_proxy_run_attribution.py, test_proxy_pool_curl_paths.py, test_2700_*, test_3402_*, test_2830_*, test_kit_browser_fetcher_proxy_pool.py, test_3398_*), после правок по ревью: 234 passed, rc=0 (до ревью было 216).
  • uv run ruff check app tests: All checks passed. ruff format --check по изменённым файлам (включая правки по ревью): already formatted. В proxy_pool.py ruff-format попутно склеил одну строку лога в attribute_run_proxy: на main файл уже не проходил --check.
  • scraper-kit: uv run python -m pytest -qrc=5, no tests ran. У пакета нет своих тестов, его код проверяется из tradein-mvp/backend/tests. ruff check src и ruff format --check по двум изменённым файлам зелёные.

Деплой

  • Пересоздаются backend, tgbot и scraper (образ из tradein-mvp/backend + packages/scraper-kit). browser/frontend — no-op.
  • Миграций нет.
  • Перед мержем проверить SELECT id, source FROM scrape_runs WHERE status='running': up -d scraper'а убивает идущий сбор.

Closes #3299
Closes #3404
Refs #3310
Refs #3408

🤖 Generated with Claude Code

Четыре дефекта пула прокси МЕРА, по коммиту на каждый, плюс правки по ревью отдельными коммитами. Миграций нет. ## #3299 — выделенный узел не выдавался никому **Было.** Запасной заход `acquire()` и защита последнего узла в `mark_banned()` спрашивали, есть ли у выделенной привязки ВТОРОЙ узел той же привязки. Выделенный узел штучный, поэтому ответ почти всегда «нет». Итог: такой узел не получал ни один источник, хотя «свой» источник обслуживали узлы `'any'`. **Улики (прод, только чтение, 17.09).** Включены узлы 1 (`yandex`), 13 и 14 (`any`), 15 (`avito`). Узел 14 забанен Цианом до 18.09 01:42, узел 1 забанен Цианом до 30.09, узел 13 арендован прогоном 7344. Значит, для cian основной заход пуст, а свободный и здоровый узел 15 fallback не отдавал: EXISTS искал другой узел с `provider_affinity = 'avito'`, а такого нет. 30.08 по той же причине легли прогоны добора Домклика 5449–5459. **Сделано.** `tradein-mvp/backend/app/services/proxy_pool.py`. Резервом теперь считается узел той же привязки или `'any'`. Он должен быть здоров (`consecutive_fails`), с живой арендой порта и без бана от источника выделенной привязки. Предикат одинаковый в `acquire` и в `mark_banned`. `leased_by` намеренно не проверяется: аренда временная, иначе защита срабатывала бы при каждом параллельном прогоне. Этот довод уже записан у `mark_banned`. **Тесты.** `tests/test_3299_fallback_counts_any_nodes.py` на живом Postgres: схема из миграций, внешняя транзакция с откатом. 10 случаев: - три из приёмки issue; - резерв в карантине по `consecutive_fails`; - два зеркальных случая для `mark_banned`: бан записан, и источник действительно получает узел / защита держит; - по ревью: резерв с истёкшей арендой в `acquire`, резерв в карантине и резерв с истёкшей арендой в `mark_banned` (каждое условие предиката резерва теперь краснеет само, см. «Правки по ревью»); - по ревью: внешний отбор `mark_banned` не засчитывает источнику узел с истёкшей арендой (дефект был и на main, исправлен здесь же). В моке `tests/services/test_proxy_pool.py` primary и fallback различаются по полному фрагменту `IN (:provider, 'any')`: с этой правкой короткий `IN` есть и в подзапросе. Новый предикат в моке включается по подстроке, как остальные. `test_acquire_fallback_prefers_non_expired_over_expired` получил третий узел-резерв. Без него живой узел оказывается последним для cian: просроченный узел резервом больше не считается, и это правильно. **Фальсификация.** - `proxy_pool.py` из origin/main: ``` E AssertionError: домклику выдан None E AssertionError: assert 'protected' == 'banned' 2 failed, 4 passed ``` - Из подзапроса убрана только проверка `consecutive_fails`: ``` E AssertionError: assert ProxyLease(id=42, ...) is None FAILED ...::test_unhealthy_any_node_is_not_a_backup 1 failed, 5 passed ``` **Изменение поведения — решение владельца.** Новый предикат пускает в fallback не только узел 15. Read-only SELECT предиката на проде 17.09 09:04 UTC: узел 1 (`asocks-residential-1`, affinity `yandex`) проходит fallback для avito, cian и domclick (старый предикат — нет), потому что у yandex есть здоровые 'any'-узлы 13/14; узел 15 (`avito`) проходит его для cian, domclick и yandex. Сейчас узел 1 забанен всеми тремя источниками до 30.09, так что сразу ничего не изменится. Если residential-трафик узла 1 оплачивается по объёму, пускать ли его под чужие источники после 30.09 — решать владельцу. **Приёмка на проде после деплоя.** 1. Тот же read-only SELECT: для узла 15 при source = cian/domclick fallback возвращает true. 2. В логах tradein-scraper при занятых 13/14 появляется `FALLBACK affinity` с `id=15`. 3. Известная цена компромисса `leased_by` (резерв аренду не проверяет): на раскладе 17.09 (13 арендован прогоном, 14 забанен avito до 16:45) fallback отдаст 15 Циану, и у avito не будет свободного узла, пока 13 не освободится. Если после деплоя в этом окне появятся падения avito с «pool empty», это эта цена, а не регрессия. ## #3310 — бан на curl-пути копил счётчик отказов и выводил последний узел **Было.** Защита в `mark_banned` не пишет бан последнему узлу и обещает «узел продолжит выдаваться». Но `curl_proxy_url` на том же `ProxyBanError` вслед за баном звал `mark_health(ok=False)`. После трёх 403 или капч подряд `consecutive_fails = 3`, и `acquire` отсекал узел для всех источников. Браузерный путь это исправил в #3288 (PR #3357, `report_platform_ban` → `health=False`). Тем же способом проверено на проде: 155 срабатываний защиты за 07.09–17.09, у узлов 13/14/15 `consecutive_fails` 0. Curl-путь остался с дефектом, через него ходят cian detail, резолв ЖК, история цен Циана и оценщик. **Сделано.** `tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py`: на `ProxyBanError` зовётся `mark_banned` ВМЕСТО `mark_health(False)`. Транспортные сбои по-прежнему засчитываются узлу. **Тесты.** `tests/test_3310_curl_ban_keeps_last_node.py` на живом Postgres, настоящим трактом `curl_proxy_url` → `RealProxyProvider` → `proxy_pool`. Сессии адаптера привязаны к транзакции теста. - Единственный узел и три `CianBlockedError`: `consecutive_fails == 0`, бан-строк 0 (сработала защита), `acquire('cian')` выдаёт узел. Это п.1–3 приёмки: воспроизведение, двусторонний тест по значению, обещание из лога подтверждено выдачей. - Обратная сторона: `OSError` даёт `consecutive_fails == 1`. Пять старых проверок в `test_proxy_pool_curl_paths.py`, `test_2700_*`, `test_3402_*` и `test_2830_*` (2 шт.) ждали `mark_health(False)` на бане, то есть фиксировали сам дефект. Они поправлены. **Фальсификация** (`_proxy.py` из origin/main): ``` E assert 3 == 0 E + where 3 = _fails(<Session>, 63) E assert [(7, False)] == [] 6 failed, 47 passed ``` **Приёмка на проде после деплоя.** После строки «бан не записан: это последний узел» из curl-пути (Циан/оценщик) у этого узла не растёт `consecutive_fails`, и он продолжает выдаваться. **П.4 issue не выполнен, поэтому `Refs #3310`, а не Closes.** П.4: `avito_detail_backfill` не падает в «pool empty» при enabled-узле. Прод, только чтение, 17.09: прогоны 7360 (07:46), 7328, 7315, 7292, 7242, 7203, 7172 и 7104 упали с `no proxy available for provider='avito' (pool empty in prod, refusing env/direct fallback — #2616)`. Расклад на момент 7360: узел 13 арендован `domclick_city_sweep_moskva` 7344 (06:12–08:36), узлы 14 и 15 забанены avito (до 16:45 и до 10:46). Свободного незабаненного узла для avito не было. Это нехватка узлов (#2638), а не дефект этого PR, и эта правка такие падения не лечит. #3310 закрыть после прод-приёмки выше; п.4 остаётся за #2638. ## #3404 — у egress-пути прогон не знал свой узел **Было.** PR #3405 писал `scrape_runs.proxy_id` только из `proxy_pool.acquire()`. Прогоны, которые берут прокси через `proxy_egress.resolve_proxy_url` без аренды, атрибуцию не получали. Это yandex_detail_backfill, yandex_address_backfill и curl-ветка avito_detail_backfill. Прод, 7 суток до 17.09: | source | прогонов | с proxy_id | |---|---|---| | yandex_detail_backfill | 56 | 0 | | yandex_address_backfill | 1 | 0 | | avito_detail_backfill | 56 | 39 | | cian_detail_backfill | 28 | 26 | **Сделано.** `resolve_proxy_url`, если узел выбран и `current_run_id` выставлен, зовёт `attribute_run_proxy`. Вызов идёт своей короткой сессией: `db` вызывающего — долгоживущая сессия прогона посреди работы, а атрибуция коммитит и на сбое откатывает. `current_run_id` доходит до задачи: `_claim_run` выставляет его до `asyncio.create_task`, и тот же канал уже работает у acquire-пути. Часть B #3404 (гашение бана вместо DELETE) была на проде и раньше: строки узла 13 погашены 16.09 с `cleared_reason`. **Тесты.** `tests/test_3404_egress_run_attribution.py` на живом Postgres: - внутри прогона пишутся `proxy_id` и `counters.proxy_ids`; - вне прогона `scrape_runs` не тронут; - незакоммиченное изменение вызывающего не коммитится. **Фальсификация.** - `proxy_egress.py` из origin/main: ``` E assert None == 70 2 failed, 1 passed ``` - Атрибуция через `db` вызывающего вместо своей сессии: ``` E AssertionError: атрибуция закоммитила чужую транзакцию E assert 't3404' not in {'t3404': 1} ``` **Приёмка на проде через сутки после деплоя.** ```sql SELECT source, count(*), count(proxy_id) FROM scrape_runs WHERE source IN ('yandex_detail_backfill','yandex_address_backfill') AND started_at > '<время деплоя>' GROUP BY 1; ``` `count(proxy_id)` больше 0 и почти равен числу прогонов. NULL допустим только у прогонов, упавших до выбора прокси. ## #3408 п.3 — детальная Циана держала event loop операциями пула **Было.** `providers/cian/detail.py::fetch_detail` без `session`/`browser_fetcher` входил в синхронный `curl_proxy_url`. Вызывающий — админ-ручка истории цен Циана в публичном tradein-backend (один воркер uvicorn): пара блокирующих походов в БД на каждый листинг батча. #3398 так уже перевёл три сайта `/estimate`, этот остался. **Сделано.** `async with acurl_proxy_url(...)`: вход и выход идут в потоке, `ProxyBanError`/`CianBlockedError` изнутри блока доходят до пула как раньше (`test_2700`/`test_3402` зелёные). В докстринге `acurl_proxy_url` поправлен потолок: `pool_timeout` теперь 5 с (#3444), а не 30. **Тест.** `tests/test_3408_cian_detail_pool_ops_off_loop.py`, по образцу `test_3398`: `acquire` спит 0,3 с, соседняя корутина тикает. Фальсификация (`detail.py` из origin/main): ``` E AssertionError: loop простоял всё время acquire: тиков всего 0 ``` **Приёмка на проде после деплоя.** Вызвать админ-ручку истории цен Циана на маленьком батче. В логе на каждый листинг должны быть `leased`/`released`, строк `QueuePool` — 0. Пп.1, 2, 4 #3408 по триажу закрыты PR #3444 (прод-приёмка в комментарии к issue). Этот PR делает только п.3, поэтому issue не закрывает: закрытие — после приёмки п.3. ## Правки по ревью Каждую находку сначала проверил сам. Опровергать не пришлось: всё, что проверялось, подтвердилось. **1. Закрывающая ссылка на #3310 заменена на `Refs #3310` (блокирующая, исправлено).** Проверил на проде: восемь падений `avito_detail_backfill` с «pool empty» подтвердились, п.4 приёмки не выполнен. Улики и расклад узлов на момент 7360 записаны в разделе #3310. **2. Две части предиката резерва без проверки по значению (неблокирующая, исправлено, нашлась ещё одна).** Мутационный прогон на локальном Postgres со схемой из всех миграций, `test_3299_*` + `services/test_proxy_pool.py`: | снято из предиката | до правки | после правки | |---|---|---| | (a) `other.expires_at` в `acquire` | 72 passed, rc=0 | 1 failed: `test_expired_any_node_is_not_a_backup` | | (b) `other.consecutive_fails` в `mark_banned` | 72 passed, rc=0 | 1 failed: `test_mark_banned_unhealthy_backup_does_not_count` | | (c) `other.consecutive_fails` в `acquire` | 1 failed | 1 failed (тест уже был) | | (d) `other.expires_at` в `mark_banned` (ревью не называло) | 72 passed, rc=0 | 1 failed: `test_mark_banned_expired_backup_does_not_count` | Утверждение «предикат одинаковый в обоих местах» было верно по тексту, но по значению проверялась одна часть из четырёх. Теперь каждая проверена своим узлом. **3. Найдено при проверке п.2: внешний отбор `mark_banned` засчитывал узел с истёкшей арендой (исправлено).** Докстринг обещает считать доступность «тем же правилом, что и acquire()». Но внешний EXISTS («есть ли у источника другой узел») не проверял `expires_at`, а `acquire` такой узел не выдаёт. Проба: у cian остались банимый 'any'-узел и 'any'-узел с истёкшей арендой, и `mark_banned` писал бан (`'banned'`), то есть cian оставался без прокси. Дефект был на main и раньше, а сработать может между истечением аренды и третьим проваленным healthcheck. Добавлена строка `AND (sp.expires_at IS NULL OR sp.expires_at > now())` и тест `test_mark_banned_expired_node_does_not_save_the_source` (бан не пишется, cian получает узел). Фальсификация (строка снята): ``` E AssertionError: assert 'banned' == 'protected' FAILED tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_backup_does_not_count FAILED tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_node_does_not_save_the_source 2 failed, 74 passed ``` Тексты красных прогонов (a), (b), (d): ``` (a) E AssertionError: assert ProxyLease(id=193, url='http://t3299-…', kind='http', rotate_url=None) is None E + where ProxyLease(id=193, …) = acquire(<Session>, 'domclick', run_id=None) (b) E AssertionError: assert 'banned' == 'protected' FAILED …::test_mark_banned_unhealthy_backup_does_not_count (d) E AssertionError: assert 'banned' == 'protected' FAILED …::test_mark_banned_expired_backup_does_not_count ``` Исходник восстанавливался копией, `diff -q` пустой. **4. Изменение поведения #3299 (подтверждено, записано в разделе #3299).** Проверил read-only SELECT'ом предиката на проде: узел 1 (`yandex`) теперь проходит fallback для avito/cian/domclick. Ревью этого не называло, но так же узел 15 теперь проходит fallback и для yandex. **5. Компромисс `leased_by` (подтверждено, записано в приёмку #3299, п.3).** **Без правок.** Закрывающие ссылки на #3299 и #3404 остаются. По #3404 сам проверил, что `resolve_proxy_url` зовётся один раз на прогон и до цикла (`yandex_detail_backfill.py:332`, `yandex_address_backfill.py:138`, `avito_detail_backfill.py:456` при сборке сессии), так что лишняя сессия нагрузки не даёт. Отсутствие самоблокировки не перепроверял, принято по доводу ревью (`update_heartbeat` коммитит раньше, внутри `lock_timeout` 2s). По #3408 п.3 синхронный `with curl_proxy_url` остался только в `providers/cian/newbuilding.py:932` (`resolve_cian_zhk_url_via_search`). Его зовёт задача `newbuilding_enrich_backfill` в scraper, а не публичный loop, поэтому `Refs #3408` верно. ## Прогоны Все прогоны локально, rc снят у самого pytest. После правок по ревью и merge origin/main (миграция 308 из #3567, схема пересобрана): - МЕРА backend, весь сьют, DSN-заглушка (как в инструкции): `DATABASE_URL=postgresql+psycopg://test:test@localhost:5432/test uv run python -m pytest tests/ -q -p no:cacheprovider` — **6226 passed, 57 skipped, rc=0**. Первый прогон после правок снова дал rc=1 при «6226 passed» в сводке: гейт нашёл 4 необъявленных пропуска новых live-тестов. Они внесены в `tests/skip_allowlist.txt` отдельным коммитом, прогон повторён. (До ревью та же ловушка сработала на 11 пропусках.) - МЕРА backend, весь сьют на Postgres со схемой из всех миграций (как в ci-tradein.yml, одноразовый локальный контейнер): **6282 passed, 1 skipped, rc=0** (до ревью было 6278, +4 новых теста). - Proxy-тесты на живой БД (`test_3299_*`, `test_3310_*`, `test_3404_*`, `test_3408_*`, `services/test_proxy_pool.py`, `services/test_proxy_egress.py`, `services/test_proxy_rotation.py`, `test_3404_proxy_run_attribution.py`, `test_proxy_pool_curl_paths.py`, `test_2700_*`, `test_3402_*`, `test_2830_*`, `test_kit_browser_fetcher_proxy_pool.py`, `test_3398_*`), после правок по ревью: **234 passed, rc=0** (до ревью было 216). - `uv run ruff check app tests`: All checks passed. `ruff format --check` по изменённым файлам (включая правки по ревью): already formatted. В `proxy_pool.py` ruff-format попутно склеил одну строку лога в `attribute_run_proxy`: на main файл уже не проходил `--check`. - scraper-kit: `uv run python -m pytest -q` — **rc=5, no tests ran**. У пакета нет своих тестов, его код проверяется из `tradein-mvp/backend/tests`. `ruff check src` и `ruff format --check` по двум изменённым файлам зелёные. ## Деплой - Пересоздаются `backend`, `tgbot` и `scraper` (образ из `tradein-mvp/backend` + `packages/scraper-kit`). `browser`/`frontend` — no-op. - Миграций нет. - **Перед мержем проверить `SELECT id, source FROM scrape_runs WHERE status='running'`**: `up -d` scraper'а убивает идущий сбор. Closes #3299 Closes #3404 Refs #3310 Refs #3408 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 5 commits 2026-09-17 08:14:41 +00:00
Запасной заход acquire() и защита последнего узла в mark_banned() спрашивали,
есть ли у выделенной привязки ВТОРОЙ узел той же привязки. Выделенный узел
штучный, поэтому ответ почти всегда «нет», и такой узел не выдавался никому,
хотя свой источник обслуживали 'any'-узлы. Прод 17.09: узел 15 (avito)
свободен и здоров, а cian/domclick его не получали; 30.08 так лёг добор
Домклика (прогоны 5449-5459).

Теперь резерв — любой узел той же привязки или 'any', здоровый
(consecutive_fails), с живой арендой порта и не забаненный источником
выделенной привязки. Предикат одинаковый в обоих местах. leased_by
намеренно не проверяется: аренда временная.

Тесты на живом Postgres (tests/test_3299_*): 6 случаев из приёмки, два
красные на старом SQL. Мок test_proxy_pool.py: маршрутизация primary/fallback
по полному фрагменту IN (:provider, 'any') и гейт нового предиката.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Защита последнего узла в mark_banned бережёт узел от бан-строки и пишет
«узел продолжит выдаваться», но curl_proxy_url на том же ProxyBanError
вслед за баном звал mark_health(ok=False). Три ответа 403/капчи подряд —
consecutive_fails = 3, и acquire отсекал узел для ВСЕХ источников: пул
пуст при живом узле. Браузерный путь это исправил в #3288
(report_platform_ban), curl-путь — нет; через него ходят cian detail,
ЖК-резолв, история цен Циана и оценщик.

Теперь на ProxyBanError зовётся mark_banned вместо mark_health(False).
Транспортные сбои по-прежнему засчитываются узлу.

Тест на живом Postgres настоящим трактом (curl_proxy_url →
RealProxyProvider → proxy_pool): единственный узел, три CianBlockedError →
consecutive_fails == 0, acquire('cian') выдаёт узел; на старом коде
«assert 3 == 0». Пять старых проверок ждали mark_health(False) на бане —
они фиксировали дефект, поправлены.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #3405 писал scrape_runs.proxy_id только из proxy_pool.acquire(). Прогоны,
которые берут прокси через proxy_egress.resolve_proxy_url без аренды
(yandex_detail_backfill, yandex_address_backfill, curl-ветка
avito_detail_backfill), атрибуцию не получали: прод 17.09 —
yandex_detail_backfill 0 из 56, yandex_address_backfill 0 из 1.

resolve_proxy_url при выбранном узле и выставленном current_run_id зовёт
attribute_run_proxy. Своей короткой сессией: db вызывающего — долгоживущая
сессия прогона посреди работы, а атрибуция коммитит и на сбое откатывает.

Тесты на живом Postgres: атрибуция внутри прогона (proxy_id и
counters.proxy_ids), вне прогона scrape_runs не тронут, незакоммиченная
работа вызывающего не коммитится. Красные прогоны: на старом коде
«assert None == 70»; с атрибуцией через db вызывающего — «атрибуция
закоммитила чужую транзакцию».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fetch_detail без session/browser_fetcher входил в синхронный curl_proxy_url:
acquire/mark_health/release RealProxyProvider'а ходят в БД прямо на loop'е.
Вызывающий — админ-ручка истории цен Циана в публичном tradein-backend
(один воркер uvicorn): пара блокирующих вызовов на каждый листинг батча.
#3398 перевёл так три сайта /estimate, этот остался.

Теперь async with acurl_proxy_url: вход и выход в потоке, contextvars
(current_run_id) копируются, ProxyBanError/CianBlockedError изнутри блока
доходят до пула как раньше (test_2700/test_3402 зелёные). Попутно потолок в
докстринге acurl_proxy_url: pool_timeout теперь 5 с (#3444), не 30.

Тест по образцу test_3398: acquire спит 0.3 с, соседняя корутина тикает.
На синхронном входе — «тиков всего 0».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test(tradein): объявить пропуски live-тестов пула прокси (#3299, #3310, #3404)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 15s
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 5m47s
47a91acf61
Без БД 11 новых live-тестов пропускаются, и гейт conftest роняет прогон
(«НЕУЧТЁННЫЙ ПРОПУСК», rc=1 при «6226 passed» в сводке). В ci-tradein.yml они
идут по-настоящему: локальный прогон на Postgres со схемой из миграций —
6278 passed, 1 skipped, rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 3 commits 2026-09-17 09:10:23 +00:00
Ревью PR #3565: у нового предиката резерва две части не проверялись ни одним
тестом. Снятие `other.expires_at` из подзапроса acquire и снятие
`other.consecutive_fails` из подзапроса mark_banned оставляли прогон
test_3299 + services/test_proxy_pool.py зелёным (72 passed). Проверено тем же
способом, и нашлась третья такая часть: `other.expires_at` в mark_banned.

Добавлено по live-тесту на каждое условие: просроченный резерв в acquire,
резерв в карантине и просроченный резерв в mark_banned.

Попутно найден дефект рядом. Внешний отбор mark_banned («есть ли у источника
другой узел») срок аренды не проверял, хотя acquire такой узел не выдаёт, а
докстринг обещает «тем же правилом, что acquire()». Если у источника остались
только просроченные узлы, бан уходил последнему живому, и источник оставался
без прокси, пока healthcheck не наберёт просроченным узлам отказов. Добавлена
проверка `sp.expires_at` и тест на этот случай.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test(tradein): объявить пропуски новых live-тестов защиты пула (#3299)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
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 17s
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 5m32s
d551cfbab9
Четыре live-теста из правок по ревью без БД пропускаются, а гейт conftest
роняет прогон с DSN-заглушкой на необъявленных пропусках (rc=1 при
«6226 passed» в сводке). Внесены в skip_allowlist.txt к остальным тестам #3299.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 09:39:51 +00:00
Merge origin/main в fix/proxy-pool-guards (#3299)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 16s
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 19s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m54s
90b93b90b5
Конфликт только в tests/skip_allowlist.txt: обе стороны дописали блок в конец —
live-тесты пула прокси (#3299/#3310/#3404) и миграции 310 (#3385). Оставлены оба.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 09:56:19 +00:00
merge origin/main: починка красного main (#3572)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 26s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 7m47s
bdc169af8b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 10:48:32 +00:00
merge origin/main: конфликт skip_allowlist.txt (оба блока сохранены)
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 7m45s
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
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
4ccf701df6
Полный сьют после слияния: 6378 passed, 61 skipped, rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit 2b73961af5 into main 2026-09-17 10:49:04 +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#3565
No description provided.