fix(tradein/scrapers): подключить geoportal coords backfill в scheduler (#1967) #2346
6 changed files with 349 additions and 1 deletions
|
|
@ -251,6 +251,19 @@ async def _job_yandex_detail_backfill(
|
||||||
await run_yandex_detail_backfill(db, run_id=run_id, params=params)
|
await run_yandex_detail_backfill(db, run_id=run_id, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
# ── geoportal_coords_backfill — sync local exact match в executor (#1967) ─────
|
||||||
|
async def _job_geoportal_coords_backfill(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
from app.tasks.backfill_listings_coords_geoportal import run_geoportal_coords_backfill
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: run_geoportal_coords_backfill(db, run_id=run_id, params=params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── cadastral_geo_match — sync FDW-refresh+match в executor ───────────────────
|
# ── cadastral_geo_match — sync FDW-refresh+match в executor ───────────────────
|
||||||
async def _job_cadastral_geo_match(
|
async def _job_cadastral_geo_match(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -376,6 +389,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"yandex_newbuilding_sweep": Handler(
|
"yandex_newbuilding_sweep": Handler(
|
||||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||||
),
|
),
|
||||||
|
"geoportal_coords_backfill": Handler(
|
||||||
|
_job_geoportal_coords_backfill, "geoportal_coords_backfill"
|
||||||
|
),
|
||||||
"geocode_missing_listings": Handler(
|
"geocode_missing_listings": Handler(
|
||||||
_job_geocode_missing_listings, "geocode_missing_listings"
|
_job_geocode_missing_listings, "geocode_missing_listings"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,23 @@ Sources:
|
||||||
- domclick_city_sweep → run_domclick_city_sweep (scrape_pipeline.py; citywide SERP via
|
- domclick_city_sweep → run_domclick_city_sweep (scrape_pipeline.py; citywide SERP via
|
||||||
camoufox BrowserFetcher; shipped DORMANT enabled=false, enable
|
camoufox BrowserFetcher; shipped DORMANT enabled=false, enable
|
||||||
manually after prod-smoke)
|
manually after prod-smoke)
|
||||||
|
- geoportal_coords_backfill → run_geoportal_coords_backfill
|
||||||
|
(tasks/backfill_listings_coords_geoportal.py, #1967; nightly
|
||||||
|
exact street+house match против ekb_geoportal_buildings (местный
|
||||||
|
реестр зданий ЕКБ) для listings.lat IS NULL. Pure internal DB op —
|
||||||
|
никакого внешнего HTTP/rate-limit (в отличие от Nominatim ниже).
|
||||||
|
Ранее был manual-only script (#1841); recurring нужен потому что
|
||||||
|
иначе новые NULL-coord листинги ждут geocode_missing_listings и
|
||||||
|
получают более грубый (coarse) city-centroid geocode вместо
|
||||||
|
точного house-level матча. Window 05:00-06:00 UTC — ДО
|
||||||
|
geocode_missing_listings (06:00-09:00 UTC), чтобы точный матч
|
||||||
|
имел приоритет над Nominatim.)
|
||||||
- geocode_missing_listings → run_geocode_missing_listings
|
- geocode_missing_listings → run_geocode_missing_listings
|
||||||
(tasks/geocode_missing.py; nightly backfill listings.lat/lon для
|
(tasks/geocode_missing.py; nightly backfill listings.lat/lon для
|
||||||
всех источников с NULL coords; address-dedup batch loop с
|
всех источников с NULL coords; address-dedup batch loop с
|
||||||
wall-clock budget; window 06:00-09:00 UTC — после city sweeps
|
wall-clock budget; window 06:00-09:00 UTC — после city sweeps
|
||||||
~03-04 UTC и deals OS-cron 05:30/05:45 UTC)
|
~03-04 UTC, deals OS-cron 05:30/05:45 UTC и geoportal_coords_backfill
|
||||||
|
05:00-06:00 UTC)
|
||||||
- yandex_detail_backfill → run_yandex_detail_backfill
|
- yandex_detail_backfill → run_yandex_detail_backfill
|
||||||
(tasks/yandex_detail_backfill.py, #1553; nightly backfill of
|
(tasks/yandex_detail_backfill.py, #1553; nightly backfill of
|
||||||
yandex detail_enriched_at for ~3952 listings never enriched by
|
yandex detail_enriched_at for ~3952 listings never enriched by
|
||||||
|
|
@ -870,6 +882,51 @@ async def trigger_yandex_address_backfill_run(
|
||||||
return run_id
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_geoportal_coords_backfill_run(
|
||||||
|
db: Session, schedule_row: dict[str, Any]
|
||||||
|
) -> int | None:
|
||||||
|
"""Создать scrape_runs + launch run_geoportal_coords_backfill в executor (sync DB-only task).
|
||||||
|
|
||||||
|
Exact street+house match против ekb_geoportal_buildings (#1967 — was manual-only #1841).
|
||||||
|
Pure local DB op — никакого внешнего HTTP/rate-limit, поэтому запускается ПЕРЕД
|
||||||
|
geocode_missing_listings (Nominatim): точный house-level матч должен получить шанс
|
||||||
|
первым, иначе более грубый (coarse) geocode Nominatim'а забирает адрес раньше и
|
||||||
|
listing выпадает из WHERE lat IS NULL до следующего цикла.
|
||||||
|
|
||||||
|
Sync task (SELECT + per-row local lookup + UPDATE, no async HTTP) — run in run_in_executor
|
||||||
|
by the same pattern as trigger_cadastral_geo_match_run. run_geoportal_coords_backfill owns
|
||||||
|
the scrape_runs lifecycle (mark_done/mark_failed).
|
||||||
|
|
||||||
|
Returns run_id или None (skip — already running).
|
||||||
|
"""
|
||||||
|
run_id = _claim_run(db, schedule_row)
|
||||||
|
if run_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
params = schedule_row.get("default_params") or {}
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
run_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from app.tasks.backfill_listings_coords_geoportal import (
|
||||||
|
run_geoportal_coords_backfill,
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: run_geoportal_coords_backfill(run_db, run_id=run_id, params=params),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("scheduler: run_geoportal_coords_backfill crashed run_id=%d", run_id)
|
||||||
|
finally:
|
||||||
|
run_db.close()
|
||||||
|
|
||||||
|
_spawn_tracked(_run())
|
||||||
|
logger.info("scheduler: triggered geoportal_coords_backfill run_id=%d", run_id)
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
async def trigger_geocode_missing_listings_run(
|
async def trigger_geocode_missing_listings_run(
|
||||||
db: Session, schedule_row: dict[str, Any]
|
db: Session, schedule_row: dict[str, Any]
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
|
|
@ -1995,6 +2052,8 @@ async def scheduler_loop() -> None:
|
||||||
await trigger_newbuilding_enrich_run(db, sch)
|
await trigger_newbuilding_enrich_run(db, sch)
|
||||||
elif source == "yandex_newbuilding_sweep":
|
elif source == "yandex_newbuilding_sweep":
|
||||||
await trigger_yandex_newbuilding_sweep_run(db, sch)
|
await trigger_yandex_newbuilding_sweep_run(db, sch)
|
||||||
|
elif source == "geoportal_coords_backfill":
|
||||||
|
await trigger_geoportal_coords_backfill_run(db, sch)
|
||||||
elif source == "geocode_missing_listings":
|
elif source == "geocode_missing_listings":
|
||||||
await trigger_geocode_missing_listings_run(db, sch)
|
await trigger_geocode_missing_listings_run(db, sch)
|
||||||
elif source == "avito_detail_backfill":
|
elif source == "avito_detail_backfill":
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@
|
||||||
python -m app.tasks.backfill_listings_coords_geoportal
|
python -m app.tasks.backfill_listings_coords_geoportal
|
||||||
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
|
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
|
||||||
|
|
||||||
|
Также запускается nightly через in-app scheduler (source='geoportal_coords_backfill',
|
||||||
|
migration 171) — run_geoportal_coords_backfill(). Local exact match, никакого внешнего
|
||||||
|
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
|
||||||
|
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
|
||||||
|
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
|
||||||
|
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring).
|
||||||
|
|
||||||
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
|
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
|
||||||
Повторный прогон не затирает уже проставленные координаты.
|
Повторный прогон не затирает уже проставленные координаты.
|
||||||
"""
|
"""
|
||||||
|
|
@ -29,6 +36,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.geocoder import _geoportal_house_match, _parse_street_house
|
from app.services.geocoder import _geoportal_house_match, _parse_street_house
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -50,6 +58,17 @@ class BackfillCoordsResult:
|
||||||
errors: int = 0 # исключения при обработке отдельной записи
|
errors: int = 0 # исключения при обработке отдельной записи
|
||||||
duration_sec: float = field(default=0.0)
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
|
def to_counters(self) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"candidates": self.candidates,
|
||||||
|
"matched": self.matched,
|
||||||
|
"updated": self.updated,
|
||||||
|
"no_address": self.no_address,
|
||||||
|
"no_match": self.no_match,
|
||||||
|
"errors": self.errors,
|
||||||
|
"duration_sec": int(self.duration_sec),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _update_listing_coords(
|
def _update_listing_coords(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|
@ -253,6 +272,69 @@ def backfill_coords_from_geoportal(
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
|
||||||
|
def run_geoportal_coords_backfill(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
run_id: int,
|
||||||
|
params: dict,
|
||||||
|
) -> BackfillCoordsResult:
|
||||||
|
"""Run-lifecycle wrapper for backfill_coords_from_geoportal (sync, DB-only task).
|
||||||
|
|
||||||
|
Launched by the in-app scheduler (source='geoportal_coords_backfill') via
|
||||||
|
trigger_geoportal_coords_backfill_run, или вручную. Pure local exact street+house match
|
||||||
|
против ekb_geoportal_buildings — никакого внешнего HTTP, поэтому весь backlog (или
|
||||||
|
ограниченный `limit`) отрабатывается за один прогон без wall-clock budget (в отличие
|
||||||
|
от geocode_missing_listings, который rate-limited Nominatim'ом).
|
||||||
|
|
||||||
|
Params (default_params jsonb):
|
||||||
|
limit: максимум листингов за прогон (default None = весь backlog).
|
||||||
|
batch_size: размер батча SELECT+commit (default 500).
|
||||||
|
sources: фильтр по source (default None = все источники).
|
||||||
|
|
||||||
|
Finalises scrape_runs (mark_done / mark_failed) с counters.
|
||||||
|
"""
|
||||||
|
limit = params.get("limit")
|
||||||
|
batch_size = int(params.get("batch_size", DEFAULT_BATCH_SIZE))
|
||||||
|
sources = params.get("sources")
|
||||||
|
|
||||||
|
counters: dict[str, int] = BackfillCoordsResult().to_counters()
|
||||||
|
|
||||||
|
try:
|
||||||
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
|
|
||||||
|
res = backfill_coords_from_geoportal(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
sources=sources,
|
||||||
|
batch_size=batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
counters = res.to_counters()
|
||||||
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
|
logger.info(
|
||||||
|
"run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
|
||||||
|
"updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs",
|
||||||
|
run_id,
|
||||||
|
res.candidates,
|
||||||
|
res.matched,
|
||||||
|
res.updated,
|
||||||
|
res.no_address,
|
||||||
|
res.no_match,
|
||||||
|
res.errors,
|
||||||
|
res.duration_sec,
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("run_geoportal_coords_backfill: run_id=%d FAILED", run_id)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
-- 171_scrape_schedules_seed_geoportal_coords_backfill.sql
|
||||||
|
-- Seed row делающий recurring уже существовавший manual-only geoportal coords backfill.
|
||||||
|
--
|
||||||
|
-- Context (#1967): avito ~47% активных листингов имели NULL lat/lon → невидимы для
|
||||||
|
-- ST_DWithin радиус-поиска аналогов в estimator'е. Root cause — avito SERP-карточки
|
||||||
|
-- намеренно сохраняются с lat=lon=NULL (координаты приходят точнее из detail-страницы
|
||||||
|
-- или из geocode-cron по address). geocode_missing_listings (migration 110) с 2026-06-16
|
||||||
|
-- уже закрывает большую часть разрыва через Nominatim/Yandex (rate-limited, coarse
|
||||||
|
-- city-centroid когда geocoder не доходит до дома).
|
||||||
|
--
|
||||||
|
-- app/tasks/backfill_listings_coords_geoportal.py существует с #1841 (точный street+house
|
||||||
|
-- матч против местного реестра зданий ЕКБ, ekb_geoportal_buildings) и даёт house-level
|
||||||
|
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
|
||||||
|
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
|
||||||
|
-- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241
|
||||||
|
-- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр).
|
||||||
|
--
|
||||||
|
-- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну
|
||||||
|
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.
|
||||||
|
--
|
||||||
|
-- Window 05:00-06:00 UTC — ДО geocode_missing_listings (06:00-09:00 UTC, migration 110):
|
||||||
|
-- точный house-level матч должен получить шанс ПЕРВЫМ. Если Nominatim успевает
|
||||||
|
-- проставить (более грубые) coords раньше — адрес выпадает из WHERE lat IS NULL и
|
||||||
|
-- более точный geoportal-матч уже не применяется до следующего появления NULL-строки.
|
||||||
|
--
|
||||||
|
-- default_params:
|
||||||
|
-- limit — None (без ограничений, весь backlog за один прогон; pure local DB —
|
||||||
|
-- быстро, никакого wall-clock budget как у geocode_missing_listings не нужно).
|
||||||
|
-- batch_size — 500 строк на SELECT + commit-интервал (см. DEFAULT_BATCH_SIZE в модуле).
|
||||||
|
-- sources — None (все источники; ekb_geoportal_buildings сам ограничивает scope до ЕКБ).
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
|
||||||
|
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'geoportal_coords_backfill',
|
||||||
|
true, -- ACTIVE: pure internal DB op, no external
|
||||||
|
-- HTTP/anti-bot/rate-limit exposure.
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||||
|
'{"batch_size": 500}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMENT ON TABLE scrape_schedules IS
|
||||||
|
'In-app scheduler config (заменяет cron-script setup). '
|
||||||
|
'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), '
|
||||||
|
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), '
|
||||||
|
'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), '
|
||||||
|
'yandex_address_backfill (#855, EKB pilot), '
|
||||||
|
'sber_index_pull (#887, monthly СберИндекс city-level price index), '
|
||||||
|
'rosreestr_quarter_poll (#888, monthly Rosreestr new-quarter availability check), '
|
||||||
|
'cian_city_sweep (dormant, #973), '
|
||||||
|
'yandex_newbuilding_sweep (dormant, #974), '
|
||||||
|
'geocode_missing_listings (#1: listings geom backfill, all sources), '
|
||||||
|
'geoportal_coords_backfill (#1967: exact EKB house-level coords match, recurring '
|
||||||
|
'wiring of the former manual-only #1841 script — runs BEFORE geocode_missing_listings).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -9,14 +9,19 @@
|
||||||
- limit/sources параметры прокидываются в SQL.
|
- limit/sources параметры прокидываются в SQL.
|
||||||
- Идемпотентность: UPDATE с WHERE lat IS NULL не затирает уже заполненное.
|
- Идемпотентность: UPDATE с WHERE lat IS NULL не затирает уже заполненное.
|
||||||
- SAVEPOINT откат батча при исключении в DB.execute.
|
- SAVEPOINT откат батча при исключении в DB.execute.
|
||||||
|
- run_geoportal_coords_backfill (#1967): run-lifecycle wrapper mark_done/mark_failed.
|
||||||
|
- Scheduler wiring (#1967): trigger fn + dispatch branch + migration 171 seed content.
|
||||||
|
|
||||||
Все сетевые вызовы и DB замоканы.
|
Все сетевые вызовы и DB замоканы.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -26,12 +31,17 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
_wp_mock = MagicMock()
|
_wp_mock = MagicMock()
|
||||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
|
from app.services import scheduler # noqa: E402
|
||||||
from app.services.geocoder import GeocodeSuggestion # noqa: E402
|
from app.services.geocoder import GeocodeSuggestion # noqa: E402
|
||||||
|
from app.tasks import backfill_listings_coords_geoportal as bcg # noqa: E402
|
||||||
from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402
|
from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402
|
||||||
BackfillCoordsResult,
|
BackfillCoordsResult,
|
||||||
backfill_coords_from_geoportal,
|
backfill_coords_from_geoportal,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_SQL_DIR = Path(__file__).resolve().parents[2] / "data" / "sql"
|
||||||
|
_MIGRATION_171 = _SQL_DIR / "171_scrape_schedules_seed_geoportal_coords_backfill.sql"
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_HIT = GeocodeSuggestion(
|
_HIT = GeocodeSuggestion(
|
||||||
|
|
@ -362,3 +372,112 @@ def test_batch_pagination_two_pages() -> None:
|
||||||
|
|
||||||
assert res.candidates == 3
|
assert res.candidates == 3
|
||||||
assert res.no_match == 3
|
assert res.no_match == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── run_geoportal_coords_backfill (run-lifecycle wrapper, #1967) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_wrapper_marks_done_with_counters(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""run_geoportal_coords_backfill: delegates to backfill_coords_from_geoportal, mark_done."""
|
||||||
|
marked: dict[str, Any] = {}
|
||||||
|
monkeypatch.setattr(bcg.runs_mod, "update_heartbeat", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bcg.runs_mod,
|
||||||
|
"mark_done",
|
||||||
|
lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(bcg.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||||
|
|
||||||
|
fake_result = BackfillCoordsResult(
|
||||||
|
candidates=10, matched=3, updated=3, no_address=2, no_match=5, errors=0
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bcg,
|
||||||
|
"backfill_coords_from_geoportal",
|
||||||
|
lambda _db, **k: fake_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
out = bcg.run_geoportal_coords_backfill(
|
||||||
|
MagicMock(),
|
||||||
|
run_id=42,
|
||||||
|
params={"batch_size": 500},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out is fake_result
|
||||||
|
assert marked["run_id"] == 42
|
||||||
|
assert marked["counters"]["candidates"] == 10
|
||||||
|
assert marked["counters"]["matched"] == 3
|
||||||
|
assert marked["counters"]["updated"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""run_geoportal_coords_backfill: exception → mark_failed + re-raise (no silent failure)."""
|
||||||
|
failed: dict[str, Any] = {}
|
||||||
|
monkeypatch.setattr(bcg.runs_mod, "update_heartbeat", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(bcg.runs_mod, "mark_done", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bcg.runs_mod,
|
||||||
|
"mark_failed",
|
||||||
|
lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _boom(_db: Any, **_k: Any) -> BackfillCoordsResult:
|
||||||
|
raise RuntimeError("db down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(bcg, "backfill_coords_from_geoportal", _boom)
|
||||||
|
|
||||||
|
class _DB:
|
||||||
|
def rollback(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
bcg.run_geoportal_coords_backfill(_DB(), run_id=9, params={}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert failed["run_id"] == 9
|
||||||
|
assert "db down" in failed["err"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scheduler wiring (#1967) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_has_trigger_and_dispatch() -> None:
|
||||||
|
assert hasattr(scheduler, "trigger_geoportal_coords_backfill_run")
|
||||||
|
loop_src = inspect.getsource(scheduler.scheduler_loop)
|
||||||
|
assert 'source == "geoportal_coords_backfill"' in loop_src
|
||||||
|
assert "trigger_geoportal_coords_backfill_run(db, sch)" in loop_src
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_claims_run_and_runs_in_executor() -> None:
|
||||||
|
src = inspect.getsource(scheduler.trigger_geoportal_coords_backfill_run)
|
||||||
|
assert "_claim_run(db, schedule_row)" in src
|
||||||
|
# sync DB-only task → run_in_executor (not a bare async call).
|
||||||
|
assert "run_in_executor" in src
|
||||||
|
assert "run_geoportal_coords_backfill" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_runs_before_geocode_missing_listings_in_source_docstring() -> None:
|
||||||
|
"""Header docs must place geoportal_coords_backfill BEFORE geocode_missing_listings.
|
||||||
|
|
||||||
|
Ordering is the whole point (#1967): exact house-level match should claim NULL-coord
|
||||||
|
listings before the coarser Nominatim/Yandex backfill does.
|
||||||
|
"""
|
||||||
|
header = scheduler.__doc__ or ""
|
||||||
|
geoportal_idx = header.find("geoportal_coords_backfill")
|
||||||
|
nominatim_idx = header.find("geocode_missing_listings")
|
||||||
|
assert geoportal_idx != -1
|
||||||
|
assert nominatim_idx != -1
|
||||||
|
assert geoportal_idx < nominatim_idx
|
||||||
|
|
||||||
|
|
||||||
|
# ── Migration content (#1967) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_171_seeds_schedule_before_geocode_missing_listings_window() -> None:
|
||||||
|
sql = _MIGRATION_171.read_text(encoding="utf-8")
|
||||||
|
assert "'geoportal_coords_backfill'" in sql
|
||||||
|
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||||
|
assert "BEGIN;" in sql and "COMMIT;" in sql
|
||||||
|
# Window 5-6 UTC — before geocode_missing_listings' 06:00-09:00 window (migration 110).
|
||||||
|
assert "\n 5,\n 6,\n" in sql
|
||||||
|
# enabled=true — safe by default (pure internal DB op, no external HTTP).
|
||||||
|
assert "'geoportal_coords_backfill',\n true," in sql
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ SOURCE_TO_OLD_TRIGGER: dict[str, str] = {
|
||||||
"rosreestr_quarter_poll": "trigger_rosreestr_quarter_poll_run",
|
"rosreestr_quarter_poll": "trigger_rosreestr_quarter_poll_run",
|
||||||
"newbuilding_enrich": "trigger_newbuilding_enrich_run",
|
"newbuilding_enrich": "trigger_newbuilding_enrich_run",
|
||||||
"yandex_newbuilding_sweep": "trigger_yandex_newbuilding_sweep_run",
|
"yandex_newbuilding_sweep": "trigger_yandex_newbuilding_sweep_run",
|
||||||
|
"geoportal_coords_backfill": "trigger_geoportal_coords_backfill_run",
|
||||||
"geocode_missing_listings": "trigger_geocode_missing_listings_run",
|
"geocode_missing_listings": "trigger_geocode_missing_listings_run",
|
||||||
"avito_detail_backfill": "trigger_avito_detail_backfill_run",
|
"avito_detail_backfill": "trigger_avito_detail_backfill_run",
|
||||||
"yandex_detail_backfill": "trigger_yandex_detail_backfill_run",
|
"yandex_detail_backfill": "trigger_yandex_detail_backfill_run",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue