From 71d60ff13e4d9f229f67447b7180020a6160e47b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:24:20 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0):=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=81=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=83=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D1=88=D0=BD=D1=8B=D0=BC=20(#3511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обработчик landing_showcase_deals безусловно ставил прогону done. У счётчиков витрины (considered/eligible/written) нет результатного ключа кита, поэтому сводка просроченных судит её только по статусу: прогон с written=0 обнулял часы свежести так же, как удачный, а страница тем временем теряла таблицу. Теперь written=0 — mark_failed с причиной и logger.error. Тест идёт путём сводки: обработчик -> freshness_rows -> stale_sources; пустой последний прогон при старом непустом даёт витрину в тревоге, непустой — нет. Co-Authored-By: Claude Opus 5 --- .../backend/app/services/product_handlers.py | 18 +++- .../tests/test_3469_showcase_schedule.py | 82 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 42630b40..7cc7d6d6 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -357,7 +357,23 @@ async def _job_landing_showcase_deals( counters = await loop.run_in_executor( None, lambda: refresh_landing_showcase_deals(db, **kwargs) ) - ctx.runs.mark_done(db, run_id, counters) + # #3511: пустая витрина — не успех. Результатного ключа кита + # (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных + # судит её по статусу, и `done` с written=0 гасил часы свежести так же, + # как удачный прогон, — а страница тем временем теряла таблицу целиком. + if not counters.get("written"): + logger.error( + "landing_showcase_deals run_id=%d: витрина пуста (written=0, " + "considered=%s, eligible=%s) — прогон неуспешен", + run_id, + counters.get("considered"), + counters.get("eligible"), + ) + ctx.runs.mark_failed( + db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception: logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id) ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {}) diff --git a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py index 4394079b..79a9a5b0 100644 --- a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py +++ b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py @@ -53,6 +53,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Any +from unittest.mock import MagicMock, patch import pytest @@ -211,6 +212,87 @@ def test_showcase_counters_do_not_fake_freshness() -> None: assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False +# ── 3а. Пустой прогон витрины не гасит часы свежести (#3511) ──────────────── + + +class _RecordedRuns: + """`ctx.runs`, который кладёт финал прогона строкой `_STALE_SOURCES_SQL`. + + Тогда тест идёт ТЕМ ЖЕ путём, что и сводка на проде: обработчик ставит статус, + `freshness_rows` судит, принёс ли прогон данные, `stale_sources` — просрочку. + Проверка «вызван mark_failed» такой путь не проходит: она зелёная и тогда, + когда статус верный, а мера свежести его не видит. + """ + + def __init__(self) -> None: + self.rows: list[Any] = [] + self.finished_at = NOW + + def _row(self, status: str, counters: dict[str, Any]) -> None: + self.rows.append( + SimpleNamespace( + source=SOURCE, + interval_days=str(_seeded_interval_days()), + created_at=NOW - timedelta(days=30), + finished_at=self.finished_at, + status=status, + counters=counters, + ) + ) + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self._row("done", counters) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self._row("failed", counters) + + +async def _run_handler(runs: _RecordedRuns, counters: dict[str, int], age_days: float) -> None: + runs.finished_at = NOW - timedelta(days=age_days) + with patch( + "app.tasks.landing_showcase_deals.refresh_landing_showcase_deals", + return_value=counters, + ): + await _job_landing_showcase_deals(MagicMock(), 1, {}, SimpleNamespace(runs=runs)) + + +_FULL = {"considered": 200, "priced": 180, "eligible": 160, "written": 20} +_EMPTY = {"considered": 200, "priced": 180, "eligible": 160, "written": 0} + + +@pytest.mark.asyncio +async def test_empty_showcase_run_does_not_reset_freshness() -> None: + """Последний непустой прогон старше 3× такта, вчерашний пустой → витрина в тревоге. + + До #3511 пустой прогон завершался `done`, и `run_brought_data` (судит по + статусу: результатного ключа кита у витрины нет) засчитывал его свежестью — + опустевший блок лэндинга молчал бы сколько угодно. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _EMPTY, age_days=1) + + stale = _stale_now(runs.rows) + assert [s.source for s in stale] == [SOURCE], ( + f"пустой прогон витрины засчитан свежестью: статусы {[r.status for r in runs.rows]}" + ) + assert stale[0].age_days > _ACCEPTANCE_CYCLES * _seeded_interval_days() + + +@pytest.mark.asyncio +async def test_nonempty_showcase_run_keeps_freshness() -> None: + """Контроль: тот же сценарий с непустым последним прогоном — тревоги нет. + + Зелёный с обеих сторон правки; без него правка могла бы валить любой прогон. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _FULL, age_days=1) + + assert _stale_now(runs.rows) == [] + assert [r.status for r in runs.rows] == ["done", "done"] + + # ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────