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
Бан площадкой был глобальным: п.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
98 lines
6.5 KiB
PL/PgSQL
98 lines
6.5 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. Ручные выключения (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;
|