From caa3b8b0567561813b5b02cfe2ab1421e9a7ecbf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 05:48:32 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein):=20=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BF=D1=83=D0=BB=20=D0=94=D0=9E=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D1=80=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D0=B7=20=D0=B2=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=20(#3384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lease берётся один раз в BrowserFetcher.__aenter__, поэтому на проде с пустым пулом NoProxyAvailableError вылетает из самого `async with` — ДО первой карточки и мимо стоп-механики внутри цикла (no_proxy_stop = True; break), которая и пишет counters.no_proxy_stop. Общий `except Exception` ловил его и делал mark_failed с нулевыми counters без ключа: прогон, который к площадке не ходил вообще, в SQL-разборе простоя по counters.no_proxy_stop (#3288/#3367) не находится. Дыра одинаковая у всех трёх бэкфиллов (avito её тоже не обрабатывал: __aenter__ вызывается напрямую строкой 427, отказ уходит в тот же общий except). Правка — в трёх уже существующих обработчиках, которые и так зовут mark_failed: ключ no_proxy_stop=1 при caused_by_no_proxy(exc). Оборачивать `async with` в try/except пришлось бы с переносом ~200 строк тела под новый отступ в каждом файле, и покрывало бы только падение на входе; здесь ловится любой путь мимо цикла. Тест — через настоящий BrowserFetcher: подделан только провайдер прокси (его acquire поднимает NoProxyAvailableError), отказ рождается там же, где в проде. Проверяется failed + no_proxy_stop=1 + attempted=0 (у циана listings_processed=0) и ноль POST'ов в сайдкар. Closes #3384 --- tradein-mvp/backend/app/services/scheduler.py | 10 ++ .../app/tasks/avito_detail_backfill.py | 11 +- .../app/tasks/domclick_detail_backfill.py | 11 +- .../test_3384_no_proxy_at_batch_start.py | 167 ++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 7b42fd0e..4403836b 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -42,6 +42,7 @@ from scraper_kit.orchestration import runs as kit_runs # такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих) # сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах. from scraper_kit.orchestration.scheduler import compute_next_run_at +from scraper_kit.proxy_errors import caused_by_no_proxy from sqlalchemy import text from sqlalchemy.orm import Session @@ -208,6 +209,15 @@ async def _execute_cian_backfill( ) except Exception as exc: logger.exception("scheduler: cian_history_backfill run_id=%d failed", run_id) + if caused_by_no_proxy(exc): + # #3384: пул был пуст ещё ДО первого объявления — lease берётся в + # BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из + # самого `async with` (cian_history_backfill.py:218) мимо стоп-механики + # внутри цикла, которая и ставит no_proxy_stop. Без этого ключа прогон, + # который к площадке не ходил ВООБЩЕ, неотличим от любого другого падения: + # counters нулевые, причина только в тексте, а разбор простоя идёт SQL'ём + # по counters.no_proxy_stop (#3288/#3367). Ключ тот же, что у ветки выше. + counters["no_proxy_stop"] = 1 runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) raise diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index cbd52c23..72f4ab24 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -1088,7 +1088,16 @@ async def run_avito_detail_backfill( run_id, counters.duration_sec, ) - runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict()) + current_counters = counters.to_dict() + if _caused_by_empty_pool(exc): + # #3384: пул был пуст ещё ДО первой карточки — lease берётся в + # BrowserFetcher.__aenter__ (строка 427), поэтому NoProxyAvailableError + # вылетает мимо стоп-механики цикла, которая и ставит no_proxy_stop. Без + # ключа такой прогон (attempted=0, к площадке не ходили) неотличим от + # любого другого падения: разбор простоя идёт SQL'ём по + # counters.no_proxy_stop (#3288/#3367), а не грепом текста ошибки. + current_counters["no_proxy_stop"] = 1 + runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters) raise finally: diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 4d35cd87..5f4c6963 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -642,5 +642,14 @@ async def run_domclick_detail_backfill( run_id, counters.duration_sec, ) - runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict()) + current_counters = counters.to_dict() + if _caused_by_empty_pool(exc): + # #3384: пул был пуст ещё ДО первой карточки — lease берётся в + # BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из + # самого `async with` (строка 403) мимо стоп-механики цикла, которая и + # ставит no_proxy_stop. Без ключа такой прогон (attempted=0, к площадке не + # ходили) неотличим от любого другого падения: разбор простоя идёт SQL'ём + # по counters.no_proxy_stop (#3283/#3367), а не грепом текста ошибки. + current_counters["no_proxy_stop"] = 1 + runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters) raise diff --git a/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py b/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py new file mode 100644 index 00000000..6dbaae33 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py @@ -0,0 +1,167 @@ +"""#3384 — пустой пул ДО первого объявления терял диагноз в записи прогона. + +Lease берётся ОДИН раз в `BrowserFetcher.__aenter__` (см. `_acquire_lease`), поэтому +на проде с пустым пулом `NoProxyAvailableError` вылетает из самого `async with`, +ДО первой карточки — то есть мимо стоп-механики внутри цикла (`no_proxy_stop = True; +break`), которая и пишет `counters.no_proxy_stop`. Общий `except Exception` ловил его и +писал `mark_failed` со стухшими нулевыми counters без ключа: прогон, который к площадке +не ходил ВООБЩЕ, в SQL-разборе простоя по `counters.no_proxy_stop` (#3288/#3367) не +находится и выглядит как произвольное падение задачи. + +Подделка ровно одна — провайдер прокси, чей `acquire()` поднимает +`NoProxyAvailableError` (так ведёт себя пул, когда живых узлов не осталось). +`BrowserFetcher` НАСТОЯЩИЙ: отказ рождается там же, где в проде, и проходит весь +рабочий тракт до `mark_failed`. Счётчик POST'ов в сайдкар считается на +`httpx.AsyncClient.post` — «к площадке не ходили» проверяется, а не предполагается. + +Сеть/БД замоканы; в сеть тест не ходит. +""" + +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +sys.modules.setdefault("weasyprint", MagicMock()) + +import httpx # noqa: E402 +import pytest # noqa: E402 +from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402 + +from app.services import scheduler as sched # noqa: E402 +from app.tasks import avito_detail_backfill as adb # noqa: E402 +from app.tasks import cian_history_backfill as chb # noqa: E402 +from app.tasks import domclick_detail_backfill as dcb # noqa: E402 + + +class _EmptyPoolProvider: + """Провайдер прокси, у которого не осталось живых узлов.""" + + def __init__(self, *_a: Any, **_kw: Any) -> None: + pass + + def acquire(self, source: str) -> None: + raise NoProxyAvailableError(source) + + def release(self, *_a: Any, **_kw: Any) -> None: + pass + + def mark_health(self, *_a: Any, **_kw: Any) -> None: + pass + + +def _mock_db(n_rows: int, url: str) -> MagicMock: + rows = [{"id": i + 1, "source_url": f"{url}{i + 1}"} for i in range(n_rows)] + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = rows + db.execute.return_value = sel + return db + + +def _prod_pool(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод + включённый пул — единственная конфигурация, где отказ #2616 живой.""" + monkeypatch.setattr(chb.settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(chb.settings, "environment", "production") + monkeypatch.setattr(chb.settings, "browser_http_endpoint", "http://browser:9000") + + +def _mock_session_svc() -> MagicMock: + svc = MagicMock() + svc.load_session.return_value = None + svc.COOKIE_EXPIRY_WARN_DAYS = 5 + svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30) + return svc + + +@pytest.mark.asyncio +async def test_cian_empty_pool_at_start_marks_failed_with_no_proxy_stop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Циан: пул пуст на старте → failed + no_proxy_stop=1, обработано 0 объявлений.""" + _prod_pool(monkeypatch) + db = _mock_db(5, "https://ekb.cian.ru/sale/flat/") + runs = MagicMock() + post = AsyncMock() + + with ( + patch.object(chb, "RealProxyProvider", _EmptyPoolProvider), + patch.object(sched, "runs_mod", runs), + patch.object(httpx.AsyncClient, "post", post), + pytest.raises(NoProxyAvailableError), + ): + await sched._execute_cian_backfill( + db, run_id=3384, params={"batch_size": 5, "do_houses": False} + ) + + runs.mark_done.assert_not_called() + runs.mark_banned.assert_not_called() + runs.mark_failed.assert_called_once() + counters = runs.mark_failed.call_args.args[3] + assert counters["no_proxy_stop"] == 1, f"counters={counters}: диагноз потерян" + assert counters["listings_processed"] == 0, "к площадке не ходили — 0 объявлений" + post.assert_not_called() + + +@pytest.mark.asyncio +async def test_domclick_empty_pool_at_start_marks_failed_with_no_proxy_stop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Домклик: тот же контракт — failed + no_proxy_stop=1 + attempted=0.""" + _prod_pool(monkeypatch) + db = _mock_db(5, "https://ekaterinburg.domclick.ru/card/sale__flat__") + runs = MagicMock() + post = AsyncMock() + + with ( + patch.object(dcb, "RealProxyProvider", _EmptyPoolProvider), + patch.object(dcb, "domclick_session_svc", _mock_session_svc()), + patch.object(dcb, "runs_mod", runs), + patch.object(httpx.AsyncClient, "post", post), + pytest.raises(NoProxyAvailableError), + ): + await dcb.run_domclick_detail_backfill( + db, run_id=3384, params={"batch_size": 5, "budget_sec": 3600} + ) + + runs.mark_backfill_finished.assert_not_called() + runs.mark_failed.assert_called_once() + counters = runs.mark_failed.call_args.args[3] + assert counters["no_proxy_stop"] == 1, f"counters={counters}: диагноз потерян" + assert counters["attempted"] == 0, "к площадке не ходили — 0 попыток" + post.assert_not_called() + + +@pytest.mark.asyncio +async def test_avito_empty_pool_at_start_marks_failed_with_no_proxy_stop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Avito: BrowserFetcher.__aenter__ вызывается напрямую — дыра та же, контракт тот же.""" + _prod_pool(monkeypatch) + monkeypatch.setattr(adb.settings, "scraper_fetch_mode", "browser") + monkeypatch.setattr(adb.settings, "avito_detail_backfill_use_curl", False) + db = _mock_db(5, "https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_") + runs = MagicMock() + post = AsyncMock() + + with ( + patch.object(adb, "RealProxyProvider", _EmptyPoolProvider), + patch.object(adb, "AvitoScraper", MagicMock()), + patch.object(adb, "runs_mod", runs), + patch.object(httpx.AsyncClient, "post", post), + pytest.raises(NoProxyAvailableError), + ): + await adb.run_avito_detail_backfill( + db, run_id=3384, params={"batch_size": 5, "budget_sec": 3600} + ) + + runs.mark_done.assert_not_called() + runs.mark_failed.assert_called_once() + counters = runs.mark_failed.call_args.args[3] + assert counters["no_proxy_stop"] == 1, f"counters={counters}: диагноз потерян" + assert counters["attempted"] == 0, "к площадке не ходили — 0 попыток" + post.assert_not_called() -- 2.45.3 From 47dabec67826ed9705a6f1e1a90e54d55783f6ad Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 06:27:45 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(#3384):=20cian=20=E2=80=94=20=D0=B2=20m?= =?UTF-8?q?ark=5Ffailed=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=B5,=20=D0=BD=D1=83=D0=BB=D0=B8=20he?= =?UTF-8?q?artbeat=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D1=82=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F;=20=D1=81=D1=82=D0=B0=D0=B1=20=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью нашло у цианa (в отличие от avito/домклика с живым counters.to_dict()) старый словарь в общем except: реальные значения присваиваются уже ПОСЛЕ возврата из backfill_cian_history, а отказ бывает и посреди неё — пул опустел между стадиями, упал SELECT домов. Тогда поверх измеренного в запись прогона уезжали нули, и SQL-разбор простоя (#3288/#3367) читал «к площадке не ходили» про прогон, который ходил. Механизм оказался хуже описанного в ревью: mark_failed мержит counters (`counters || :counters`) только в kit-копии, а cian/avito/домклик зовут app.services.scrape_runs, где UPDATE counters ЗАМЕНЯЕТ (scrape_runs.py:738). Поэтому «отдать только {no_proxy_stop: 1}» стёрло бы измеренное начисто; вместо этого _heartbeat кладёт свой снимок в те же counters (nonlocal), и в mark_failed уезжает последнее измеренное + флаг. Тест по значению: heartbeat записал listings_processed=5, дальше пул пуст → в jsonb- payload mark_failed должно остаться 5, а не 0 (проверяется сам payload UPDATE'а, runs_mod настоящий). На HEAD ветки красный: `counters={'listings_processed': 0, ..., 'no_proxy_stop': 1}: нули поверх измеренных 5`. Стаб пула приведён к проду: RealProxyProvider.acquire при пустом пуле ВОЗВРАЩАЕТ None (scraper_adapters.py:230), а не поднимает, — исключение из провайдера глотал `except Exception` в _acquire_lease и приходило к тому же отказу другим путём. Теперь NoProxyAvailableError рождается там же, где в проде (browser_fetcher.py:712, ветка `lease is None and use_pool and production`) — проверено прогоном против до-#3384 исходников: все три теста красные, трейс из _acquire_lease. _prod_pool патчит app.core.config.settings явно + assert, что все три задачи держат тот же синглтон: раньше патч через chb.settings выглядел настройкой одного циана. --- tradein-mvp/backend/app/services/scheduler.py | 20 +++- .../test_3384_no_proxy_at_batch_start.py | 102 ++++++++++++++++-- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 4403836b..5c483c1a 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -127,8 +127,18 @@ async def _execute_cian_backfill( """Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен ронять уже идущую работу — прогон в худшем случае вернётся к прежнему поведению (пометка 'zombie' на 6-м часу).""" + nonlocal counters + # #3384: снимок измеренного едет не только в БД, но и в `counters` — этот словарь + # уезжает в mark_failed из общего except ниже, а тот counters ЗАМЕНЯЕТ + # (scrape_runs.py:738 `counters = CAST(:counters AS jsonb)`; мерж `||` — только у + # kit-копии, которую этот путь не зовёт). Пока снимок сюда не доезжал, любой отказ + # ПОСЛЕ пройденной стадии (пул опустел между стадиями, упал SELECT домов) писал + # поверх измеренного предынициализированные нули, и SQL-разбор простоя + # (#3288/#3367) читал «к площадке не ходили» про прогон, который ходил. + # Присваивание ДО записи в БД: сбой heartbeat'а не должен стирать сам факт замера. + counters = _counters(progress) try: - runs_mod.update_heartbeat(db, run_id, _counters(progress)) + runs_mod.update_heartbeat(db, run_id, counters) except Exception: logger.warning( "scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)", @@ -136,6 +146,8 @@ async def _execute_cian_backfill( exc_info=True, ) + # Стартовые нули: прогон виден в админке до первого прогресса. Держатся здесь ровно + # до первого `_heartbeat` — дальше в `counters` лежит измеренное (см. выше). counters: dict[str, int] = { "listings_processed": 0, "listings_succeeded": 0, @@ -215,8 +227,10 @@ async def _execute_cian_backfill( # самого `async with` (cian_history_backfill.py:218) мимо стоп-механики # внутри цикла, которая и ставит no_proxy_stop. Без этого ключа прогон, # который к площадке не ходил ВООБЩЕ, неотличим от любого другого падения: - # counters нулевые, причина только в тексте, а разбор простоя идёт SQL'ём - # по counters.no_proxy_stop (#3288/#3367). Ключ тот же, что у ветки выше. + # причина только в тексте, а разбор простоя идёт SQL'ём по + # counters.no_proxy_stop (#3288/#3367). Ключ тот же, что у ветки выше. + # Флаг ДОБАВЛЯЕТСЯ к последнему снимку `_heartbeat`, а не подменяет его: + # опустевший между стадиями пул — это отказ ПОСЛЕ реальной работы. counters["no_proxy_stop"] = 1 runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) raise diff --git a/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py b/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py index 6dbaae33..c0e36231 100644 --- a/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py +++ b/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py @@ -8,17 +8,23 @@ break`), которая и пишет `counters.no_proxy_stop`. Общий `exce не ходил ВООБЩЕ, в SQL-разборе простоя по `counters.no_proxy_stop` (#3288/#3367) не находится и выглядит как произвольное падение задачи. -Подделка ровно одна — провайдер прокси, чей `acquire()` поднимает -`NoProxyAvailableError` (так ведёт себя пул, когда живых узлов не осталось). -`BrowserFetcher` НАСТОЯЩИЙ: отказ рождается там же, где в проде, и проходит весь -рабочий тракт до `mark_failed`. Счётчик POST'ов в сайдкар считается на +Подделка ровно одна — провайдер прокси, чей `acquire()` возвращает `None` (ровно так +ведёт себя прод-`RealProxyProvider.acquire`, scraper_adapters.py:230, когда живых узлов +не осталось: он НЕ поднимает исключение). Отказ поэтому рождается там же, где в проде, — +в `BrowserFetcher._acquire_lease` (`lease is None and use_pool and production`), — и +проходит весь рабочий тракт до `mark_failed`. Счётчик POST'ов в сайдкар считается на `httpx.AsyncClient.post` — «к площадке не ходили» проверяется, а не предполагается. +Третий тест — про соседний случай: пул опустел МЕЖДУ стадиями, то есть уже ПОСЛЕ +реальной работы. Там проверяется, что в запись прогона не уезжают нули поверх +измеренного (`mark_failed` у `app.services.scrape_runs` counters ЗАМЕНЯЕТ, а не мержит). + Сеть/БД замоканы; в сеть тест не ходит. """ from __future__ import annotations +import json import os import sys from datetime import UTC, datetime, timedelta @@ -32,6 +38,7 @@ import httpx # noqa: E402 import pytest # noqa: E402 from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402 +from app.core.config import settings # noqa: E402 from app.services import scheduler as sched # noqa: E402 from app.tasks import avito_detail_backfill as adb # noqa: E402 from app.tasks import cian_history_backfill as chb # noqa: E402 @@ -39,13 +46,21 @@ from app.tasks import domclick_detail_backfill as dcb # noqa: E402 class _EmptyPoolProvider: - """Провайдер прокси, у которого не осталось живых узлов.""" + """Провайдер прокси, у которого не осталось живых узлов — как прод. + + `acquire` возвращает None, а не поднимает: `RealProxyProvider.acquire` + (scraper_adapters.py:222-234) отдаёт None, когда `proxy_pool.acquire` не нашёл + свободного узла. Разница не косметическая — исключение из провайдера + `_acquire_lease` глотает своим `except Exception` (browser_fetcher.py:696) и + приходит к тому же отказу другим путём, мимо ветки `lease is None`, которая и + работает на проде. + """ def __init__(self, *_a: Any, **_kw: Any) -> None: pass def acquire(self, source: str) -> None: - raise NoProxyAvailableError(source) + return None def release(self, *_a: Any, **_kw: Any) -> None: pass @@ -64,10 +79,20 @@ def _mock_db(n_rows: int, url: str) -> MagicMock: def _prod_pool(monkeypatch: pytest.MonkeyPatch) -> None: - """Прод + включённый пул — единственная конфигурация, где отказ #2616 живой.""" - monkeypatch.setattr(chb.settings, "use_proxy_pool_browser", True) - monkeypatch.setattr(chb.settings, "environment", "production") - monkeypatch.setattr(chb.settings, "browser_http_endpoint", "http://browser:9000") + """Прод + включённый пул — единственная конфигурация, где отказ #2616 живой. + + Патчится ОДИН объект — `app.core.config.settings`: все три задачи и + `RealScraperConfig` (scraper_adapters.py:26) держат один и тот же синглтон, поэтому + `monkeypatch.setattr(chb.settings, ...)` выглядел как настройка одного циана, а на + деле настраивал и avito с домкликом. Assert ниже — чтобы зависимость не осталась + молчаливой: разъедется импорт — тест скажет об этом, а не начнёт тихо мерить не то. + """ + assert chb.settings is settings, "cian берёт другой settings — патч мимо" + assert adb.settings is settings, "avito берёт другой settings — патч мимо" + assert dcb.settings is settings, "domclick берёт другой settings — патч мимо" + monkeypatch.setattr(settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(settings, "environment", "production") + monkeypatch.setattr(settings, "browser_http_endpoint", "http://browser:9000") def _mock_session_svc() -> MagicMock: @@ -165,3 +190,60 @@ async def test_avito_empty_pool_at_start_marks_failed_with_no_proxy_stop( assert counters["no_proxy_stop"] == 1, f"counters={counters}: диагноз потерян" assert counters["attempted"] == 0, "к площадке не ходили — 0 попыток" post.assert_not_called() + + +@pytest.mark.asyncio +async def test_cian_empty_pool_between_stages_keeps_measured_counters() -> None: + """Циан: пул опустел ПОСЛЕ пройденной стадии — нули не должны лечь поверх замера. + + У циана (в отличие от avito/домклика с их живым `counters.to_dict()`) в общий + `except` приходит СТАРЫЙ словарь: реальные значения присваиваются уже после возврата + из `backfill_cian_history`, а отказ бывает и посреди неё — пул опустел между + стадиями, упал SELECT домов. `runs_mod` здесь настоящий + (`app.services.scrape_runs`), и его `mark_failed` counters ЗАМЕНЯЕТ + (`counters = CAST(:counters AS jsonb)`, scrape_runs.py:738) — то есть в записи + прогона остаётся ровно то, что уехало последним аргументом. + + Проверка по значению: смотрим jsonb-payload обоих UPDATE'ов (heartbeat и + mark_failed), а не факт вызова. + """ + measured = 5 + + async def _fake_backfill(_db: Any, **kw: Any) -> None: + """Стадия объявлений прошла (heartbeat записал замер) — потом пул опустел.""" + progress = chb.CianBackfillResult() + progress.listings_processed = measured + progress.listings_succeeded = measured + on_progress = kw["on_progress"] + assert on_progress is not None, "прогон без сигнала живости — мерить нечего" + on_progress(progress) + raise NoProxyAvailableError("cian") + + updates: list[dict[str, Any]] = [] + db = MagicMock() + + def _capture(_stmt: Any, params: Any = None, *_a: Any, **_kw: Any) -> MagicMock: + if isinstance(params, dict) and "counters" in params: + updates.append({**params, "counters": json.loads(params["counters"])}) + return MagicMock() + + db.execute.side_effect = _capture + + with ( + patch.object(chb, "backfill_cian_history", _fake_backfill), + pytest.raises(NoProxyAvailableError), + ): + await sched._execute_cian_backfill(db, run_id=3384, params={"batch_size": 5}) + + beats = [u["counters"] for u in updates if "error" not in u] + finals = [u["counters"] for u in updates if "error" in u] + assert beats and beats[-1]["listings_processed"] == measured, ( + f"heartbeat не записал замер — проверять нечего: {beats}" + ) + assert len(finals) == 1, f"ожидался ровно один mark_failed: {updates}" + final = finals[0] + assert final.get("no_proxy_stop") == 1, f"counters={final}: диагноз потерян" + assert final["listings_processed"] == measured, ( + f"counters={final}: нули поверх измеренных {measured} — SQL-разбор простоя " + "(#3288/#3367) прочитает «к площадке не ходили» про прогон, который ходил" + ) -- 2.45.3