From 69e70473985d0043c78a25d6ca562a8109deb817 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:58:43 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D1=8B=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=D0=B0=20=D1=87=D0=B5=D0=BA=D0=BF=D0=BE=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B0:=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B,=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D1=83=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA?= =?UTF-8?q?=D1=83,=20=D1=83=D0=BF=D0=B0=D0=B2=D1=88=D0=B8=D0=B9=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2?= =?UTF-8?q?=20(#3415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #3561: три мутации гейта оставались зелёными — порог `attempted >= 2`, жадный гейт `failed >= 1` и снятый `houses_attempted += len(nb_id_list)` в ветке «houses DB query failed». Добавлены проверки по чекпоинту: - 1 из 2 домов отказал в каждом якоре — оба якоря в чекпоинте (контроль жадности); - единственный дом якоря отказал — якорь не в чекпоинте (порог); - упал запрос домов — якоря не в чекпоинте, houses_attempted == houses_failed == 2. Код не менялся. Co-Authored-By: Claude Opus 5 --- ...test_3415_bucket_done_only_if_phases_ok.py | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py b/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py index f74aaaa9..6b0155a8 100644 --- a/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py +++ b/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py @@ -49,8 +49,14 @@ DETAIL_ROWS = [ class _FakeDb: - def __init__(self, prev_counters: dict[str, Any] | None = None) -> None: + def __init__( + self, + prev_counters: dict[str, Any] | None = None, + house_rows: list[dict[str, Any]] | None = None, + ) -> None: self.prev_counters = prev_counters or {} + # [] — запрос домов падает (ветка «houses DB query failed»). + self.house_rows = HOUSE_ROWS if house_rows is None else house_rows self.heartbeats: list[dict[str, Any]] = [] def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: @@ -61,7 +67,9 @@ class _FakeDb: return MagicMock(fetchone=lambda: types.SimpleNamespace(counters=self.prev_counters)) if params and "ids" in params: # houses-фаза: дома, у которых есть cian_zhk_url. - return MagicMock(mappings=lambda: MagicMock(all=lambda: HOUSE_ROWS)) + if not self.house_rows: + raise RuntimeError("houses DB query failed") + return MagicMock(mappings=lambda: MagicMock(all=lambda: self.house_rows)) if params and "limit" in params: # detail-фаза avito: карточки-кандидаты на обогащение. return MagicMock(mappings=lambda: MagicMock(all=lambda: DETAIL_ROWS)) @@ -114,16 +122,23 @@ def _config() -> types.SimpleNamespace: async def _run( - prev: dict[str, Any] | None, *, houses_ok: bool, run_id: int = 8401 + prev: dict[str, Any] | None, + *, + houses_ok: bool, + run_id: int = 8401, + house_rows: list[dict[str, Any]] | None = None, + failing_urls: frozenset[str] | None = None, ) -> tuple[_FakeDb, Any]: from scraper_kit.orchestration import pipeline as pl _FakeScraper.visited = [] - db = _FakeDb(prev) + db = _FakeDb(prev, house_rows) - async def _fake_newbuilding(*_a: Any, **_kw: Any) -> Any: + async def _fake_newbuilding(zhk_url: str, *_a: Any, **_kw: Any) -> Any: # None — ровно тот отказ, что был на проде 06.09: исключения нет, # счётчик houses_failed растёт, фаза возвращается штатно. + if failing_urls is not None: + return None if zhk_url in failing_urls else object() return object() if houses_ok else None with ( @@ -189,6 +204,42 @@ async def test_healthy_anchor_is_still_checkpointed() -> None: ) +@pytest.mark.asyncio +async def test_anchor_with_partially_failed_houses_is_still_checkpointed() -> None: + """Контроль жадности: один отказавший дом из двух — якорь пройден. + + Гейт про ПОЛНЫЙ отказ фазы. Жадный гейт (любой отказ) собирал бы такой якорь + заново каждым прогоном — на проде houses почти всегда теряет пару домов. + """ + db, counters = await _run( + None, houses_ok=True, failing_urls=frozenset({HOUSE_ROWS[0]["cian_zhk_url"]}) + ) + assert (counters.houses_attempted, counters.houses_failed) == (4, 2) + assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], ( + f"якорь с частичным отказом houses не помечен пройденным: {_last_checkpoint(db)}" + ) + + +@pytest.mark.asyncio +async def test_single_failed_attempt_blocks_checkpoint() -> None: + """Порог — одна попытка: единственный дом якоря отказал — якорь не пройден.""" + db, counters = await _run(None, houses_ok=False, house_rows=HOUSE_ROWS[:1]) + assert (counters.houses_attempted, counters.houses_enriched) == (2, 0) + assert _last_checkpoint(db) == [], ( + f"якорь с 1 из 1 отказавшим домом помечен пройденным: {_last_checkpoint(db)}" + ) + + +@pytest.mark.asyncio +async def test_houses_db_query_failure_blocks_checkpoint() -> None: + """Упал запрос домов: попытки считаются вместе с отказами, якорь не пройден.""" + db, counters = await _run(None, houses_ok=True, house_rows=[]) + assert _last_checkpoint(db) == [], ( + f"якорь с упавшим запросом домов помечен пройденным: {_last_checkpoint(db)}" + ) + assert (counters.houses_attempted, counters.houses_failed) == (2, 2) + + class _FakeAvitoScraper: """Двойник AvitoScraper: SERP отдаёт лоты, дальше работает detail-фаза."""