gendesign/tradein-mvp/backend/scripts/backfill_external_valuations_house_id.py
bot-backend c2096249d5
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
fix(tradein/data): dry-run бэкфилла — savepoint-паритет с write-веткой (#2236)
2026-07-03 09:28:27 +03:00

366 lines
13 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.

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