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