gendesign/tradein-mvp/backend/app/tasks/osm_poi_ekb_refresh.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

109 lines
4 KiB
Python

"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-coef).
Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the
`gendesign_osm_poi_ekb` FDW foreign table (migration 168 — live view of gendesign's
`osm_poi_ekb` table, Site Finder's OSM POI registry, GRANTed to `tradein_fdw_reader` in PR-A
of #2045, already merged + deployed on gendesign).
WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW — see
`app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW
round-trip (~1.16s/row without a geom index on the remote table) — UNUSABLE for a synchronous
endpoint (`GET /api/v1/trade-in/location-coef`). We materialize the FDW once (single bulk
scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then
`app/services/location_coef.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate.
Scheduler source='osm_poi_ekb_refresh' (daily — OSM POI data changes rarely). Pure internal
DB op — one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot.
psycopg v3: app SQL uses CAST(:x AS type), never :x::type.
"""
from __future__ import annotations
import logging
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__)
def refresh_osm_poi_ekb_local(db: Session) -> int:
"""TRUNCATE osm_poi_ekb_local; bulk INSERT from gendesign_osm_poi_ekb FDW.
Single FDW scan (no per-row round-trips). Builds a 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 osm_poi_ekb_local"))
db.execute(
text(
"""
INSERT INTO osm_poi_ekb_local (category, name, lat, lon, geom)
SELECT
category, name, lat, lon,
ST_SetSRID(ST_MakePoint(lon, lat), 4326)
FROM gendesign_osm_poi_ekb
WHERE lat IS NOT NULL
AND lon IS NOT NULL
"""
)
)
count = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
db.commit()
logger.info("refresh_osm_poi_ekb_local: loaded %d rows from FDW", count)
return int(count)
@dataclass
class OsmPoiRefreshResult:
poi_loaded: int = 0 # rows in osm_poi_ekb_local after refresh
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"poi_loaded": self.poi_loaded,
"duration_sec": int(self.duration_sec),
}
def run_osm_poi_ekb_refresh(db: Session, *, run_id: int, params: dict) -> OsmPoiRefreshResult:
"""Run-lifecycle wrapper for the OSM POI local-mirror refresh (scheduler entrypoint).
Launched by the kit scheduler (source='osm_poi_ekb_refresh') via
product_handlers._job_osm_poi_ekb_refresh, or manually. No params consumed — single
unconditional bulk refresh (`params` accepted for scheduler-signature compatibility).
Finalises scrape_runs (mark_done / mark_failed) with counters.
"""
counters: dict[str, int] = {"poi_loaded": 0}
start = time.monotonic()
try:
runs_mod.update_heartbeat(db, run_id, counters)
loaded = refresh_osm_poi_ekb_local(db)
result = OsmPoiRefreshResult(poi_loaded=loaded, duration_sec=time.monotonic() - start)
counters = result.to_counters()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_osm_poi_ekb_refresh: run_id=%d DONE poi_loaded=%d duration=%.1fs",
run_id,
result.poi_loaded,
result.duration_sec,
)
return result
except Exception as exc:
logger.exception("run_osm_poi_ekb_refresh: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise