gendesign/tradein-mvp/backend/data/sql/210_scrape_proxy_source_bans.sql
bot-backend 00bc07a55f
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
fix(tradein/proxy): backup-узел должен быть пригоден + рычаг снятия бана (#2600 п.2)
Правки по 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
2026-08-05 17:45:07 +05:00

105 lines
7.4 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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