tradein/proxy: пул не отличает «оператор выключил руками» от авто-отключения — проверка здоровья молча возвращает узел в строй #2610

Closed
opened 2026-08-01 05:37:06 +00:00 by lekss361 · 1 comment
Owner

Находка из ревью PR #2609 (самовосстановление пула). Не блокировала мерж, но оставлять нельзя — следующий оператор наступит.

Суть

PR #2609 чинит первопричину прод-инцидента: раньше run_proxy_healthcheck брал WHERE enabled, поэтому узел, авто-выключенный после 5 сбоев, не проверялся больше никогда и вернуться не мог. Транзиентный сбой сети становился вечным приговором — измерено на проде: два исправных узла лежали выключенными, а единственный «здоровый» отдавал 502.

Лечение — mark_health(ok=True) теперь безусловно ставит enabled = true. И вот здесь побочный эффект.

В схеме scrape_proxies (миграции 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql) нет поля, различающего два разных смысла enabled = false:

  1. пул выключил сам после серии сбоев — это надо уметь отменять;
  2. человек выключил руками через PATCH /proxies/{id} (admin.py:2852) или POST /proxies/bulk (admin.py:2744) — это отменять нельзя.

После #2609 второй случай перестаёт работать: оператор снимает узел из ротации, а первая же успешная проба ipify возвращает его обратно. Молча — в этой ветке даже предупреждения нет.

Особенно неприятно в главном рабочем сценарии: оператор выводит узел, забаненный Авито. Проба ipify через него проходит (ipify никого не банит), значит узел вернётся в строй гарантированно, и снимать его придётся снова и снова.

Решение

Колонка manually_disabled boolean NOT NULL DEFAULT false (или disabled_reason text — второе информативнее для разбора). Выставляется в админских ручках при ручном выключении; mark_health(ok=True) не трогает enabled, если она взведена. Обратно совместимо: у всех существующих строк false, поведение #2609 сохраняется для авто-выключенных.

Не забыть: ручка включения должна её сбрасывать, иначе узел, выключенный руками однажды, больше никогда не будет автоматически восстановлен.

Зависимость, которую стоит держать в голове

