gendesign/tradein-mvp/backend/app/tasks/cadastral_geo_match.py
bot-backend 85059aeb1b refactor(tradein/scheduler): удалить legacy scheduler_loop + scraper-scheduling, kit единственный путь (#2397 Part C)
Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend
(uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m
app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь
(_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers)
самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop
или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler
диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/
app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill
из scheduler.py) — мимо удаляемой legacy-машинерии.

app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop,
get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/
_drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт
app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS
(достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые
импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py),
import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в
product_handlers.py — job-тела kit-handler'ов).

main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок
запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`);
прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код.

scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false →
legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь.
Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore"
защищает от startup-краха на leftover env var), но на ветвление не влияет.

app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/
после этого PR (только тесты, которые Part E удалит вместе с самим файлом) —
подтверждено grep. scrape_pipeline.py не тронут (Part E).

Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер,
backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py /
test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант
против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py).
Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_
wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами
task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми.
test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты).
test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против
удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/
test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/
registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не
app.services.scheduler, а сам scraper_kit.orchestration.scheduler.
test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop,
переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR).
test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из
scrape_pipeline) намеренно НЕ тронуты — Part E.

Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех
изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
2026-07-04 13:00:30 +03:00

323 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.

"""Geo-nearest cadastral building matcher for listings (#cadastral-geo-match).
Fills `listings.building_cadastral_number` (currently 0% / ~43k rows) from the nearest
cadastral building, via a LOCAL materialized mirror of the gendesign_cad_buildings FDW.
WHY a local mirror (perf fact, measured live):
gendesign_cad_buildings is a postgres_fdw foreign table (~36.7k EKB rows) with NO
PostGIS geom — only scalar lat/lon. A per-listing nearest-building query over the FDW
takes ~1.16s/row → ~13h for 43k listings. UNUSABLE per-row.
⇒ We materialize the FDW once (single bulk scan) into cad_buildings_local with a real
Point geom + GIST, then run a fast LOCAL KNN nearest-neighbour join (geom <-> point).
The whole local backfill runs in well under a minute (see PR EXPLAIN evidence).
APPROXIMATION (deliberate first increment):
This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest
cadastral building within `threshold_m`, NOT necessarily its exact cadastral building.
The threshold is always logged. Exact cadastral resolution + parcel-containment are
deferred (cad_parcels FDW not exposed). Tier-0 house matching in the estimator already
treats building_cadastral_number as a hint, not ground truth, so an approximate fill is
a net win over 0% coverage.
Pipeline (one combined run, scheduler source='cadastral_geo_match'):
1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
2. match_listings_to_buildings(db, threshold_m) — set-based LATERAL KNN UPDATE.
Refresh-then-match in ONE run guarantees the match always reads a fresh mirror.
psycopg v3: app SQL uses CAST(:x AS type), never :x::type.
"""
from __future__ import annotations
import logging
import math
import time
from dataclasses import dataclass, field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
DEFAULT_THRESHOLD_M = 50
DEFAULT_BATCH_SIZE = 5000
# EKB latitude — used to convert the metric threshold into a degree gate radius for the
# GIST-indexable geometry-space pre-filter (the gate is a SUPERSET of the true circle; the
# ST_DistanceSphere recheck decides acceptance, so a slightly-too-wide gate is harmless).
_EKB_LAT_DEG = 56.84
_M_PER_DEG_LAT = 111_320.0
def _deg_gate_for(threshold_m: float) -> float:
"""Degree radius that always encloses `threshold_m` metres at EKB latitude.
Longitude degrees shrink by cos(lat), so we size the gate on the tighter (longitude)
axis and apply a 1.5× safety factor. The gate only pre-filters candidates for the GIST
KNN scan; the exact ST_DistanceSphere(...) <= threshold_m recheck is authoritative, so
over-sizing the gate never produces a false match, only a few extra candidates to rank.
"""
m_per_deg_lon = _M_PER_DEG_LAT * math.cos(math.radians(_EKB_LAT_DEG))
return 1.5 * threshold_m / m_per_deg_lon
# ── Refresh: materialize FDW → cad_buildings_local (single bulk scan) ─────────
def refresh_cad_buildings_local(db: Session) -> int:
"""TRUNCATE cad_buildings_local; bulk INSERT from gendesign_cad_buildings FDW.
Single FDW scan (no per-row round-trips). Builds Point(4326) geom from lon/lat.
Idempotent: TRUNCATE+INSERT in one transaction — the table is fully replaced atomically.
Returns the number of rows loaded.
"""
db.execute(text("TRUNCATE cad_buildings_local"))
db.execute(
text(
"""
INSERT INTO cad_buildings_local (
cad_num, readable_address, year_built, floors, area_m2, purpose,
lat, lon, geom
)
SELECT
cad_num, readable_address, year_built, floors, area_m2, purpose,
lat, lon,
ST_SetSRID(ST_MakePoint(lon, lat), 4326)
FROM gendesign_cad_buildings
WHERE lat IS NOT NULL
AND lon IS NOT NULL
AND cad_num IS NOT NULL
ON CONFLICT (cad_num) DO NOTHING
"""
)
)
count = db.execute(text("SELECT count(*) FROM cad_buildings_local")).scalar() or 0
db.commit()
logger.info("refresh_cad_buildings_local: loaded %d rows from FDW", count)
return int(count)
# ── Match: LATERAL KNN nearest building → listings.building_cadastral_number ──
# Set-based UPDATE. For each candidate listing (geo present, optionally only NULL bcn) the
# LATERAL subquery picks the single nearest cad_buildings_local building and accepts it only
# if its TRUE distance is within :threshold_m metres.
#
# PERF (measured live, the whole point of this design):
# The gate is a GEOMETRY-space ST_DWithin(cb.geom, l_geom, :deg_gate) — planar degrees,
# fully GIST-indexable, NO per-row geography cast. The `<-> ` KNN order then returns the
# single nearest candidate via the GIST index. ONLY that one nearest row is converted to a
# true metric distance via ST_DistanceSphere (spherical metres) and compared to :threshold_m.
# This is ~175× faster than a geography-cast ST_DWithin in the WHERE: a geography filter
# forced a per-candidate cast+recheck that the planner could not push into the index, so the
# full 41k-listing UPDATE ran 58s+ and never finished; the geometry-gate version does a
# 5000-row chunk in ~330ms and the full ~41k backfill in a few seconds.
#
# :deg_gate is a degree radius that ALWAYS encloses :threshold_m at EKB latitude (~56.8°):
# 1° lat ≈ 111_320 m, and we widen by /cos(lat) for longitude + a safety factor, so the
# gate is a superset of the true threshold circle and the ST_DistanceSphere recheck is what
# actually decides acceptance (no false negatives from the gate).
#
# Batching: pick a chunk of candidate listing ids first (LIMIT :batch_size), then run the
# LATERAL UPDATE only over that chunk — bounds lock/WAL footprint on the ~43k table and lets
# the run loop checkpoint a heartbeat between chunks. only_missing=true makes each chunk drain
# monotonically (filled rows drop out of the candidate set), so the loop terminates.
# Single set-based LATERAL KNN UPDATE over ALL candidates in one statement.
# NOT chunked by OFFSET: an OFFSET walk over the `building_cadastral_number IS NULL`
# predicate is unsound because the UPDATE mutates that very predicate mid-walk —
# matched rows drop out of the candidate set, so OFFSET (reset OR advance) skips
# un-processed rows. The full UPDATE is ~5s for ~41k listings (GIST KNN, measured),
# so a single statement is both correct and fast enough; no chunking needed.
_MATCH_SQL = text(
"""
WITH matched AS (
SELECT l.id AS id, m.cad_num AS cad_num
FROM listings l
JOIN LATERAL (
SELECT cb.cad_num,
ST_DistanceSphere(cb.geom, l.geom) AS dist_m
FROM cad_buildings_local cb
WHERE ST_DWithin(cb.geom, l.geom, CAST(:deg_gate AS double precision))
ORDER BY cb.geom <-> l.geom
LIMIT 1
) m ON true
WHERE l.geom IS NOT NULL
AND (
CAST(:only_missing AS boolean) = false
OR l.building_cadastral_number IS NULL
)
AND m.dist_m <= CAST(:threshold_m AS double precision)
)
UPDATE listings l
SET building_cadastral_number = matched.cad_num
FROM matched
WHERE l.id = matched.id
"""
)
_CANDIDATES_SQL = text(
"""
SELECT count(*) FROM listings l
WHERE l.geom IS NOT NULL
AND (CAST(:only_missing AS boolean) = false OR l.building_cadastral_number IS NULL)
"""
)
@dataclass
class CadMatchResult:
buildings_loaded: int = 0 # rows in cad_buildings_local after refresh
candidates_total: int = 0 # listings eligible for matching (geo present + filter)
listings_matched: int = 0 # listings updated with a building_cadastral_number
threshold_m: int = DEFAULT_THRESHOLD_M
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"buildings_loaded": self.buildings_loaded,
"candidates_total": self.candidates_total,
"listings_matched": self.listings_matched,
"threshold_m": self.threshold_m,
"duration_sec": int(self.duration_sec),
}
def match_listings_to_buildings(
db: Session,
*,
threshold_m: int = DEFAULT_THRESHOLD_M,
batch_size: int = DEFAULT_BATCH_SIZE,
only_missing: bool = True,
) -> int:
"""Set-based LATERAL KNN UPDATE: fill listings.building_cadastral_number.
GEO-NEAREST (approximate): each candidate listing is matched to the nearest
cad_buildings_local building within `threshold_m` metres. The threshold is logged.
Single set-based UPDATE over ALL candidates (no chunking). An OFFSET walk would be
unsound: the UPDATE mutates the `building_cadastral_number IS NULL` predicate it pages
over, so matched rows drop out and OFFSET (reset OR advance) skips un-processed rows.
The full UPDATE is ~5s for ~41k listings (GIST KNN), so one statement is correct and
fast. `batch_size` is accepted for schedule-param compatibility but no longer used.
Returns listings matched.
"""
logger.info(
"match_listings_to_buildings: GEO-NEAREST match threshold_m=%d "
"only_missing=%s (approximate: nearest building, not exact cadastral)",
threshold_m,
only_missing,
)
deg_gate = _deg_gate_for(float(threshold_m))
result = db.execute(
_MATCH_SQL,
{
"threshold_m": float(threshold_m),
"deg_gate": deg_gate,
"only_missing": only_missing,
},
)
total_matched = result.rowcount
db.commit()
logger.info(
"match_listings_to_buildings: DONE total_matched=%d threshold_m=%d",
total_matched,
threshold_m,
)
return total_matched
# ── Combined refresh + match (callable directly or via run wrapper) ───────────
def refresh_and_match(
db: Session,
*,
threshold_m: int = DEFAULT_THRESHOLD_M,
batch_size: int = DEFAULT_BATCH_SIZE,
only_missing: bool = True,
) -> CadMatchResult:
"""Refresh the local cad mirror, then geo-match listings. Returns a CadMatchResult."""
start = time.monotonic()
res = CadMatchResult(threshold_m=threshold_m)
res.buildings_loaded = refresh_cad_buildings_local(db)
res.candidates_total = int(
db.execute(_CANDIDATES_SQL, {"only_missing": only_missing}).scalar() or 0
)
res.listings_matched = match_listings_to_buildings(
db, threshold_m=threshold_m, batch_size=batch_size, only_missing=only_missing
)
res.duration_sec = time.monotonic() - start
coverage = 100.0 * res.listings_matched / res.candidates_total if res.candidates_total else 0.0
logger.info(
"refresh_and_match: DONE buildings=%d candidates=%d matched=%d "
"coverage=%.1f%% threshold_m=%d duration=%.1fs",
res.buildings_loaded,
res.candidates_total,
res.listings_matched,
coverage,
threshold_m,
res.duration_sec,
)
return res
# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
def run_cadastral_geo_match(db: Session, *, run_id: int, params: dict) -> CadMatchResult:
"""Run-lifecycle wrapper for the combined refresh+match job (sync, DB-only).
Launched by the kit scheduler (source='cadastral_geo_match') via
product_handlers._job_cadastral_geo_match, or manually. Refreshes cad_buildings_local
from the FDW (one bulk scan) then geo-matches listings.building_cadastral_number to the
nearest cadastral building within threshold_m.
Params (default_params jsonb):
threshold_m: max nearest-building distance to accept (metres, default 50).
batch_size: listings per LATERAL UPDATE chunk (default 5000).
only_missing: only fill WHERE building_cadastral_number IS NULL (default true).
Finalises scrape_runs (mark_done / mark_failed) with counters.
"""
threshold_m = int(params.get("threshold_m", DEFAULT_THRESHOLD_M))
batch_size = int(params.get("batch_size", DEFAULT_BATCH_SIZE))
only_missing = bool(params.get("only_missing", True))
counters: dict[str, int] = {
"buildings_loaded": 0,
"candidates_total": 0,
"listings_matched": 0,
"threshold_m": threshold_m,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
res = refresh_and_match(
db,
threshold_m=threshold_m,
batch_size=batch_size,
only_missing=only_missing,
)
counters = res.to_counters()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_cadastral_geo_match: run_id=%d DONE buildings=%d candidates=%d "
"matched=%d threshold_m=%d duration=%.1fs",
run_id,
res.buildings_loaded,
res.candidates_total,
res.listings_matched,
threshold_m,
res.duration_sec,
)
return res
except Exception as exc:
logger.exception("run_cadastral_geo_match: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise