gendesign/tradein-mvp/backend/app/services/backfill_block_breaker.py
bot-backend 8176e8d167
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-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 4m49s
fix(tradein/scrapers): ABORT-лог называл серию блоков, хотя рвал прогон по доле
Прод-прогон 5210 оборвался по ratio-критерию — 14 блоков из 20, ровно порог 0.7 —
и отчитался строкой «ABORT -- 1 consecutive blocks». Число верное: последняя серия
в тот момент действительно равнялась единице (19-я попытка успех, 20-я блок).
Величина не та. Читатель лога видит цифру, по которой обрыва быть не могло, и идёт
искать несуществующий баг в брейкере.

Причина: #3184 заменил критерий обрыва на долю в скользящем окне, а текст лога
остался от прежнего критерия «N подряд» — то есть ровно та же болезнь, которую
#3178 лечил у соседней строки (литерал «IP rate-limited» вместо измеренной причины).

- BlockRatioBreaker.abort_reason() возвращает "ratio" / "safety_net" / None;
  should_abort() выражен через него, поведение не меняется.
- abort_explanation() даёт текст с той величиной, по которой обрыв и произошёл:
  доля печатает «доля блоков 14/20 в окне (порог 70%)», safety-net — «5 блоков
  подряд без единого успеха (снапшот 5 короче окна 20)».
- counters["abort_reason"] — чтобы причина обрыва читалась SQL-запросом по
  scrape_runs, а не грепом контейнера. Ключа нет, если прогон не обрывался.

Тесты (проверено мутацией источника — на прежнем сообщении оба падают):
- ratio-обрыв на раскладке прогона 5210 (серия на обрыве = 1) требует «14/20»
  в логе и отсутствия слова consecutive;
- safety-net требует «5 блоков подряд» и отсутствия «доля блоков» — без этого
  зеркала первый тест проходил бы и у сообщения, всегда печатающего долю;
- прогон без обрыва (13/20) не пишет abort_reason в counters.

Refs #3184, #3178
2026-08-28 23:04:07 +03:00

176 lines
12 KiB
Python
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.

"""Критерий обрыва avito_detail_backfill по доле блоков (#3184).
Модуль написан источник-агностично (см. BlockRatioBreaker ниже), но единственный
текущий вызывающий -- avito_detail_backfill. Исходная задача планировала тот же
критерий и для domclick_detail_backfill ("тот же критерий, что у avito"), но это
снято ревью (#3184 review MAJOR 1): у Домклика ОДИН выделенный residential-прокси
БЕЗ ротации (data/sql/175_scrape_schedules_seed_domclick_detail_backfill.sql,
max_consecutive_blocks=3 против avito=5 -- намеренно ниже, ранний обрыв бережёт
репутацию единственного узла), а калибровка окна/порога ниже сделана на avito-пуле
С ротацией. При устойчивой доле блоков около порога (например 0.65) Домклик по
этому критерию не обрывался бы никогда -- десятки запросов по QRATOR с одного
IP, ровно то, что max_consecutive_blocks=3 был призван предотвратить. Домклик
получит свою калибровку отдельной задачей.
Раньше avito_detail_backfill абортил по голой длине "N блоков подряд"
(max_consecutive_blocks). Замер 40 прогонов avito за 14 суток (2026-08-14..28)
показал: ВСЕ 40 закончились статусом 'banned', при этом доля блоков в них
колебалась 25-100% (47/190=25%, 39/100=39%, 33/71=46%, 20/63=32%, 5/5=100%) --
"N подряд" не отличал выгоревший прокси-пул от прогона, который честно
обогатил 125 карточек из 190. Причина: блоки автокоррелированы и идут
пачками -- при базовой доле 25-46% пачка из 5-6 подряд у независимой модели
была бы редкостью, но встречается в каждом прогоне.
Критерий заменён на долю блоков в скользящем окне последних N попыток: пачка
сама по себе больше не абортит, абортит устойчиво высокая доля (>= порога,
не строго "выше" -- окно 20 при пороге 0.7 абортит уже на 14/20, 13/20 ещё
нет). Окно короче типичной пачки бесполезно, поэтому дефолт (settings.
detail_backfill_block_ratio_window) заметно больше 5-6.
Safety-net для прогонов КОРОЧЕ окна (снапшот меньше window_size -- ratio-
критерий физически недостижим, окно никогда не заполнится) сохранён: если ВСЕ
попытки с начала прогона были блоками (ни одного успеха) и накопилось не
меньше safety_min -- абортим не дожидаясь окна. Раньше это было единственным
критерием под именем max_consecutive_blocks; имя и дефолт (5) сохранены как
safety_min, чтобы не менять поведение коротких прогонов вида 0/5 (#3184).
Условие "снапшот короче окна" обязательно, иначе safety-net гасит сам фикс:
холодный старт сессии/прокси -- самое вероятное место пачки блоков, и на
длинном прогоне (снапшот >= окна) пачка из safety_min блоков В НАЧАЛЕ
абортила бы точно так же, как до правки, до того как ratio-критерий вообще
успел бы включиться (#3184 review MAJOR 2).
Отказы-не-блоки (сеть/таймаут/парсер уровня "ответ есть, но наш") НЕ рвут
"чистоту" серии для safety-net -- это исторический инвариант
avito_detail_backfill (только успех сбрасывал consecutive_blocks,
TimeoutError/Exception его не трогали), сохранён через record_failure():
она двигает окно (значит, доля в ratio-критерии всё равно снижается), но НЕ
трогает _pure_block_run и НЕ сбрасывает серию. Из-за этого 4 таймаута + 5
блоков подряд (ни одного успеха) всё ещё дают safety-net abort -- это
осознанное поведение, а не баг: таймаут не доказывает, что тракт живой ответ
получает, только успех доказывает.
"""
from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
@dataclass
class BlockRatioBreaker:
"""Стейт одного прогона detail-backfill'а: окно, safety-net, гистограмма пачек."""
window_size: int
ratio_threshold: float
safety_min: int
snapshot_size: int
_window: deque[bool] = field(init=False, repr=False)
_consecutive_blocks: int = field(default=0, init=False)
_pure_block_run: bool = field(default=True, init=False)
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
def __post_init__(self) -> None:
self._window = deque(maxlen=max(self.window_size, 1))
@property
def consecutive_blocks(self) -> int:
"""Для логов ABORT/BLOCKED -- та же цифра, что раньше выводилась в consecutive=%d."""
return self._consecutive_blocks
@property
def window_blocks(self) -> int:
"""Числитель ratio-критерия: сколько блоков в текущем окне."""
return sum(self._window)
@property
def window_len(self) -> int:
"""Знаменатель ratio-критерия: сколько попыток уже влезло в окно."""
return len(self._window)
def record_block(self) -> None:
self._consecutive_blocks += 1
self._window.append(True)
def record_success(self) -> None:
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
self._flush_streak()
self._pure_block_run = False
self._window.append(False)
def record_failure(self) -> None:
"""Отказ-не-блок: в знаменатель окна идёт (тракт получил ответ, не блок),
но серию/purity НЕ трогает -- зеркалит исторический avito-инвариант."""
self._window.append(False)
def record_neutral(self) -> None:
"""404/gone у avito -- не блок и не проверка тракта, не двигает ни окно, ни
серию (тот же смысл, что "neutral to the breaker" в комментарии у
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
задачи -- своя калибровка, свои ограничения прокси-пула)."""
return
def _flush_streak(self) -> None:
if self._consecutive_blocks:
self.streak_histogram[self._consecutive_blocks] += 1
self._consecutive_blocks = 0
def abort_reason(self) -> str | None:
"""Какой критерий требует обрыва прямо сейчас, или None.
Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому
вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
"""
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
if (
self.snapshot_size < self.window_size
and self._pure_block_run
and self._consecutive_blocks >= self.safety_min
):
return "safety_net"
if len(self._window) == self.window_size and self.window_size > 0:
ratio = sum(self._window) / self.window_size
if ratio >= self.ratio_threshold:
return "ratio"
return None
def should_abort(self) -> bool:
return self.abort_reason() is not None
def abort_explanation(self) -> str:
"""Текст для лога ABORT -- ровно та величина, по которой обрыв и произошёл.
До этого лог печатал "%d consecutive blocks" всегда, а критерий с #3184
стал ratio: прогон 5210 (14 блоков из 20, обрыв ровно по порогу 0.7) выдал
"ABORT -- 1 consecutive blocks", потому что в момент срабатывания текущая
серия равнялась единице. Число верное, величина не та -- читатель лога видит
цифру, по которой обрыва быть не могло, и идёт искать несуществующий баг.
Пустая строка означает "рвать не по чему" -- вызывается только под
should_abort(), так что в логи не попадает.
"""
reason = self.abort_reason()
if reason == "ratio":
return (
f"доля блоков {self.window_blocks}/{self.window_len} в окне "
f"(порог {self.ratio_threshold * 100:.0f}%)"
)
if reason == "safety_net":
return (
f"{self._consecutive_blocks} блоков подряд без единого успеха "
f"(снапшот {self.snapshot_size} короче окна {self.window_size})"
)
return ""
def finalize(self) -> dict[str, int]:
"""Досчитать хвостовую пачку (прогон оборвался посреди серии блоков, без
финального успеха) и отдать гистограмму для counters (jsonb) -- ключи СТРОКОЙ:
jsonb/JSON всё равно хранит только строковые ключи, отдаём их такими сразу,
чтобы то, что пишем ({6: 1}), совпадало с тем, что потом читаем ({"6": 1})."""
self._flush_streak()
return {str(streak): count for streak, count in self.streak_histogram.items()}