fix(tradein): пустой пул ДО первого объявления теряет диагноз в записи прогона (#3384)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
This commit is contained in:
parent
33444b7eab
commit
caa3b8b056
4 changed files with 197 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
167
tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py
Normal file
167
tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue