chore(tradein/scheduler): добор карточек Яндекса и Циана шёл раз в сутки и простаивал
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 4m58s

compute_next_run_at держит суточную гранулярность (interval_days, минимум 1),
подчасовой такт делается хуком reschedule_after_minutes как post_claim. У
avito_detail_backfill он есть (180 мин), у yandex_detail_backfill и
cian_detail_backfill не было — отсюда один прогон в сутки.

Цена простоя по замеру прода 31.08:

  Яндекс: 375-450 карточек за прогон, блоков НОЛЬ за неделю, очередь 11110
          → 25 суток при нынешнем такте
  Циан:   блоков ноль, очередь 20501

Такты разные, и это не произвол:

  yandex — 180 мин (8 прогонов/сутки). Ходит через resolve_proxy_url: берёт
           URL узла, но НЕ лизует его, поэтому чужие прогоны не блокирует.
  cian   — 360 мин (4 прогона/сутки). Ходит через BrowserFetcher и ДЕРЖИТ
           lease весь прогон, то есть отнимает узел у Авито и Домклика. Пул
           дефицитен (#2638), поэтому осторожнее.

Асимметрия зафиксирована комментарием у обоих хендлеров и в докстринге
миграции — иначе следующий читатель выровняет интервалы и сожжёт пул. Тест
test_cian_default_interval_is_360_minutes_not_180 ассертит именно неравенство,
чтобы выравнивание без замера покраснело.

283_scrape_schedules_cadence_yandex_cian_detail.sql — идемпотентный
UPDATE ... SET default_params = default_params || jsonb, остальные ключи
параметров не трогает.

Значения 180/360 — консервативная отправная точка по аналогии с Авито, а не
найденный оптимум: двигать вниз только по замеру нескольких суток, глядя и на
свипы тоже (тот же довод, что в комментарии у avito_detail_backfill).

Тесты (5): наличие post_claim у обоих, дефолтные интервалы, переопределение
через params. Прогон: 397 passed, 1 skipped, ruff чист.
This commit is contained in:
bot-backend 2026-08-31 14:17:12 +03:00
parent c4fcedda9a
commit 4ddb3c7196
3 changed files with 178 additions and 1 deletions

View file

@ -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"
),

View file

@ -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;

View file

@ -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