gendesign/tradein-mvp/backend/data/sql/287_proxy_run_attribution.sql
bot-backend b89788ee99 feat(tradein/proxy): прогон знает свой узел, а снятый бан перестаёт стирать историю (#3404)
Выбор оператора мобильного прокси опирался на две ненадёжные опоры.

Первая: `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
2026-09-06 13:05:20 +03:00

107 lines
8.6 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 287_proxy_run_attribution.sql
-- scrape_runs.proxy_id — узел прогона (#3404 A) + soft-clear банов по источнику (#3404 B).
--
-- Dependencies: 015_scrape_runs.sql (scrape_runs), 157_scrape_proxies.sql (scrape_proxies),
-- 210_scrape_proxy_source_bans.sql (scrape_proxy_source_bans).
-- Apply after: 286_offer_price_history_decimal_slips.sql
--
-- ЧАСТЬ A — WHY:
-- До сих пор ни одна строка scrape_runs не знала, через какой узел пула шёл прогон:
-- ProxyProvider.acquire(provider) run_id не принимает, а leased_by у боевого пути —
-- NON_RUN_LEASE_MARKER. Разбор исхода прогона по узлу (кто плодит баны/провалы)
-- был возможен только вручную, по времени. Код-часть (app/services/proxy_pool.py:
-- attribute_run_proxy) пишет сюда после каждой выдачи лиза; здесь только схема.
--
-- ЧАСТЬ A — WHAT:
-- proxy_id — узел, через который шёл прогон. Если за прогон узел МЕНЯЛСЯ (ротация
-- при повторных провалах в browser_fetcher, либо curl-путь берёт лиз на каждый вызов
-- в providers/_proxy.py), здесь остаётся ПОСЛЕДНИЙ; полная цепочка узлов копится в
-- scrape_runs.counters->'proxy_ids' (jsonb-массив, пишет тот же attribute_run_proxy).
-- ON DELETE SET NULL, а не CASCADE — узел из пула может быть выведен/удалён оператором,
-- история прогонов (аналитика, отчёты) не должна пропадать вместе с ним.
-- Индекс (proxy_id, started_at DESC) — под разрез «исход прогона по узлу за период»
-- (WHERE proxy_id = ... ORDER BY started_at DESC); partial по proxy_id IS NOT NULL не
-- делаем, потому что колонка сортировки (started_at) в самом индексе — Postgres и так
-- не будет использовать индекс без него для прогонов без узла.
--
-- НИЧЕГО НЕ БЭКФИЛЛИТСЯ: связать уже прошедшие прогоны с конкретным узлом задним
-- числом нечем — leased_by исторически = NON_RUN_LEASE_MARKER, а лог выдачи лизов
-- не хранит run_id. Для всех строк scrape_runs, созданных ДО этой миграции,
-- proxy_id остаётся NULL навсегда — это не «прогон без прокси», а «прогон, для
-- которого атрибуция не собиралась». Врать восстановленным/угаданным значением
-- нельзя, поэтому backfill-UPDATE здесь сознательно отсутствует.
--
-- ЧАСТЬ B — WHY:
-- clear_source_bans() (proxy_pool.py) сейчас делает DELETE строки
-- scrape_proxy_source_bans. Это стирает историю эскалации (ban_count) и не оставляет
-- следа, что бан был снят ДОСРОЧНО (оператором/успешной ротацией exit-IP), в отличие
-- от бана, который просто истёк сам. Код-часть переводит функцию на UPDATE
-- (гашение: banned_until=now(), ban_count=0, cleared_at/cleared_reason проставляются),
-- строка доживает до штатного purge в run_proxy_healthcheck (SOURCE_BAN_PURGE_DAYS).
-- Эскалация при повторном бане той же пары сохраняется 1:1: формула в mark_banned
-- берёт СТАРЫЙ ban_count как показатель степени (base * 2^ban_count), при
-- ban_count=0 после гашения это ровно SOURCE_BAN_BASE_HOURS=6ч — байт-в-байт как
-- свежий INSERT после DELETE.
--
-- ЧАСТЬ B — WHAT:
-- cleared_at — момент досрочного снятия бана (не путать с истечением banned_until
-- само по себе: NULL значит «бан снят не был / истёк сам», не-NULL — снят
-- оператором или ротацией exit-IP до истечения срока или сразу после).
-- cleared_reason — свободный текст причины снятия (тот же 'reason', что передаётся
-- в clear_source_bans).
--
-- ИДЕМПОТЕНТНОСТЬ:
-- ADD COLUMN IF NOT EXISTS × 3, CREATE INDEX IF NOT EXISTS, COMMENT ON COLUMN
-- (безусловны, но идемпотентны сами по себе — просто перезаписывают тот же текст).
-- Backfill-DML в файле нет вовсе, поэтому повторный прогон — чистый no-op.
BEGIN;
SET LOCAL lock_timeout = '5s';
-- ── Часть A: scrape_runs.proxy_id ────────────────────────────────────────────
ALTER TABLE scrape_runs
ADD COLUMN IF NOT EXISTS proxy_id bigint REFERENCES scrape_proxies(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_scrape_runs_proxy_id_started_at
ON scrape_runs (proxy_id, started_at DESC);
COMMENT ON COLUMN scrape_runs.proxy_id IS
'Узел пула (scrape_proxies.id), через который шёл прогон. NULL = прогон без '
'прокси (эстиматор, admin-инициированные вызовы с NON_RUN_LEASE_MARKER) либо '
'прогон ДО применения миграции 287 (backfill не делался — связать нечем). '
'Если узел менялся mid-run (ротация после серии провалов в browser_fetcher, '
'либо curl-путь берёт лиз заново на каждый вызов) — здесь ПОСЛЕДНИЙ выданный '
'узел, полная цепочка — counters->''proxy_ids'' (jsonb-массив id, в порядке '
'первой выдачи). ON DELETE SET NULL: удаление узла из пула не должно уносить '
'историю прогонов.';
-- ── Часть B: soft-clear в scrape_proxy_source_bans ───────────────────────────
ALTER TABLE scrape_proxy_source_bans
ADD COLUMN IF NOT EXISTS cleared_at timestamptz,
ADD COLUMN IF NOT EXISTS cleared_reason text;
COMMENT ON COLUMN scrape_proxy_source_bans.cleared_at IS
'Момент досрочного снятия бана (proxy_pool.clear_source_bans, #3404) — '
'оператором или успешной ротацией exit-IP. NULL = бан не снимался вручную '
'(либо ещё активен, либо истёк сам по banned_until). Строка при гашении НЕ '
'удаляется — доживает до штатного purge (SOURCE_BAN_PURGE_DAYS), таймер '
'которого для погашенных строк отсчитывается от banned_until = момент гашения.';
COMMENT ON COLUMN scrape_proxy_source_bans.cleared_reason IS
'Причина досрочного снятия бана (тот же текст, что передан в '
'clear_source_bans(reason=...)). NULL, если строка не гасилась вручную.';
COMMENT ON COLUMN scrape_proxy_source_bans.ban_count IS
'Сколько раз эта пара банилась. Срок ТЕКУЩЕГО бана (banned_until - banned_at) = '
'base * 2^(ban_count-1), потолок SOURCE_BAN_MAX_HOURS: ban_count=1 → 6ч, 2 → 12ч, '
'3 → 24ч и т.д. Сбрасывается либо purge''ем через SOURCE_BAN_PURGE_DAYS после '
'истечения, либо proxy_pool.clear_source_bans (#3404: досрочное ГАШЕНИЕ строки —'
' banned_until=now(), ban_count=0, cleared_at/cleared_reason проставляются; '
'строка НЕ удаляется, живёт до purge). Оба пути одинаково обнуляют ban_count, '
'поэтому следующий бан той же пары в обоих случаях стартует заново с '
'SOURCE_BAN_BASE_HOURS.';
COMMIT;