Выбор оператора мобильного прокси опирался на две ненадёжные опоры. Первая: `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
107 lines
8.6 KiB
PL/PgSQL
107 lines
8.6 KiB
PL/PgSQL
-- 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;
|