-- 210_scrape_proxy_source_bans.sql -- Здоровье прокси по ПАРЕ «узел × источник» (#2600 п.2). -- -- WHY: -- До сих пор бан был ГЛОБАЛЬНЫМ: #2600 п.1 (mark_banned) на распознанный бан -- площадкой выключал узел целиком — enabled=false, disabled_reason='banned:'. -- Реальность другая: Авито банит IP, а Яндекс через тот же IP ходит чисто. Один -- забаненный источник выкидывал живой узел из пула для ВСЕХ источников, пул худел -- в разы быстрее, чем его успевают пополнять (#2638). -- -- WHAT: -- scrape_proxy_source_bans — по строке на пару (proxy_id, source). Пока -- banned_until > now(), acquire(source) этот узел НЕ выдаёт; для любого ДРУГОГО -- источника узел остаётся первосортным. Узел больше не выключается глобально — -- enabled/disabled_reason остаются исключительно за оператором (#2610) и за -- авто-disable'ом по серии транспортных сбоев (mark_health). -- -- ban_count — счётчик повторных банов той же пары: срок эскалирует -- 6ч → 12ч → 24ч → 48ч → 72ч (потолок), см. SOURCE_BAN_BASE_HOURS/ -- SOURCE_BAN_MAX_HOURS в app/services/proxy_pool.py. Истёкшие строки НЕ -- удаляются сразу — purge в run_proxy_healthcheck сносит их только через 7 суток -- после истечения (SOURCE_BAN_PURGE_DAYS), и это же механизм сброса ban_count: -- узел, неделю чистый после снятия бана, начинает эскалацию с нуля. -- -- КОНВЕРСИЯ СТАРЫХ ГЛОБАЛЬНЫХ БАНОВ (обязательная часть миграции): -- После #2600 п.1 на проде могли остаться узлы enabled=false с -- disabled_reason LIKE 'banned:%'. Новый код такой семантики больше НЕ пишет и -- ничего её не снимает, а mark_health(ok=True) не воскрешает узлы с non-NULL -- disabled_reason (#2610) — узел завис бы выключенным навсегда, до ручного PATCH. -- Поэтому здесь каждый такой узел конвертируется в per-source бан на 6 часов -- (тот же SOURCE_BAN_BASE_HOURS) и возвращается в строй: enabled=true, -- disabled_reason=NULL. Матчинг по ТОЧНОМУ списку 'banned:<источник>', а не по -- LIKE — ручные тексты оператора (в т.ч. начинающиеся с 'banned:', этот формат -- подсказан комментарием 209-й) НЕ трогаются, это его решение. -- -- IDEMPOTENCY / SAFETY: -- - Весь файл в одной транзакции BEGIN/COMMIT. -- - CREATE TABLE / INDEX IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING → -- повторный прогон no-op (auto-apply strict на деплое это требует). -- - Конверсионный UPDATE после повторного прогона не находит строк (первый -- прогон уже снял disabled_reason) — тоже no-op. -- - ON DELETE CASCADE: удаление прокси уносит его баны, «висячих» строк нет. -- -- Dependencies: 157_scrape_proxies.sql, 209_scrape_proxies_disabled_reason.sql BEGIN; CREATE TABLE IF NOT EXISTS scrape_proxy_source_bans ( proxy_id bigint NOT NULL REFERENCES scrape_proxies(id) ON DELETE CASCADE, source text NOT NULL, banned_until timestamptz NOT NULL, reason text, ban_count integer NOT NULL DEFAULT 1, banned_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (proxy_id, source) ); COMMENT ON TABLE scrape_proxy_source_bans IS 'Баны прокси по паре (узел, источник), #2600 п.2. Активна строка с ' 'banned_until > now() — acquire(source) такой узел не выдаёт, для других ' 'источников узел остаётся доступным. Глобальное выключение узла (enabled=false) ' 'сюда НЕ относится — это ручное действие оператора или авто-disable по серии ' 'транспортных сбоев.'; 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 ' '(ручное включение узла оператором / успешная ротация exit-IP).'; -- Горячий путь — NOT EXISTS-фильтр в acquire(): (proxy_id, source) уже покрыт PK, -- этот индекс закрывает purge/листинг активных банов по времени. CREATE INDEX IF NOT EXISTS idx_scrape_proxy_source_bans_until ON scrape_proxy_source_bans (banned_until); -- ── конверсия старых глобальных банов (#2600 п.1 → п.2) ───────────────────── -- -- ТОЧНЫЙ список значений, а не LIKE 'banned:%': 209-я миграция сама предлагает этот -- формат в комментарии, поэтому оператор мог написать руками что-то вроде -- 'banned:avito вручную'. LIKE тогда дал бы source='avito вручную' (бан-строка, которая -- ни с чем не сматчится) и МОЛЧА отменил бы ручное выключение. Домен ниже — тот же, что -- у scrape_proxies.provider_affinity (на практике mark_banned п.1 писал только -- avito/cian/yandex/domclick — это значения BrowserFetcher._source). INSERT INTO scrape_proxy_source_bans (proxy_id, source, banned_until, reason) SELECT id, substring(disabled_reason from 8), -- отрезает префикс 'banned:' (7 символов) now() + interval '6 hours', 'migrated from disabled_reason (210)' FROM scrape_proxies WHERE NOT enabled AND disabled_reason IN ('banned:avito', 'banned:cian', 'banned:yandex', 'banned:domclick', 'banned:generic', 'banned:any') ON CONFLICT (proxy_id, source) DO NOTHING; UPDATE scrape_proxies SET enabled = true, disabled_reason = NULL, updated_at = now() WHERE NOT enabled AND disabled_reason IN ('banned:avito', 'banned:cian', 'banned:yandex', 'banned:domclick', 'banned:generic', 'banned:any'); COMMIT;