-- 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;