gendesign/tradein-mvp/backend/app/tasks/listing_source_snapshot.py
bot-backend 85059aeb1b refactor(tradein/scheduler): удалить legacy scheduler_loop + scraper-scheduling, kit единственный путь (#2397 Part C)
Топология подтверждена перед удалением (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.
2026-07-04 13:00:30 +03:00

141 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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