All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 4m47s
Замер 2026-08-22, уже после починки транспорта (#3049): обогащение работает, но очередь не разбирается. За сутки 238 карточек при 9 951 активном объявлении. Причина в планировщике, а не в скрапинге. `compute_next_run_at` имеет суточную гранулярность по построению — `interval_days = max(1, int(...))`, целевая дата `now + interval_days`. Меньше суток не выражается. Бэкфилл при этом умирает по бану через 17-83 минуты, то есть работал около часа в сутки, а остальное время расписание ждало следующего дня. Механизм sub-hourly каденса уже был написан — `reschedule_after_minutes` (#2162, сделан для proxy_healthcheck). Его просто не подключили к бэкфиллу. Хук ставит next_run_at = now() + interval_minutes сразу после claim и сам себя тормозит: пока прогон идёт, has_running_run в _claim_run возвращает None. 180 минут — осознанно консервативная отправная точка, НЕ найденный оптимум. Данных для подбора нет, и имеющиеся два прогона противоречат наивному ожиданию: 4562 дал 175 карточек за 83 минуты, а 4586 через 4.3 часа — когда пул прокси был давно чист — умер за 17 минут с 42 карточками. Значит память Авито длиннее часов, и учащение может ухудшить выход. Отдельно держать в голове при подборе: те же 4 прокси обслуживают SERP-свипы, то есть первичный сбор. Сжечь их на обогащении хуже, чем медленно обогащать. Двигать интервал вниз только по замеру нескольких суток, глядя и на свипы. Подбор — через default_params расписания, правка кода для этого не нужна. Тесты закрепляют подключённость хука и коридор дефолта, а не конкретное значение: 180 будет двигаться, а вот утрата хука вернёт суточный простой молча. Проверено фальсификацией — на неизменённом коде все три падают.
79 lines
3.7 KiB
Python
79 lines
3.7 KiB
Python
"""Каденс avito_detail_backfill: минуты, а не сутки.
|
||
|
||
Замер 2026-08-22 на проде: `compute_next_run_at` имеет суточную гранулярность
|
||
(`interval_days = max(1, int(...))`, целевая дата `now + interval_days`), поэтому
|
||
бэкфилл получал ровно один прогон в сутки. Прогон при этом умирает по бану через
|
||
17-83 минуты — 23 часа из 24 задание простаивало, 238 обогащённых карточек за
|
||
сутки при 9 951 активном объявлении.
|
||
|
||
Механизм sub-hourly каденса уже существовал (`reschedule_after_minutes`, #2162,
|
||
сделан для `proxy_healthcheck`) — не был подключён.
|
||
|
||
Тест закрепляет именно подключённость хука, а не значение интервала: 180 минут
|
||
подобраны консервативно и будут двигаться по замерам, а вот утрата хука вернёт
|
||
суточный простой молча.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
from app.services.product_handlers import build_product_handlers
|
||
|
||
|
||
def test_backfill_has_sub_daily_cadence_hook() -> None:
|
||
"""Без post_claim бэкфилл откатывается к суточной гранулярности."""
|
||
handlers = build_product_handlers(MagicMock())
|
||
handler = handlers["avito_detail_backfill"]
|
||
|
||
assert handler.post_claim is not None, (
|
||
"avito_detail_backfill остался без post_claim — каденс вернулся к "
|
||
"одному прогону в сутки (compute_next_run_at, interval_days >= 1)"
|
||
)
|
||
|
||
|
||
def test_backfill_default_interval_is_sane() -> None:
|
||
"""Дефолт не должен быть ни суточным, ни агрессивнее получаса.
|
||
|
||
Нижняя граница — не оптимум, а страховка: те же 4 прокси обслуживают
|
||
SERP-свипы, и слишком частое обогащение бьёт по первичному сбору.
|
||
"""
|
||
handlers = build_product_handlers(MagicMock())
|
||
hook = handlers["avito_detail_backfill"].post_claim
|
||
assert hook is not None
|
||
|
||
captured: dict[str, int] = {}
|
||
|
||
class _DB:
|
||
def execute(self, _stmt: object, params: dict[str, object]) -> None:
|
||
captured["mins"] = int(params["mins"]) # type: ignore[arg-type]
|
||
|
||
def commit(self) -> None:
|
||
return None
|
||
|
||
# params без ключа → сработает дефолт фабрики, его и проверяем.
|
||
hook(_DB(), 1, {}, MagicMock())
|
||
|
||
assert 30 <= captured["mins"] <= 360, (
|
||
f"интервал по умолчанию {captured['mins']} мин вне разумного коридора"
|
||
)
|
||
assert captured["mins"] < 1440, "интервал в сутки и больше — это исходный дефект"
|
||
|
||
|
||
def test_interval_minutes_from_params_wins() -> None:
|
||
"""Подбор интервала — через default_params расписания, без правки кода."""
|
||
handlers = build_product_handlers(MagicMock())
|
||
hook = handlers["avito_detail_backfill"].post_claim
|
||
assert hook is not None
|
||
|
||
captured: dict[str, int] = {}
|
||
|
||
class _DB:
|
||
def execute(self, _stmt: object, params: dict[str, object]) -> None:
|
||
captured["mins"] = int(params["mins"]) # type: ignore[arg-type]
|
||
|
||
def commit(self) -> None:
|
||
return None
|
||
|
||
hook(_DB(), 1, {"interval_minutes": 90}, MagicMock())
|
||
assert captured["mins"] == 90
|