fix(#3393): capped по сканированному окну — interrupted-строка не глушит лестницу навсегда; контроль zero-сторожа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m55s

This commit is contained in:
bot-backend 2026-09-06 10:07:29 +05:00
parent 5f94bf0eeb
commit ba4fb03a05
3 changed files with 86 additions and 4 deletions

View file

@ -463,9 +463,17 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
# #3393: оборванного деплоем прогона в популяции стрика нет. getattr —
# функция целиком под `except Exception: pass`, и строка без колонки
# выключила бы сторож молча (тот же класс, что #2703).
scanned = len(rows)
rows = [r for r in rows if not _was_interrupted(getattr(r, "counters", None))]
streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned"))
capped = streak >= STREAK_SCAN_LIMIT
# Потолок — по СКАНИРОВАННОМУ окну, а не по отфильтрованному: по второму
# одна interrupted-строка внутри 500 опускает измеримый максимум до 499, и
# `capped` становится недостижим по построению. Источник, сломанный наглухо,
# замолкал бы после 384-й неудачи навсегда (следующая веха 768 больше окна),
# а новую interrupted-строку в окно подкладывает каждый деплой — ровно
# анти-спам «один раз навсегда» из #2670/#2703. Условие читается так: окно
# было полным И стрик покрывает всё, что мы вообще могли судить.
capped = scanned >= STREAK_SCAN_LIMIT and streak >= len(rows)
if not capped and not _streak_alert_due(streak, n):
return
@ -526,6 +534,7 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
# #3393: та же популяция, что у сторожа неудач — оборванный деплоем прогон
# не судим (в т.ч. не он решает, «измерен» ли свежайший результат ниже).
scanned = len(rows)
rows = [r for r in rows if not _was_interrupted(getattr(r, "counters", None))]
if not rows:
return
@ -546,7 +555,10 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
return
streak = _leading_streak(rows, _is_zero_done)
capped = streak >= STREAK_SCAN_LIMIT
# Потолок — по сканированному окну, как у _alert_if_consecutive_failures:
# по отфильтрованному списку одна interrupted-строка делает `capped`
# недостижимым, и лестница за последней вехой в пределах окна молчит навсегда.
capped = scanned >= STREAK_SCAN_LIMIT and streak >= len(rows)
if not capped and not _streak_alert_due(streak, n):
return

View file

@ -131,6 +131,64 @@ def test_streaks_unchanged_without_the_mark(name: str) -> None:
_run_failure_watchdog(_MODULES[name], rows).capture_message.assert_not_called()
@pytest.mark.parametrize("name", list(_MODULES))
def test_zero_streak_unchanged_without_the_mark(name: str) -> None:
"""Тот же контроль для zero-result-сторожа: 4 измеренных нуля + 'done' с
результатом, без метки стрик 4, между вехами, молчание (прежнее поведение)."""
rows = [_row("done", lots_fetched=0) for _ in range(4)] + [_row("done", lots_fetched=42)]
_run_zero_watchdog(_MODULES[name], rows).capture_message.assert_not_called()
# ── (б2) потолок окна: одна interrupted-строка не глушит лестницу навсегда ────
def _full_window(mod: Any, row: SimpleNamespace, interrupted: SimpleNamespace | None) -> list[Any]:
"""Полное окно сканирования из одинаковых строк; `interrupted` (если дан) — внутри."""
rows = [row for _ in range(mod.STREAK_SCAN_LIMIT)]
if interrupted is not None:
rows[100] = interrupted
return rows
@pytest.mark.parametrize("name", list(_MODULES))
def test_full_failure_window_alerts_despite_one_interrupted(name: str) -> None:
"""500 отказов, одна строка с меткой внутри окна → алерт по потолку, не тишина.
Красный на main: `capped` считался по УЖЕ отфильтрованному списку, где максимум
499 то есть недостижим по построению, как только в окно попадает хоть одна
interrupted-строка (а её подкладывает каждый деплой). Стрик 499 мимо вехи
(последняя в пределах окна 384, следующая 768), поэтому источник, сломанный
наглухо, замолкал бы навсегда: ровно анти-спам «один раз навсегда» (#2670/#2703).
"""
mod = _MODULES[name]
limit = mod.STREAK_SCAN_LIMIT
marked = _row("failed", attempted=10, failed=2, interrupted=1)
sentry = _run_failure_watchdog(mod, _full_window(mod, _row("failed"), marked))
sentry.capture_message.assert_called_once()
assert f"{limit - 1} consecutive" in sentry.capture_message.call_args[0][0]
# Контроль: то же окно без метки алертило и до правки — краснеет метка, а не длина.
control = _run_failure_watchdog(mod, _full_window(mod, _row("failed"), None))
control.capture_message.assert_called_once()
assert f"{limit} consecutive" in control.capture_message.call_args[0][0]
@pytest.mark.parametrize("name", list(_MODULES))
def test_full_zero_window_alerts_despite_one_interrupted(name: str) -> None:
"""Тот же потолок у второй лестницы — обе копии правятся одинаково."""
mod = _MODULES[name]
limit = mod.STREAK_SCAN_LIMIT
zero = _row("done", lots_fetched=0)
marked = _row("done", lots_fetched=0, interrupted=1)
sentry = _run_zero_watchdog(mod, _full_window(mod, zero, marked))
sentry.capture_message.assert_called_once()
assert f"{limit - 1} consecutive" in sentry.capture_message.call_args[0][0]
control = _run_zero_watchdog(mod, _full_window(mod, zero, None))
control.capture_message.assert_called_once()
assert f"{limit} consecutive" in control.capture_message.call_args[0][0]
# ── (в) honest-status-гейт не судит частичные counters ───────────────────────

View file

@ -462,9 +462,17 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
# #3393: оборванного деплоем прогона в популяции стрика нет. getattr —
# функция целиком под `except Exception: pass`, и строка без колонки
# выключила бы сторож молча (тот же класс, что #2703).
scanned = len(rows)
rows = [r for r in rows if not _was_interrupted(getattr(r, "counters", None))]
streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned"))
capped = streak >= STREAK_SCAN_LIMIT
# Потолок — по СКАНИРОВАННОМУ окну, а не по отфильтрованному: по второму
# одна interrupted-строка внутри 500 опускает измеримый максимум до 499, и
# `capped` становится недостижим по построению. Источник, сломанный наглухо,
# замолкал бы после 384-й неудачи навсегда (следующая веха 768 больше окна),
# а новую interrupted-строку в окно подкладывает каждый деплой — ровно
# анти-спам «один раз навсегда» из #2670/#2703. Условие читается так: окно
# было полным И стрик покрывает всё, что мы вообще могли судить.
capped = scanned >= STREAK_SCAN_LIMIT and streak >= len(rows)
if not capped and not _streak_alert_due(streak, n):
return
@ -527,6 +535,7 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
# #3393: та же популяция, что у сторожа неудач — оборванный деплоем прогон
# не судим (в т.ч. не он решает, «измерен» ли свежайший результат ниже).
scanned = len(rows)
rows = [r for r in rows if not _was_interrupted(getattr(r, "counters", None))]
if not rows:
return
@ -547,7 +556,10 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
return
streak = _leading_streak(rows, _is_zero_done)
capped = streak >= STREAK_SCAN_LIMIT
# Потолок — по сканированному окну, как у _alert_if_consecutive_failures:
# по отфильтрованному списку одна interrupted-строка делает `capped`
# недостижимым, и лестница за последней вехой в пределах окна молчит навсегда.
capped = scanned >= STREAK_SCAN_LIMIT and streak >= len(rows)
if not capped and not _streak_alert_due(streak, n):
return