"""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-вызовов) — запускается in-app scheduler'ом через trigger_listing_source_snapshot_run() (scheduler.py), по образцу 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