-- 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. Ручные выключения (disabled_reason без префикса -- 'banned:') НЕ трогаются — это решение оператора. -- -- 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 'Сколько раз эта пара банилась. Срок следующего бана = base * 2^(ban_count-1), ' 'потолок SOURCE_BAN_MAX_HOURS. Сбрасывается только удалением строки purge''ем ' 'через SOURCE_BAN_PURGE_DAYS после истечения бана.'; -- Горячий путь — 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) ───────────────────── -- -- substring(... from 8) — отрезает префикс 'banned:' (7 символов). LIKE 'banned:_%' -- (а не 'banned:%') гарантирует непустой source для NOT NULL-колонки; вырожденное -- 'banned:' без источника строки бана не получает, но узел ниже всё равно вернётся -- в строй — висеть вечно выключенным он не должен ни в каком случае. INSERT INTO scrape_proxy_source_bans (proxy_id, source, banned_until, reason) SELECT id, substring(disabled_reason from 8), now() + interval '6 hours', 'migrated from disabled_reason (210)' FROM scrape_proxies WHERE NOT enabled AND disabled_reason LIKE 'banned:_%' 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 LIKE 'banned:%'; COMMIT;