Пул прокси МЕРА: оплаченный выделенный узел снова выдаётся, бан не выбивает последний узел, прогон знает свой прокси #3565
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#3565
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/proxy-pool-guards"
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?
Четыре дефекта пула прокси МЕРА, по коммиту на каждый, плюс правки по ревью отдельными коммитами. Миграций нет.
#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 случаев:consecutive_fails;mark_banned: бан записан, и источник действительно получает узел / защита держит;acquire, резерв в карантине и резерв с истёкшей арендой вmark_banned(каждое условие предиката резерва теперь краснеет само, см. «Правки по ревью»);mark_bannedне засчитывает источнику узел с истёкшей арендой (дефект был и на main, исправлен здесь же).В моке
tests/services/test_proxy_pool.pyprimary и fallback различаются по полному фрагментуIN (:provider, 'any'): с этой правкой короткийINесть и в подзапросе. Новый предикат в моке включается по подстроке, как остальные.test_acquire_fallback_prefers_non_expired_over_expiredполучил третий узел-резерв. Без него живой узел оказывается последним для cian: просроченный узел резервом больше не считается, и это правильно.Фальсификация.
proxy_pool.pyиз origin/main:consecutive_fails:Изменение поведения — решение владельца. Новый предикат пускает в fallback не только узел 15. Read-only SELECT предиката на проде 17.09 09:04 UTC: узел 1 (
asocks-residential-1, affinityyandex) проходит fallback для avito, cian и domclick (старый предикат — нет), потому что у yandex есть здоровые 'any'-узлы 13/14; узел 15 (avito) проходит его для cian, domclick и yandex. Сейчас узел 1 забанен всеми тремя источниками до 30.09, так что сразу ничего не изменится. Если residential-трафик узла 1 оплачивается по объёму, пускать ли его под чужие источники после 30.09 — решать владельцу.Приёмка на проде после деплоя.
FALLBACK affinityсid=15.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/15consecutive_fails0. 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):Приёмка на проде после деплоя. После строки «бан не записан: это последний узел» из 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_moskva7344 (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:Сделано.
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:dbвызывающего вместо своей сессии:Приёмка на проде через сутки после деплоя.
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):Приёмка на проде после деплоя. Вызвать админ-ручку истории цен Циана на маленьком батче. В логе на каждый листинг должны быть
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:other.expires_atвacquiretest_expired_any_node_is_not_a_backupother.consecutive_failsвmark_bannedtest_mark_banned_unhealthy_backup_does_not_countother.consecutive_failsвacquireother.expires_atвmark_banned(ревью не называло)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 получает узел). Фальсификация (строка снята):Тексты красных прогонов (a), (b), (d):
Исходник восстанавливался копией,
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_timeout2s). По #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, схема пересобрана):
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 пропусках.)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.pyruff-format попутно склеил одну строку лога вattribute_run_proxy: на main файл уже не проходил--check.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 -dscraper'а убивает идущий сбор.Closes #3299
Closes #3404
Refs #3310
Refs #3408
🤖 Generated with Claude Code