Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend (uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь (_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers) самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/ app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill из scheduler.py) — мимо удаляемой legacy-машинерии. app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop, get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/ _drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS (достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py), import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в product_handlers.py — job-тела kit-handler'ов). main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`); прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код. scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false → legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь. Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore" защищает от startup-краха на leftover env var), но на ветвление не влияет. app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/ после этого PR (только тесты, которые Part E удалит вместе с самим файлом) — подтверждено grep. scrape_pipeline.py не тронут (Part E). Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер, backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py / test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py). Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_ wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми. test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты). test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/ test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/ registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не app.services.scheduler, а сам scraper_kit.orchestration.scheduler. test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop, переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR). test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из scrape_pipeline) намеренно НЕ тронуты — Part E. Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail (test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
141 lines
6.5 KiB
Python
141 lines
6.5 KiB
Python
"""Daily per-source snapshot writer (#570).
|
||
|
||
Берёт текущее состояние listing_sources (последний снимок на canonical listing × source)
|
||
и пишет ежедневный ряд в listing_source_snapshots + change-log price_change в
|
||
listing_source_events. Так история per-source цены копится СРАЗУ, независимо от
|
||
(сейчас DORMANT) скраперов — см. шапку data/sql/079_listing_source_history.sql.
|
||
|
||
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) — запускается kit-scheduler'ом
|
||
через product_handlers._job_listing_source_snapshot,
|
||
по образцу import_rosreestr_dkp (sync task в run_in_executor).
|
||
|
||
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff CTE),
|
||
никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT … SELECT каждый.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services import scrape_runs as runs_mod
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
|
||
FRESHNESS_WINDOW_DAYS = 7
|
||
|
||
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
|
||
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
||
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
|
||
# is_active derived: last_seen_at в пределах окна свежести на момент снимка.
|
||
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
|
||
# run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint).
|
||
_SNAPSHOT_SQL = text(
|
||
"""
|
||
INSERT INTO listing_source_snapshots (
|
||
listing_source_id, snapshot_date, price_rub, is_active,
|
||
last_seen_at, payload_hash, observed_at, run_id
|
||
)
|
||
SELECT
|
||
id,
|
||
CURRENT_DATE,
|
||
price_rub,
|
||
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
|
||
last_seen_at,
|
||
md5(raw_payload::text),
|
||
now(),
|
||
CAST(:run_id AS bigint)
|
||
FROM listing_sources
|
||
ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET
|
||
price_rub = EXCLUDED.price_rub,
|
||
is_active = EXCLUDED.is_active,
|
||
last_seen_at = EXCLUDED.last_seen_at,
|
||
payload_hash = EXCLUDED.payload_hash,
|
||
observed_at = EXCLUDED.observed_at,
|
||
run_id = EXCLUDED.run_id
|
||
"""
|
||
)
|
||
|
||
# ── Event diff: price_change ──────────────────────────────────────────────────
|
||
# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с
|
||
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась
|
||
# (обе NOT NULL, old <> 0) — пишем price_change.
|
||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL).
|
||
# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC).
|
||
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла.
|
||
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type)
|
||
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
|
||
_EVENT_DIFF_SQL = text(
|
||
"""
|
||
WITH today AS (
|
||
SELECT listing_source_id, price_rub
|
||
FROM listing_source_snapshots
|
||
WHERE snapshot_date = CURRENT_DATE
|
||
),
|
||
prior AS (
|
||
SELECT DISTINCT ON (listing_source_id)
|
||
listing_source_id, price_rub
|
||
FROM listing_source_snapshots
|
||
WHERE snapshot_date < CURRENT_DATE
|
||
ORDER BY listing_source_id, snapshot_date DESC
|
||
)
|
||
INSERT INTO listing_source_events (
|
||
listing_source_id, change_time, event_type, price_rub, diff_percent
|
||
)
|
||
SELECT
|
||
t.listing_source_id,
|
||
now(),
|
||
'price_change',
|
||
t.price_rub,
|
||
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4)
|
||
FROM today t
|
||
JOIN prior p ON p.listing_source_id = t.listing_source_id
|
||
WHERE t.price_rub IS NOT NULL
|
||
AND p.price_rub IS NOT NULL
|
||
AND p.price_rub <> 0
|
||
AND t.price_rub <> p.price_rub
|
||
ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING
|
||
"""
|
||
)
|
||
|
||
|
||
def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]:
|
||
"""Записать дневной снимок listing_sources + price_change-события.
|
||
|
||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
||
Два set-based statement'а в одной транзакции:
|
||
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
|
||
2. diff сегодняшней цены против последнего предыдущего снимка → price_change-события.
|
||
|
||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||
|
||
Returns {"snapshotted": N, "price_change_events": M}.
|
||
"""
|
||
counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0}
|
||
try:
|
||
snap_result = db.execute(
|
||
_SNAPSHOT_SQL,
|
||
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
|
||
)
|
||
counters["snapshotted"] = snap_result.rowcount or 0
|
||
|
||
event_result = db.execute(_EVENT_DIFF_SQL)
|
||
counters["price_change_events"] = event_result.rowcount or 0
|
||
|
||
db.commit()
|
||
runs_mod.mark_done(db, run_id, counters)
|
||
logger.info(
|
||
"snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d",
|
||
run_id,
|
||
counters["snapshotted"],
|
||
counters["price_change_events"],
|
||
)
|
||
return counters
|
||
except Exception as exc:
|
||
logger.exception("snapshot_listing_sources run_id=%d failed", run_id)
|
||
db.rollback()
|
||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||
raise
|