From fa1399d59e6da52036740fc4d781625c3d7de1e2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 22 Aug 2026 14:57:20 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito):=20=D0=B1=D1=8D=D0=BA=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D0=BB=20=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=B0=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=2023=20=D1=87=D0=B0=D1=81=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=B7=2024=20=E2=80=94=20=D0=BA=D0=B0=D0=B4=D0=B5=D0=BD=D1=81?= =?UTF-8?q?=20=D0=B1=D1=8B=D0=BB=20=D1=81=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер 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 будет двигаться, а вот утрата хука вернёт суточный простой молча. Проверено фальсификацией — на неизменённом коде все три падают. --- .../backend/app/services/product_handlers.py | 25 +++++- .../tests/test_avito_backfill_cadence.py | 79 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_avito_backfill_cadence.py diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 1f2adf0a..f22dd512 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -656,7 +656,30 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "geocode_missing_listings": Handler( _job_geocode_missing_listings, "geocode_missing_listings" ), - "avito_detail_backfill": Handler(_job_avito_detail_backfill, "avito_detail_backfill"), + # Каденс — в минутах, а не в сутках (замер 2026-08-22). Дефолтная + # гранулярность compute_next_run_at — сутки, и бэкфилл получал ровно один + # прогон в день. При этом прогон умирает по бану через 17-83 минуты, то + # есть 23 часа из 24 задание простаивало: 238 обогащённых карточек за + # сутки при 9 951 активном объявлении — очередь разбиралась бы месяцами. + # + # Хук сам себя throttl'ит: пока прогон идёт, has_running_run в _claim_run + # возвращает None и next_run_at не сбрасывается. + # + # 180 минут — ОСОЗНАННО консервативная отправная точка, а не найденный + # оптимум. Данных для подбора нет: два прогона дали противоречивую + # картину (4562 — 83 мин и 175 карточек; 4586 через 4.3 часа, когда пул + # прокси был давно чист, — 17 мин и 42 карточки). Значит память Авито + # длиннее часов, и учащение может ухудшить выход, а не улучшить. + # + # Риск, который надо держать в голове при подборе: те же 4 прокси + # обслуживают SERP-свипы — первичный сбор. Сжечь их на обогащении хуже, + # чем медленно обогащать. Двигать интервал вниз только по замеру + # нескольких суток, глядя и на свипы тоже. + "avito_detail_backfill": Handler( + _job_avito_detail_backfill, + "avito_detail_backfill", + post_claim=reschedule_after_minutes(param="interval_minutes", default=180), + ), "yandex_detail_backfill": Handler(_job_yandex_detail_backfill, "yandex_detail_backfill"), "domclick_detail_backfill": Handler( _job_domclick_detail_backfill, "domclick_detail_backfill" diff --git a/tradein-mvp/backend/tests/test_avito_backfill_cadence.py b/tradein-mvp/backend/tests/test_avito_backfill_cadence.py new file mode 100644 index 00000000..81082f67 --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_backfill_cadence.py @@ -0,0 +1,79 @@ +"""Каденс 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 -- 2.45.3