From ba4fb03a0544be9356a7b8658b0a48e649cb8b28 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 10:07:29 +0500 Subject: [PATCH] =?UTF-8?q?fix(#3393):=20capped=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=D1=83=20=D0=BE=D0=BA=D0=BD=D1=83=20=E2=80=94=20int?= =?UTF-8?q?errupted-=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B3=D0=BB=D1=83=D1=88=D0=B8=D1=82=20=D0=BB=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=83=20=D0=BD=D0=B0=D0=B2=D1=81=D0=B5=D0=B3?= =?UTF-8?q?=D0=B4=D0=B0;=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=20zero-=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=B6=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrape_runs.py | 16 ++++- ...t_3393_interrupted_runs_outside_streaks.py | 58 +++++++++++++++++++ .../src/scraper_kit/orchestration/runs.py | 16 ++++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 3df5c256..6e4b5450 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_3393_interrupted_runs_outside_streaks.py b/tradein-mvp/backend/tests/test_3393_interrupted_runs_outside_streaks.py index c4fd4042..9a492738 100644 --- a/tradein-mvp/backend/tests/test_3393_interrupted_runs_outside_streaks.py +++ b/tradein-mvp/backend/tests/test_3393_interrupted_runs_outside_streaks.py @@ -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 ─────────────────────── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 7d2c82bb..67915221 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -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