All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 18s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-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 2m58s
366 lines
13 KiB
Python
366 lines
13 KiB
Python
"""Backfill external_valuations.house_id for existing rows (issue #2236).
|
||
|
||
Внешние оценки (Cian Valuation Calculator, Yandex Оценка квартиры) исторически
|
||
писались в `external_valuations` без привязки к канонической таблице `houses`:
|
||
на проде 1579/1579 строк имели `house_id IS NULL`. Write-path теперь резолвит
|
||
house_id тем же матчером, что и estimate (`match_house_readonly`), но уже
|
||
накопленные строки остаются без ссылки.
|
||
|
||
Эта one-shot джоба идёт по всем `external_valuations WHERE house_id IS NULL`
|
||
и резолвит канонический дом ТЕМ ЖЕ путём, что estimate-запрос — через
|
||
`match_house_readonly(db, address=...)`. В external_valuations нет lat/lon и
|
||
кадастра, поэтому резолв опирается на адрес (Tier 1 fingerprint / — при пустых
|
||
координатах — по нормализованному адресу дома). Best-effort: дом не нашёлся →
|
||
house_id остаётся NULL, строка не трогается.
|
||
|
||
Per-row SAVEPOINT (`db.begin_nested()`) per `.claude/rules/backend.md`: один
|
||
битый матч не должен ронять весь батч.
|
||
|
||
Идемпотентность / резюмируемость:
|
||
- Source-query: `WHERE house_id IS NULL` — уже проставленные строки
|
||
выпадают из выборки. Повторный прогон резолвит только оставшиеся NULL,
|
||
результат тот же.
|
||
- UPDATE guard `AND house_id IS NULL` — гонок с write-path не перетирает
|
||
уже проставленный house_id.
|
||
- Курсор `id > :after_id` двигается и по нерезолвленным строкам, поэтому
|
||
один прогон не зацикливается на строке, которую матчер вернул None.
|
||
|
||
Никаких сетевых вызовов — матчер работает целиком в БД.
|
||
|
||
Usage:
|
||
DATABASE_URL=postgresql+psycopg://... \\
|
||
python -m scripts.backfill_external_valuations_house_id --batch-size 500
|
||
|
||
# Канарейка сначала (без записи)
|
||
python -m scripts.backfill_external_valuations_house_id --limit 100 --dry-run
|
||
|
||
# Ограничить одним источником
|
||
python -m scripts.backfill_external_valuations_house_id --source cian_valuation
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import sys
|
||
from collections import defaultdict
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
# Allow running both as `python -m scripts.backfill_external_valuations_house_id`
|
||
# (preferred) and as a stand-alone file (fallback for adhoc invocation).
|
||
try:
|
||
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
||
from app.services.matching.houses import ( # type: ignore[import-not-found]
|
||
match_house_readonly,
|
||
)
|
||
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
from app.core.db import SessionLocal
|
||
from app.services.matching.houses import match_house_readonly
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||
)
|
||
logger = logging.getLogger("backfill_external_valuations_house_id")
|
||
|
||
# Резолвер: (db, address) -> house_id | None. Инъектируемый для тестов, по
|
||
# умолчанию — тот же match_house_readonly, что зовёт estimate-путь.
|
||
Matcher = Callable[[Session, str], "int | None"]
|
||
|
||
|
||
def _default_matcher(db: Session, address: str) -> int | None:
|
||
"""estimate-совместимый резолв дома по адресу (read-only, без создания)."""
|
||
match = match_house_readonly(db, address=address)
|
||
return match[0] if match is not None else None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Domain types
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class ExtValRow:
|
||
"""Одна строка external_valuations, которую резолвим."""
|
||
|
||
id: int
|
||
source: str
|
||
address: str
|
||
|
||
|
||
@dataclass
|
||
class Stats:
|
||
"""Агрегатные счётчики — печатаются по батчу и в конце прогона."""
|
||
|
||
processed: int = 0
|
||
resolved: int = 0
|
||
unresolved: int = 0
|
||
errors: int = 0
|
||
by_source: dict[str, dict[str, int]] = field(default_factory=dict)
|
||
|
||
def bump(self, source: str, key: str) -> None:
|
||
bucket = self.by_source.setdefault(source, defaultdict(int))
|
||
bucket[key] += 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Source query — stream rows without a house_id
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _build_select_sql(*, source: str | None) -> str:
|
||
"""Streaming SELECT — только строки с house_id IS NULL и непустым адресом."""
|
||
where_source = " AND source = :source " if source is not None else ""
|
||
return (
|
||
"SELECT id, source, address "
|
||
"FROM external_valuations "
|
||
"WHERE house_id IS NULL "
|
||
" AND address IS NOT NULL "
|
||
" AND id > :after_id "
|
||
f" {where_source} "
|
||
"ORDER BY id "
|
||
"LIMIT CAST(:limit AS int)"
|
||
)
|
||
|
||
|
||
def _fetch_batch(
|
||
db: Session, *, after_id: int, batch_size: int, source: str | None
|
||
) -> list[ExtValRow]:
|
||
"""Следующий батч нерезолвленных строк по возрастанию id."""
|
||
sql = _build_select_sql(source=source)
|
||
params: dict[str, Any] = {"after_id": after_id, "limit": batch_size}
|
||
if source is not None:
|
||
params["source"] = source
|
||
|
||
rows = db.execute(text(sql), params).mappings().all()
|
||
return [ExtValRow(id=int(r["id"]), source=r["source"], address=r["address"]) for r in rows]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-row work
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _process_row(
|
||
db: Session,
|
||
row: ExtValRow,
|
||
*,
|
||
dry_run: bool,
|
||
stats: Stats,
|
||
matcher: Matcher,
|
||
) -> None:
|
||
"""Резолвит дом и (если найден) проставляет house_id одной строке.
|
||
|
||
Обёрнуто в per-row SAVEPOINT: битый матч/UPDATE не ломает батч. Best-effort —
|
||
matcher вернул None → строка не трогается, house_id остаётся NULL.
|
||
"""
|
||
stats.processed += 1
|
||
stats.bump(row.source, "processed")
|
||
|
||
try:
|
||
# SAVEPOINT в обеих ветках: даже в dry-run matcher бьёт по БД (SELECT'ы),
|
||
# и его exception на реальной сессии перевёл бы транзакцию в aborted →
|
||
# каскад «current transaction is aborted» на всех последующих строках.
|
||
with db.begin_nested():
|
||
house_id = matcher(db, row.address)
|
||
if house_id is not None and not dry_run:
|
||
db.execute(
|
||
text(
|
||
"UPDATE external_valuations "
|
||
" SET house_id = CAST(:hid AS bigint) "
|
||
" WHERE id = CAST(:eid AS bigint) "
|
||
" AND house_id IS NULL"
|
||
),
|
||
{"hid": house_id, "eid": row.id},
|
||
)
|
||
|
||
if house_id is not None:
|
||
stats.resolved += 1
|
||
stats.bump(row.source, "resolved")
|
||
else:
|
||
stats.unresolved += 1
|
||
stats.bump(row.source, "unresolved")
|
||
|
||
logger.debug(
|
||
"backfill ext_val id=%s source=%s house_id=%s",
|
||
row.id,
|
||
row.source,
|
||
house_id,
|
||
)
|
||
except Exception as e:
|
||
stats.errors += 1
|
||
stats.bump(row.source, "errors")
|
||
logger.warning(
|
||
"backfill failed ext_val id=%s source=%s: %s",
|
||
row.id,
|
||
row.source,
|
||
e,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Driver
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def run_backfill(
|
||
db: Session,
|
||
*,
|
||
batch_size: int,
|
||
limit: int | None,
|
||
source: str | None,
|
||
dry_run: bool,
|
||
matcher: Matcher = _default_matcher,
|
||
) -> Stats:
|
||
"""Главный драйвер — стримит батчи и проставляет house_id.
|
||
|
||
Args:
|
||
db: SQLAlchemy Session.
|
||
batch_size: строк за один SELECT (каждый батч коммитится).
|
||
limit: остановиться после стольких строк (--limit). None = до конца.
|
||
source: фильтр по source ('cian_valuation'/'yandex_valuation'). None = все.
|
||
dry_run: не писать в БД, только считать.
|
||
matcher: (db, address) -> house_id|None; по умолчанию match_house_readonly.
|
||
"""
|
||
stats = Stats()
|
||
after_id = 0
|
||
batch_idx = 0
|
||
|
||
while True:
|
||
if limit is not None and stats.processed >= limit:
|
||
logger.info(
|
||
"limit reached: stopping (processed=%d, limit=%d)",
|
||
stats.processed,
|
||
limit,
|
||
)
|
||
break
|
||
|
||
effective_size = batch_size
|
||
if limit is not None:
|
||
effective_size = min(batch_size, limit - stats.processed)
|
||
if effective_size <= 0:
|
||
break
|
||
|
||
batch = _fetch_batch(db, after_id=after_id, batch_size=effective_size, source=source)
|
||
if not batch:
|
||
logger.info("no more rows — done")
|
||
break
|
||
|
||
batch_idx += 1
|
||
for row in batch:
|
||
_process_row(db, row, dry_run=dry_run, stats=stats, matcher=matcher)
|
||
after_id = max(after_id, row.id)
|
||
|
||
if not dry_run:
|
||
db.commit()
|
||
|
||
logger.info(
|
||
"batch %d done: size=%d total processed=%d resolved=%d unresolved=%d errors=%d",
|
||
batch_idx,
|
||
len(batch),
|
||
stats.processed,
|
||
stats.resolved,
|
||
stats.unresolved,
|
||
stats.errors,
|
||
)
|
||
|
||
return stats
|
||
|
||
|
||
def _log_summary(stats: Stats, *, dry_run: bool) -> None:
|
||
"""Финальная разбивка по источникам + итог."""
|
||
logger.info("=" * 72)
|
||
logger.info(
|
||
"backfill done (dry_run=%s): processed=%d resolved=%d unresolved=%d errors=%d",
|
||
dry_run,
|
||
stats.processed,
|
||
stats.resolved,
|
||
stats.unresolved,
|
||
stats.errors,
|
||
)
|
||
for src, bucket in sorted(stats.by_source.items()):
|
||
logger.info(
|
||
" %-18s processed=%d resolved=%d unresolved=%d errors=%d",
|
||
src,
|
||
bucket.get("processed", 0),
|
||
bucket.get("resolved", 0),
|
||
bucket.get("unresolved", 0),
|
||
bucket.get("errors", 0),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||
"""argparse setup, вынесено ради тестируемости."""
|
||
p = argparse.ArgumentParser(
|
||
description=(
|
||
"Backfill external_valuations.house_id — резолвит канонический дом "
|
||
"по адресу тем же match_house_readonly, что и estimate-путь. "
|
||
"Идемпотентно на re-run."
|
||
),
|
||
)
|
||
p.add_argument(
|
||
"--batch-size",
|
||
type=int,
|
||
default=500,
|
||
help="Строк за SELECT (default: 500). Каждый батч коммитится.",
|
||
)
|
||
p.add_argument(
|
||
"--limit",
|
||
type=int,
|
||
default=None,
|
||
help="Потолок на общее число строк за прогон (для канарейки).",
|
||
)
|
||
p.add_argument(
|
||
"--source",
|
||
choices=("cian_valuation", "yandex_valuation"),
|
||
default=None,
|
||
help="Ограничить одним source. Default: все.",
|
||
)
|
||
p.add_argument(
|
||
"--dry-run",
|
||
action="store_true",
|
||
help="Логировать что было бы сделано; без записи в БД.",
|
||
)
|
||
return p.parse_args(argv)
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""CLI entry point. Возвращает число обработанных строк за прогон."""
|
||
args = _parse_args(argv)
|
||
logger.info(
|
||
"starting backfill: batch_size=%d limit=%s source=%s dry_run=%s",
|
||
args.batch_size,
|
||
args.limit if args.limit is not None else "all",
|
||
args.source or "all",
|
||
args.dry_run,
|
||
)
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
stats = run_backfill(
|
||
db,
|
||
batch_size=args.batch_size,
|
||
limit=args.limit,
|
||
source=args.source,
|
||
dry_run=args.dry_run,
|
||
)
|
||
_log_summary(stats, dry_run=args.dry_run)
|
||
return stats.processed
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
sys.exit(0 if main() >= 0 else 1)
|