gendesign/tradein-mvp/backend/data/sql/210_scrape_proxy_source_bans.sql
bot-backend 964867a943
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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 2m43s
feat(tradein/proxy): здоровье прокси по паре «узел × источник» (#2600 п.2)
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.

Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.

Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.

Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.

Refs #2600
2026-08-05 17:14:12 +05:00

98 lines
6.5 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. Ручные выключения (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;