Пока сигнал бана до пула не доходит (пункт 2 issue #2600 — бан Авито приходит страницей с кодом 200 и до mark_health не долетает), флаппинг «забанен → воскрешён по ipify → снова забанен» остаётся теоретическим. Как только тот сигнал появится, связка «безусловное включение» + «бан неотличим от сетевого сбоя» станет реальным источником флаппинга. То есть эту задачу желательно закрыть ДО пункта 2 #2600, а не после.

Связано: #2600, PR #2609.

Находка из ревью PR #2609 (самовосстановление пула). Не блокировала мерж, но оставлять нельзя — следующий оператор наступит. ## Суть PR #2609 чинит первопричину прод-инцидента: раньше `run_proxy_healthcheck` брал `WHERE enabled`, поэтому узел, авто-выключенный после 5 сбоев, не проверялся больше никогда и вернуться не мог. Транзиентный сбой сети становился вечным приговором — измерено на проде: два исправных узла лежали выключенными, а единственный «здоровый» отдавал 502. Лечение — `mark_health(ok=True)` теперь **безусловно** ставит `enabled = true`. И вот здесь побочный эффект. В схеме `scrape_proxies` (миграции `157_scrape_proxies.sql`, `173_scrape_proxies_add_domclick_affinity.sql`) **нет поля, различающего два разных смысла `enabled = false`**: 1. пул выключил сам после серии сбоев — это надо уметь отменять; 2. человек выключил руками через `PATCH /proxies/{id}` (`admin.py:2852`) или `POST /proxies/bulk` (`admin.py:2744`) — это отменять нельзя. После #2609 второй случай перестаёт работать: оператор снимает узел из ротации, а первая же успешная проба ipify возвращает его обратно. Молча — в этой ветке даже предупреждения нет. Особенно неприятно в главном рабочем сценарии: оператор выводит узел, забаненный Авито. Проба ipify через него **проходит** (ipify никого не банит), значит узел вернётся в строй гарантированно, и снимать его придётся снова и снова. ## Решение Колонка `manually_disabled boolean NOT NULL DEFAULT false` (или `disabled_reason text` — второе информативнее для разбора). Выставляется в админских ручках при ручном выключении; `mark_health(ok=True)` не трогает `enabled`, если она взведена. Обратно совместимо: у всех существующих строк `false`, поведение #2609 сохраняется для авто-выключенных. Не забыть: ручка включения должна её сбрасывать, иначе узел, выключенный руками однажды, больше никогда не будет автоматически восстановлен. ## Зависимость, которую стоит держать в голове Пока сигнал бана до пула не доходит (пункт 2 issue #2600 — бан Авито приходит страницей с кодом 200 и до `mark_health` не долетает), флаппинг «забанен → воскрешён по ipify → снова забанен» остаётся **теоретическим**. Как только тот сигнал появится, связка «безусловное включение» + «бан неотличим от сетевого сбоя» станет реальным источником флаппинга. То есть эту задачу желательно закрыть ДО пункта 2 #2600, а не после. Связано: #2600, PR #2609.
Collaborator

Сделано — PR #2652, смержен и проверен на проде.

Колонка scrape_proxies.disabled_reason (миграция 209) различает две причины enabled=false, которые до этого выглядели одинаково:

  • пул выключил сам после серии сбоев → disabled_reason IS NULL. Первая же успешная ipify-проба возвращает узел в строй, как и задумано самовосстановлением #2609.
  • оператор выключил руками через admin API → disabled_reason заполнен. mark_health(ok=True) больше не трогает enabled, а пишет WARNING с id узла и причиной.

Смысл различия ровно в том сценарии, который эту задачу породил: узел, снятый с ротации как забаненный площадкой, ipify-пробой не лечится — ipify площадку не эмулирует и бана не видит, — поэтому раньше он молча возвращался в ротацию и снова получал блок. Сброс флага только явный: PATCH /proxies/{id} с enabled=true обнуляет disabled_reason в NULL.

Прод после деплоя: колонка на месте, у всех 4 живых узлов пуста (никто не снят руками), ложных воскрешений нет.

Замечу, что семантика disabled_reason='banned:<источник>', которую ввёл #2600 п.1, в п.2 (PR #2654) заменяется на отдельную таблицу бана по паре «узел × источник» — сама эта задача от того не меняется: различие «ручное против авто» остаётся и продолжает работать, а миграция 210 конвертирует остаточные banned:-строки, чтобы ни один узел не завис выключенным навсегда.

Сделано — PR #2652, смержен и проверен на проде. Колонка `scrape_proxies.disabled_reason` (миграция 209) различает две причины `enabled=false`, которые до этого выглядели одинаково: - **пул выключил сам** после серии сбоев → `disabled_reason IS NULL`. Первая же успешная ipify-проба возвращает узел в строй, как и задумано самовосстановлением #2609. - **оператор выключил руками** через admin API → `disabled_reason` заполнен. `mark_health(ok=True)` больше не трогает `enabled`, а пишет WARNING с id узла и причиной. Смысл различия ровно в том сценарии, который эту задачу породил: узел, снятый с ротации как забаненный площадкой, ipify-пробой не лечится — ipify площадку не эмулирует и бана не видит, — поэтому раньше он молча возвращался в ротацию и снова получал блок. Сброс флага только явный: `PATCH /proxies/{id}` с `enabled=true` обнуляет `disabled_reason` в NULL. Прод после деплоя: колонка на месте, у всех 4 живых узлов пуста (никто не снят руками), ложных воскрешений нет. Замечу, что семантика `disabled_reason='banned:<источник>'`, которую ввёл #2600 п.1, в п.2 (PR #2654) заменяется на отдельную таблицу бана по паре «узел × источник» — сама эта задача от того не меняется: различие «ручное против авто» остаётся и продолжает работать, а миграция 210 конвертирует остаточные `banned:`-строки, чтобы ни один узел не завис выключенным навсегда.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2610
No description provided.