Топология подтверждена перед удалением (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.
323 lines
13 KiB
Python
323 lines
13 KiB
Python
"""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
|