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-вызовов) — запускается 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
|