diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index fb879a3f..55697a27 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -710,10 +710,24 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: # строки расписания — у них свои окна, свой next_run_at и свой счётчик # прогонов; гонять оба режима под одним source нельзя, планировщик держит # на source ровно один активный прогон. + # Каденс — в минутах (замер прода 31.08.2026): та же суточная дыра + # compute_next_run_at, что у avito/yandex_detail_backfill — Циан держит + # темп ~23 с/карточку при очереди 20 501 и НОЛЕ блоков за неделю. + # + # 360 минут (4 прогона/сутки), НЕ 180 как у Яндекса — АСИММЕТРИЯ ниже + # ОБЯЗАТЕЛЬНА к прочтению перед тем, как выравнивать эти два интервала: + # Циан ходит через BrowserFetcher и ДЕРЖИТ lease узла на ВЕСЬ прогон, а + # не только берёт URL прокси (как Яндекс через resolve_proxy_url/ + # proxy_egress). Пул сейчас 3 живых узла — дефицитный ресурс, который + # Циан на время прогона отбирает у avito_detail_backfill и + # domclick_detail_backfill. Учащение Циана до 180 мин без замера того, + # как это скажется на соседях по пулу, — прямой риск сжечь мощность, + # которой и так не хватает на троих. "cian_detail_backfill": Handler( _job_cian_history_backfill, "cian_detail_backfill", pre_claim=_cian_pre_claim, + post_claim=reschedule_after_minutes(param="interval_minutes", default=360), ), "rosreestr_dkp_import": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import"), "listing_source_snapshot": Handler(_job_listing_source_snapshot, "listing_source_snapshot"), @@ -767,7 +781,21 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "avito_detail_backfill", post_claim=reschedule_after_minutes(param="interval_minutes", default=180), ), - "yandex_detail_backfill": Handler(_job_yandex_detail_backfill, "yandex_detail_backfill"), + # Каденс — в минутах (замер прода 31.08.2026), тот же дефект, что у + # avito_detail_backfill выше: без post_claim compute_next_run_at держит + # суточную гранулярность, а прогон Яндекса разбирает 375-450 карточек в + # час (budget_sec=3600) при очереди 11 110 и НУЛЕ блоков за неделю — узкое + # место чисто в частоте запуска, не в самом фетчере. + # + # 180 минут (8 прогонов/сутки) безопасны именно из-за асимметрии с Циан: + # Яндекс ходит через resolve_proxy_url (proxy_egress) — берёт URL прокси, + # но НЕ держит lease узла на весь прогон, поэтому учащение не отнимает + # узел у других source. + "yandex_detail_backfill": Handler( + _job_yandex_detail_backfill, + "yandex_detail_backfill", + post_claim=reschedule_after_minutes(param="interval_minutes", default=180), + ), "domclick_detail_backfill": Handler( _job_domclick_detail_backfill, "domclick_detail_backfill" ), diff --git a/tradein-mvp/backend/data/sql/283_scrape_schedules_cadence_yandex_cian_detail.sql b/tradein-mvp/backend/data/sql/283_scrape_schedules_cadence_yandex_cian_detail.sql new file mode 100644 index 00000000..927bc55b --- /dev/null +++ b/tradein-mvp/backend/data/sql/283_scrape_schedules_cadence_yandex_cian_detail.sql @@ -0,0 +1,45 @@ +-- 283_scrape_schedules_cadence_yandex_cian_detail.sql +-- Раскрыть такт добора карточек Яндекса и Циана (замер прода 31.08.2026). +-- +-- ЧТО БЫЛО НЕ ТАК +-- compute_next_run_at держит суточную гранулярность (interval_days, минимум 1). +-- yandex_detail_backfill и cian_detail_backfill были зарегистрированы БЕЗ +-- post_claim-хука reschedule_after_minutes — тот же дефект, что чинили для +-- avito_detail_backfill в #2162/миграции каденса выше по номерам. Итог: +-- * Яндекс: прогон разбирает 375-450 карточек за час (budget_sec=3600), +-- блоков НОЛЬ за неделю, очередь 11 110 — простаивало 23 часа из 24; +-- * Циан: ~23 с/карточку, блоков НОЛЬ за неделю, очередь 20 501 — то же самое. +-- Механизм подключён кодом (product_handlers.py) в этой же паре PR/коммитов; +-- эта миграция прописывает "interval_minutes" в default_params, чтобы новый +-- каденс был явным в расписании, а не только в дефолте хука. +-- +-- ПОЧЕМУ РАЗНЫЕ ИНТЕРВАЛЫ (180 vs 360) — АСИММЕТРИЯ LEASE +-- Яндекс ходит через resolve_proxy_url (proxy_egress) — берёт URL прокси, но +-- НЕ держит lease узла на весь прогон, поэтому учащение не блокирует чужие +-- прогоны. Отсюда 180 минут (8 прогонов/сутки). +-- +-- Циан ходит через BrowserFetcher и ДЕРЖИТ lease узла на ВЕСЬ прогон, то есть +-- на время прогона отнимает узел у avito_detail_backfill и domclick_detail_backfill. +-- Пул сейчас 3 живых узла — дефицитный ресурс. Поэтому Циану 360 минут (4 +-- прогона/сутки), а НЕ 180: учащать дальше без замера влияния на соседей по +-- пулу нельзя — риск сжечь мощность на троих. +-- +-- ИДЕМПОТЕНТНОСТЬ +-- `default_params || jsonb` — второй операнд перекрывает ключ "interval_minutes" +-- при повторном прогоне, никаких дублей и никакого ON CONFLICT не требуется: +-- это UPDATE существующих строк, а не INSERT. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица), 282_*.sql (сама строка +-- cian_detail_backfill уже существует к моменту этой миграции). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || '{"interval_minutes": 180}'::jsonb +WHERE source = 'yandex_detail_backfill'; + +UPDATE scrape_schedules +SET default_params = default_params || '{"interval_minutes": 360}'::jsonb +WHERE source = 'cian_detail_backfill'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_yandex_cian_backfill_cadence.py b/tradein-mvp/backend/tests/test_yandex_cian_backfill_cadence.py new file mode 100644 index 00000000..0e6f40bc --- /dev/null +++ b/tradein-mvp/backend/tests/test_yandex_cian_backfill_cadence.py @@ -0,0 +1,104 @@ +"""Каденс yandex_detail_backfill и cian_detail_backfill: минуты, а не сутки. + +Замер прода 31.08.2026: та же дыра, что чинили для avito_detail_backfill +(`test_avito_backfill_cadence.py`) — `compute_next_run_at` держит суточную +гранулярность (`interval_days = max(1, ...)`), а оба хендлера были +зарегистрированы без `post_claim`. Прогон Яндекса разбирает 375-450 карточек +за час при очереди 11 110, Циан — ~23 с/карточку при очереди 20 501, и оба +простаивали 23 часа из 24 при нуле блоков за неделю. + +Тесты закрепляют именно подключённость хука и разные дефолты (180 у Яндекса, +360 у Циана) — если кто-то выровняет их значения без замера, тест на дефолт +это не поймает, но факт подключения и разность значений — да. Асимметрия +интервалов имеет причину (Циан держит lease узла весь прогон, Яндекс — нет), +см. комментарий в product_handlers.py у cian_detail_backfill. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from app.services.product_handlers import build_product_handlers + + +class _DB: + """Минимальный double для post_claim-хука: перехватывает mins из execute().""" + + def __init__(self) -> None: + self.captured: dict[str, int] = {} + + def execute(self, _stmt: object, params: dict[str, object]) -> None: + self.captured["mins"] = int(params["mins"]) # type: ignore[arg-type] + + def commit(self) -> None: + return None + + +def test_yandex_detail_backfill_has_sub_daily_cadence_hook() -> None: + """Без post_claim yandex_detail_backfill откатывается к суточной гранулярности.""" + handlers = build_product_handlers(MagicMock()) + handler = handlers["yandex_detail_backfill"] + + assert handler.post_claim is not None, ( + "yandex_detail_backfill остался без post_claim — каденс вернулся к " + "одному прогону в сутки (compute_next_run_at, interval_days >= 1)" + ) + + +def test_cian_detail_backfill_has_sub_daily_cadence_hook() -> None: + """Без post_claim cian_detail_backfill откатывается к суточной гранулярности.""" + handlers = build_product_handlers(MagicMock()) + handler = handlers["cian_detail_backfill"] + + assert handler.post_claim is not None, ( + "cian_detail_backfill остался без post_claim — каденс вернулся к " + "одному прогону в сутки (compute_next_run_at, interval_days >= 1)" + ) + + +def test_yandex_default_interval_is_180_minutes() -> None: + """Яндекс не держит lease узла (resolve_proxy_url) — можно 8 прогонов/сутки.""" + handlers = build_product_handlers(MagicMock()) + hook = handlers["yandex_detail_backfill"].post_claim + assert hook is not None + + db = _DB() + hook(db, 1, {}, MagicMock()) + + assert db.captured["mins"] == 180 + + +def test_cian_default_interval_is_360_minutes_not_180() -> None: + """Циан держит lease узла (BrowserFetcher) весь прогон — вдвое реже Яндекса. + + Явно проверяем НЕравенство 180: выравнивание интервалов с Яндексом отняло + бы дефицитный узел (3 живых на пул) у avito/domclick_detail_backfill без + замера последствий. + """ + handlers = build_product_handlers(MagicMock()) + hook = handlers["cian_detail_backfill"].post_claim + assert hook is not None + + db = _DB() + hook(db, 1, {}, MagicMock()) + + assert db.captured["mins"] == 360 + assert db.captured["mins"] != 180 + + +def test_interval_minutes_from_params_wins_for_both() -> None: + """Подбор интервала — через default_params расписания, без правки кода.""" + handlers = build_product_handlers(MagicMock()) + + yandex_hook = handlers["yandex_detail_backfill"].post_claim + cian_hook = handlers["cian_detail_backfill"].post_claim + assert yandex_hook is not None + assert cian_hook is not None + + db_yandex = _DB() + yandex_hook(db_yandex, 1, {"interval_minutes": 90}, MagicMock()) + assert db_yandex.captured["mins"] == 90 + + db_cian = _DB() + cian_hook(db_cian, 1, {"interval_minutes": 240}, MagicMock()) + assert db_cian.captured["mins"] == 240