feat(tradein/proxy): прогон знает свой узел, а снятый бан перестаёт стирать историю (#3404) #3405

Merged
lekss361 merged 2 commits from feat/3404-proxy-run-attribution into main 2026-09-06 10:18:15 +00:00
Owner

Закрывает #3404.

Зачем

Выбор оператора мобильного прокси (МегаФон / Tele2 / МТС / Билайн) опирался на две ненадёжные опоры.

  1. Прогон не связан с узлом. proxy_id был только у scrape_proxy_source_bans и scrape_proxy_rotations. Вопрос «через какой узел шёл прогон, обогативший 5 карточек из 21» не выяснялся ни одним запросом.
  2. История банов стиралась. clear_source_bans делала DELETE, а зовётся она после КАЖДОЙ успешной ротации exit-IP (proxy_rotation.py:469 и :601). У #540723 (МегаФон) 23 успешные ротации с 31.08 — и ноль строк банов; у #540722 (Tele2) ротаций почти не было — и 7 банов. Сравнение «7 против 0» читалось как «Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза».

На этих числах прямо сейчас принимается решение о деньгах: какие порты пересаживать на другого оператора и сколько докупать.

Что сделано

A. scrape_runs.proxy_id — последний выданный прогону узел; полная цепочка (если узел менялся mid-run) копится в counters.proxy_ids. Пишет proxy_pool.attribute_run_proxy из единственной точки — сразу после выдачи лиза в acquire(), поэтому curl-путь (лиз на каждый вызов), браузерный sticky lease и ре-acquire при ротации покрыты одинаково, без правки сигнатур провайдеров.

run_id доходит до адаптера через ContextVar (scraper_kit.orchestration.run_context): протокол ProxyProvider.acquire(provider) его не несёт, а RealProxyProvider — один объект на весь планировщик. create_run выставляет, финализаторы сбрасывают. Если прогон и acquire окажутся в разных тредах, созданных до set — атрибуция молча останется NULL, это не ошибка.

Best-effort по построению: SET LOCAL lock_timeout = '2s' и проглоченное исключение. Строку прогона параллельно пишет heartbeat из другой сессии, а путь выдачи прокси ждать на блокировке не может; лиз к этому моменту уже закоммичен.

B. clear_source_bans гасит строку, а не удаляет: banned_until = now(), ban_count = 0, cleared_at/cleared_reason. Строка доживает до штатного purge по SOURCE_BAN_PURGE_DAYS.

Что НЕ меняется

Эскалация сохранена 1:1. Формула в mark_banned берёт ПРЕДЫДУЩИЙ ban_count показателем степени (base * 2^ban_count), поэтому погашенная строка с нулём даёт ровно SOURCE_BAN_BASE_HOURS = 6ч — байт-в-байт как свежий INSERT после DELETE. Отдельный тест ровно на это: забанить → снять → забанить снова → срок базовый, а не удвоенный.

Погашенная строка неотличима от отсутствующей для всех читателей — проверены поимённо: acquire (основной и fallback-запрос), оба guard-подзапроса mark_banned, holder-запрос диагноза, proxy_egress._pick_candidate (ранжирование по COALESCE(b.ban_count, 0) даёт 0, как у узла без истории), admin._active_source_bans. Все гейтятся по banned_until > now(), ни один не считает «строка есть» синонимом «забанен».

Ничего не бэкфиллится. Связать прошедшие прогоны с узлами нечем: leased_by исторически = NON_RUN_LEASE_MARKER, лог выдачи лизов run_id не хранит. Для строк до миграции 287 proxy_id остаётся NULL — это «атрибуция не собиралась», а не «прогон без прокси».

Миграция

287_proxy_run_attribution.sql — идемпотентна (ADD COLUMN IF NOT EXISTS ×3, CREATE INDEX IF NOT EXISTS), одна транзакция, lock_timeout 5с, DML нет вовсе. ON DELETE SET NULL у FK: вывод узла из пула не должен уносить историю прогонов.

Тесты

9 новых (test_3404_proxy_run_attribution.py) + 14 существующих переведены с DELETE-семантики на гашение. В тестах про утечку секретов добавлена проверка НОВОГО поля: ключ ротации не должен попадать и в cleared_reason — раньше это поле не проверялось, потому что его не было.

Полный прогон бэкенда после мержа свежего main: 5606 passed, 37 skipped.

Чего этот PR не делает

Не лечит сам дефицит ёмкости пула. У Авито сейчас один пригодный узел, за 7 дней 34 из 47 отказов avito_detail_backfill помечены ban_kind=infra (сайдкар либо пустой пул), и в логах есть NS_ERROR_PROXY_TOO_MANY_REQUESTS — 429 от самого провайдера. Это отдельная работа; здесь только измеримость выбора.

🤖 Generated with Claude Code

Закрывает #3404. ## Зачем Выбор оператора мобильного прокси (МегаФон / Tele2 / МТС / Билайн) опирался на две ненадёжные опоры. 1. **Прогон не связан с узлом.** `proxy_id` был только у `scrape_proxy_source_bans` и `scrape_proxy_rotations`. Вопрос «через какой узел шёл прогон, обогативший 5 карточек из 21» не выяснялся ни одним запросом. 2. **История банов стиралась.** `clear_source_bans` делала `DELETE`, а зовётся она после КАЖДОЙ успешной ротации exit-IP (`proxy_rotation.py:469` и `:601`). У #540723 (МегаФон) 23 успешные ротации с 31.08 — и ноль строк банов; у #540722 (Tele2) ротаций почти не было — и 7 банов. Сравнение «7 против 0» читалось как «Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза». На этих числах прямо сейчас принимается решение о деньгах: какие порты пересаживать на другого оператора и сколько докупать. ## Что сделано **A. `scrape_runs.proxy_id`** — последний выданный прогону узел; полная цепочка (если узел менялся mid-run) копится в `counters.proxy_ids`. Пишет `proxy_pool.attribute_run_proxy` из единственной точки — сразу после выдачи лиза в `acquire()`, поэтому curl-путь (лиз на каждый вызов), браузерный sticky lease и ре-acquire при ротации покрыты одинаково, без правки сигнатур провайдеров. `run_id` доходит до адаптера через ContextVar (`scraper_kit.orchestration.run_context`): протокол `ProxyProvider.acquire(provider)` его не несёт, а `RealProxyProvider` — один объект на весь планировщик. `create_run` выставляет, финализаторы сбрасывают. Если прогон и `acquire` окажутся в разных тредах, созданных до `set` — атрибуция молча останется NULL, это не ошибка. Best-effort по построению: `SET LOCAL lock_timeout = '2s'` и проглоченное исключение. Строку прогона параллельно пишет heartbeat из другой сессии, а путь выдачи прокси ждать на блокировке не может; лиз к этому моменту уже закоммичен. **B. `clear_source_bans` гасит строку**, а не удаляет: `banned_until = now()`, `ban_count = 0`, `cleared_at`/`cleared_reason`. Строка доживает до штатного purge по `SOURCE_BAN_PURGE_DAYS`. ## Что НЕ меняется **Эскалация сохранена 1:1.** Формула в `mark_banned` берёт ПРЕДЫДУЩИЙ `ban_count` показателем степени (`base * 2^ban_count`), поэтому погашенная строка с нулём даёт ровно `SOURCE_BAN_BASE_HOURS` = 6ч — байт-в-байт как свежий INSERT после DELETE. Отдельный тест ровно на это: забанить → снять → забанить снова → срок базовый, а не удвоенный. **Погашенная строка неотличима от отсутствующей** для всех читателей — проверены поимённо: `acquire` (основной и fallback-запрос), оба guard-подзапроса `mark_banned`, holder-запрос диагноза, `proxy_egress._pick_candidate` (ранжирование по `COALESCE(b.ban_count, 0)` даёт 0, как у узла без истории), `admin._active_source_bans`. Все гейтятся по `banned_until > now()`, ни один не считает «строка есть» синонимом «забанен». **Ничего не бэкфиллится.** Связать прошедшие прогоны с узлами нечем: `leased_by` исторически = `NON_RUN_LEASE_MARKER`, лог выдачи лизов run_id не хранит. Для строк до миграции 287 `proxy_id` остаётся NULL — это «атрибуция не собиралась», а не «прогон без прокси». ## Миграция `287_proxy_run_attribution.sql` — идемпотентна (`ADD COLUMN IF NOT EXISTS` ×3, `CREATE INDEX IF NOT EXISTS`), одна транзакция, `lock_timeout` 5с, DML нет вовсе. `ON DELETE SET NULL` у FK: вывод узла из пула не должен уносить историю прогонов. ## Тесты 9 новых (`test_3404_proxy_run_attribution.py`) + 14 существующих переведены с DELETE-семантики на гашение. В тестах про утечку секретов добавлена проверка НОВОГО поля: ключ ротации не должен попадать и в `cleared_reason` — раньше это поле не проверялось, потому что его не было. Полный прогон бэкенда после мержа свежего main: **5606 passed, 37 skipped**. ## Чего этот PR не делает Не лечит сам дефицит ёмкости пула. У Авито сейчас один пригодный узел, за 7 дней 34 из 47 отказов `avito_detail_backfill` помечены `ban_kind=infra` (сайдкар либо пустой пул), и в логах есть `NS_ERROR_PROXY_TOO_MANY_REQUESTS` — 429 от самого провайдера. Это отдельная работа; здесь только измеримость выбора. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
lekss361 added 2 commits 2026-09-06 10:12:10 +00:00
Выбор оператора мобильного прокси опирался на две ненадёжные опоры.

Первая: `scrape_runs` не знала, через какой узел шёл прогон — колонка `proxy_id`
была только у банов и ротаций. «Какой узел собрал 5 карточек из 21» не выяснялось
ни одним запросом.

Вторая: `clear_source_bans` делала DELETE, а зовётся она после КАЖДОЙ успешной
ротации exit-IP. У #540723 (МегаФон) 23 успешные ротации и ноль строк банов,
у #540722 (Tele2) ротаций почти не было и 7 банов. «7 против 0» читалось как
«Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза».

Теперь:
- `scrape_runs.proxy_id` — последний выданный прогону узел; полная цепочка
  (если узел менялся mid-run) копится в `counters.proxy_ids`. Пишет
  `proxy_pool.attribute_run_proxy` из единственной точки — сразу после выдачи
  лиза в `acquire()`, поэтому curl-путь, браузерный sticky lease и ре-acquire
  при ротации покрыты одинаково. `run_id` доходит до адаптера через ContextVar
  (`scraper_kit.orchestration.run_context`): протокол `ProxyProvider.acquire`
  его не несёт, а `RealProxyProvider` живёт одним объектом на весь планировщик.
  Best-effort: `lock_timeout` 2с и проглоченное исключение — диагностика не
  вправе ронять выдачу прокси или ждать на блокировке строки прогона.
- `clear_source_bans` гасит строку (`banned_until = now()`, `ban_count = 0`,
  `cleared_at`/`cleared_reason`) вместо удаления. Эскалация сохраняется 1:1:
  формула в `mark_banned` берёт ПРЕДЫДУЩИЙ `ban_count` показателем степени, при
  нуле это ровно `SOURCE_BAN_BASE_HOURS` — как после DELETE. Строка доживает до
  штатного purge по `SOURCE_BAN_PURGE_DAYS`.

Для всех читателей `scrape_proxy_source_bans` погашенная строка неотличима от
отсутствующей: acquire, оба guard-подзапроса `mark_banned`, `proxy_egress`
(ранжирование по `ban_count` даёт 0, как у узла без истории), admin `_active_ban` —
все гейтятся по `banned_until > now()`.

Ничего не бэкфиллится: связать прошедшие прогоны с узлами нечем (`leased_by`
исторически = NON_RUN_LEASE_MARKER), врать восстановленным значением нельзя.

Миграция 287. Тесты: 9 новых на обе части (главный — эскалация после гашения даёт
базовые 6ч, а не удвоенные) + 14 существующих переведены с DELETE-семантики на
гашение, включая проверку, что секрет ротации не утекает в новое `cleared_reason`.
Полный прогон бэкенда: 5600 passed, 37 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WHFxVPWoBnSZihkdH1Uou
Merge remote-tracking branch 'forgejo/main' into feat/3404-proxy-run-attribution
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 / 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 5m4s
f4099cb89a
lekss361 merged commit 72e3bc9e24 into main 2026-09-06 10:18:15 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#3405
No description provided.