All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m45s
Правки по deep-review PR #2654. MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование, что backup не забанен своим источником — в acquire и зеркально в защите mark_banned. MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены адреса строка держала бы узел вне выдачи без причины). LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено мутацией, тесты краснеют при удалении NOT EXISTS из запроса. LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено до точного списка значений домена provider_affinity. LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false); формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего). Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное. Refs #2600
105 lines
7.4 KiB
PL/PgSQL
105 lines
7.4 KiB
PL/PgSQL
-- 210_scrape_proxy_source_bans.sql
|
||
-- Здоровье прокси по ПАРЕ «узел × источник» (#2600 п.2).
|
||
--
|
||
-- WHY:
|
||
-- До сих пор бан был ГЛОБАЛЬНЫМ: #2600 п.1 (mark_banned) на распознанный бан
|
||
-- площадкой выключал узел целиком — enabled=false, disabled_reason='banned:<source>'.
|
||
-- Реальность другая: Авито банит 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;
|