gendesign/backend/app/workers/tasks/nspd_geo.py
Light1YT 5f60668642
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m8s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m19s
fix(nspd-geo): coerce geom to MultiPolygon + wide cad_buildings schema in on-demand ingest
REAL code-level cause (prod-confirmed, 2 parcels: "Geometry type (Polygon) does
not match column type (MultiPolygon)") of single-contour parcels stuck "fetching"
— complements migrations 129/130 (which fixed the cad_parcels NOT NULL chain).

F1: _save_parcel/_save_quarter now ST_Multi() the geom so single-contour Polygons
fit the MultiPolygon columns (migr 93/58), mirroring the bulk_harvest paths.
F2: _save_building rewritten to the wide cad_buildings schema (migr 92):
quarter_cad_number (was quarter_cad_num → UndefinedColumn), _safe_int(floors)
(range strings → NULL), source='nspd' NOT NULL.
F3: upsert_features wraps each feature in begin_nested() SAVEPOINT so one bad
feature no longer aborts the whole quarter snapshot (backend.md rule; mirrors
grid-walk). New tests/workers/test_nspd_geo.py + savepoint tests. 89 passed.
2026-06-04 13:11:47 +05:00

609 lines
23 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.

"""Bulk NSPD geo backfill — resume-friendly Celery task.
Архитектура (см. data/sql/77_nspd_geo_jobs.sql):
nspd_geo_jobs — журнал заданий (один job = одна bulk-операция)
nspd_geo_targets — список cad-номеров со status (pending/done/failed)
Resume:
1. При worker_ready signal: ищем jobs со status='running' и stale heartbeat
(>10 мин), переключаем их статус на 'resume_needed' + re-enqueue task.
2. process_nspd_geo_job(job_id) сразу читает SELECT WHERE status='pending'
— идемпотентно продолжает с того места, где остановился (UPSERT в
cad_quarters_geom / cad_buildings гарантирует no dup'ов).
Запуск (3 пути):
1. UI: POST /api/v1/admin/scrape/geo (создаёт job, наполняет targets, enqueue task)
2. CLI: python -m app.workers.tasks.nspd_geo --enqueue-region 66
3. Beat: можно добавить cron-расписание в celery_app.py
"""
from __future__ import annotations
import json
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.cadastre.bulk_harvest import _derive_quarter_cad, _safe_int, _safe_numeric
from app.services.scrapers.nspd_lite import (
NspdLiteError,
NspdLiteWafError,
fetch_via_rosreestr2coord,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Heartbeat каждые N items (компромисс: чаще = выше DB write, реже = stale-window больше)
HEARTBEAT_EVERY = 5
# WAF backoff (seconds) после первого 403/429
WAF_BACKOFF_BASE_S = 30
# ── Helpers для job/target lifecycle ────────────────────────────────────────
def _log(
db: Session, job_id: int, level: str, stage: str, message: str, cad_num: str | None = None
) -> None:
"""Запись в nspd_geo_log (виден через v_scrape_log_unified в admin UI)."""
try:
db.execute(
text(
"""
INSERT INTO nspd_geo_log (job_id, level, stage, cad_num, message)
VALUES (:jid, :lvl, :stg, :cad, :msg)
"""
),
{"jid": job_id, "lvl": level, "stg": stage, "cad": cad_num, "msg": message[:1000]},
)
db.commit()
except Exception as e:
logger.warning("nspd_geo_log write failed for job=%s: %s", job_id, e)
try:
db.rollback()
except Exception:
pass
def _start_job(db: Session, job_id: int) -> None:
db.execute(
text(
"""
UPDATE nspd_geo_jobs
SET status = 'running',
started_at = COALESCE(started_at, NOW()),
heartbeat_at = NOW(),
updated_at = NOW()
WHERE job_id = :id
"""
),
{"id": job_id},
)
db.commit()
_log(db, job_id, "info", "start", "job started/resumed")
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
sets = ["heartbeat_at = NOW()", "updated_at = NOW()"]
params: dict[str, Any] = {"id": job_id}
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
try:
db.execute(
text(f"UPDATE nspd_geo_jobs SET {', '.join(sets)} WHERE job_id = :id"),
params,
)
db.commit()
except Exception as e:
logger.warning("heartbeat failed for job=%s: %s", job_id, e)
try:
db.rollback()
except Exception:
pass
def _finish_job(db: Session, job_id: int, status: str, error: str | None = None) -> None:
db.execute(
text(
"""
UPDATE nspd_geo_jobs
SET status = :st, finished_at = NOW(), error = :err, updated_at = NOW()
WHERE job_id = :id
"""
),
{"id": job_id, "st": status, "err": error},
)
db.commit()
# ── Persistence для разных видов targets ────────────────────────────────────
def _save_quarter(db: Session, payload: dict, cad_num: str) -> int:
"""Парсит NSPD-response для квартала и UPSERT'ит в cad_quarters_geom."""
feats = (payload.get("data") or {}).get("features") or []
if not feats:
return 0
f = feats[0]
geom_geojson = f.get("geometry")
if not geom_geojson:
return 0
db.execute(
text(
"""
INSERT INTO cad_quarters_geom (cad_number, geom, raw_props, fetched_at)
VALUES (:cad,
-- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58).
-- NSPD может вернуть одноконтурный Polygon → ST_Multi coerce'ит в MultiPolygon,
-- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
-- 3857→4326 transform как в upsert_quarter_geom_from_feature (Mercator).
ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)),
CAST(:props AS jsonb), NOW())
ON CONFLICT (cad_number) DO UPDATE
SET geom = EXCLUDED.geom,
raw_props = EXCLUDED.raw_props,
fetched_at = NOW()
"""
),
{
"cad": cad_num,
"g": json.dumps(geom_geojson, ensure_ascii=False),
"props": json.dumps(f.get("properties") or {}, ensure_ascii=False),
},
)
return 1
def _save_building(db: Session, payload: dict, cad_num: str) -> int:
"""Парсит NSPD-response для здания и UPSERT'ит в cad_buildings (wide schema, migration 92).
cad_buildings (migration 92) — широкая схема:
- quarter_cad_number (НЕ quarter_cad_num),
- floors INT (NSPD может отдать "5-7" → _safe_int → NULL, иначе InvalidTextRepresentation),
- source TEXT NOT NULL (нет дефолта) → обязателен,
- geom GEOMETRY(Geometry, 4326) → полиморфный, ST_Multi НЕ нужен (mirror upsert_building).
"""
feats = (payload.get("data") or {}).get("features") or []
if not feats:
return 0
n = 0
for f in feats:
geom_geojson = f.get("geometry")
opts = (f.get("properties") or {}).get("options") or {}
if not geom_geojson:
continue
# Только этого здания (NSPD может вернуть здания всего квартала)
if opts.get("cad_num") != cad_num:
continue
quarter_cad = opts.get("quarter_cad_number") or _derive_quarter_cad(cad_num)
db.execute(
text(
"""
INSERT INTO cad_buildings (
cad_num, quarter_cad_number, geom, purpose, building_name,
readable_address, area, floors, raw_props, source
) VALUES (
:cad, :qcad,
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326),
:purpose, :name, :addr, :area, :floors, CAST(:props AS jsonb), :source
)
ON CONFLICT (cad_num) DO UPDATE
SET quarter_cad_number = EXCLUDED.quarter_cad_number,
geom = EXCLUDED.geom,
purpose = EXCLUDED.purpose,
building_name = EXCLUDED.building_name,
readable_address = EXCLUDED.readable_address,
area = EXCLUDED.area,
floors = EXCLUDED.floors,
raw_props = EXCLUDED.raw_props,
source = EXCLUDED.source
"""
),
{
"cad": cad_num,
"qcad": quarter_cad,
"g": json.dumps(geom_geojson, ensure_ascii=False),
"purpose": opts.get("purpose"),
"name": opts.get("building_name") or opts.get("name"),
"addr": opts.get("readable_address"),
"area": _safe_numeric(opts.get("area")),
"floors": _safe_int(opts.get("floors")),
"props": json.dumps(opts, ensure_ascii=False),
"source": "nspd",
},
)
n += 1
return n
def _save_parcel(db: Session, payload: dict, cad_num: str) -> int:
"""Парсит NSPD-response для земельного участка и UPSERT'ит в cad_parcels_geom.
NB: rosreestr2coord для area_type=1 (parcel) отдаёт GeoJSON уже в WGS84
(EPSG:4326, degrees). Просто ST_SetSRID — без ST_Transform, иначе геометрия
сжимается до точки в районе (0,0) на острове Null. Quarters/buildings —
другой path, у них Mercator (3857) и нужен ST_Transform.
"""
feats = (payload.get("data") or {}).get("features") or []
if not feats:
return 0
f = feats[0]
geom_geojson = f.get("geometry")
if not geom_geojson:
return 0
db.execute(
text(
"""
INSERT INTO cad_parcels_geom (cad_num, geom, raw_props, fetched_at)
VALUES (:cad,
-- cad_parcels.geom = geometry(MultiPolygon,4326) (migration 93;
-- cad_parcels_geom теперь back-compat VIEW над cad_parcels).
-- NSPD/rosreestr2coord отдаёт одноконтурный Polygon → ST_Multi coerce'ит
-- в MultiPolygon, иначе "Geometry type (Polygon) does not match column
-- type (MultiPolygon)". БЕЗ ST_Transform: parcel GeoJSON уже WGS84
-- (см. docstring) — только ST_SetSRID.
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)),
CAST(:props AS jsonb), NOW())
ON CONFLICT (cad_num) DO UPDATE
SET geom = EXCLUDED.geom,
raw_props = EXCLUDED.raw_props,
fetched_at = NOW()
"""
),
{
"cad": cad_num,
"g": json.dumps(geom_geojson, ensure_ascii=False),
"props": json.dumps(f.get("properties") or {}, ensure_ascii=False),
},
)
return 1
def _persist_target(db: Session, thematic_id: int, payload: dict, cad_num: str) -> tuple[int, str]:
"""Returns (n_features_saved, table_name). Полиморфно по thematic_id."""
if thematic_id == 1:
return _save_parcel(db, payload, cad_num), "cad_parcels_geom"
if thematic_id == 2:
return _save_quarter(db, payload, cad_num), "cad_quarters_geom"
if thematic_id == 5:
return _save_building(db, payload, cad_num), "cad_buildings"
return 0, ""
# ── Helper для создания job + targets ──────────────────────────────────────
def enqueue_geo_job(
*,
name: str | None,
job_kind: str,
source_kind: str,
source_params: dict[str, Any],
cad_nums_with_thematic: list[tuple[str, int]],
rate_ms: int = 600,
triggered_by: str = "manual",
) -> int:
"""Создаёт job + targets и возвращает job_id. Не enqueue'ит task сам.
Caller должен сделать `process_nspd_geo_job.apply_async(args=[job_id])`.
"""
import json as _json
db = SessionLocal()
try:
job_id = db.execute(
text(
"""
INSERT INTO nspd_geo_jobs (
name, job_kind, source_kind, source_params,
rate_ms, triggered_by, status, targets_total
) VALUES (:n, :k, :sk, CAST(:sp AS jsonb), :r, :tb, 'queued', :tt)
RETURNING job_id
"""
),
{
"n": name,
"k": job_kind,
"sk": source_kind,
"sp": _json.dumps(source_params, ensure_ascii=False),
"r": rate_ms,
"tb": triggered_by,
"tt": len(cad_nums_with_thematic),
},
).scalar_one()
# Bulk INSERT targets — parametrized to prevent SQL injection
if cad_nums_with_thematic:
db.execute(
text(
"INSERT INTO nspd_geo_targets (job_id, cad_num, thematic_id, status) "
"VALUES (:job_id, :cad_num, :thematic_id, 'pending') "
"ON CONFLICT (job_id, cad_num, thematic_id) DO NOTHING"
),
[
{"job_id": job_id, "cad_num": c, "thematic_id": t}
for c, t in cad_nums_with_thematic
],
)
db.commit()
return int(job_id)
finally:
db.close()
# ── Main task ───────────────────────────────────────────────────────────────
@celery_app.task(
bind=True,
name="tasks.nspd_geo.process_job",
max_retries=0, # resume через worker_ready, не через retry
soft_time_limit=3600 * 6, # 6 часов max — крупные jobs
)
def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
"""Идёт по nspd_geo_targets WHERE job_id=:id AND status='pending'.
Heartbeat каждые HEARTBEAT_EVERY items, persistent counters в job-row.
Idempotent: уже-сохранённые UPSERT'ятся, status target → 'done'.
"""
import time
db = SessionLocal()
try:
# Загружаем параметры job'а
job = (
db.execute(
text("SELECT * FROM nspd_geo_jobs WHERE job_id = :id"),
{"id": job_id},
)
.mappings()
.first()
)
if not job:
return {"error": "job_not_found", "job_id": job_id}
_start_job(db, job_id)
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
if job["rate_ms"]:
rate_ms = int(job["rate_ms"])
else:
from app.services.job_settings import get_setting_value
rate_ms = int(get_setting_value("nspd_geo", "rate_ms", 600, db=db))
# Текущие счётчики (для resume — продолжаем с тех значений)
done = int(job["targets_done"] or 0)
failed = int(job["targets_failed"] or 0)
skipped = int(job["targets_skipped"] or 0)
n_requests = int(job["requests_count"] or 0)
n_waf = int(job["waf_blocked_count"] or 0)
consecutive_waf = 0
logger.info("process_nspd_geo_job start: job=%s rate=%dms", job_id, rate_ms)
while True:
target = (
db.execute(
text(
"""
SELECT target_id, cad_num, thematic_id, attempts
FROM nspd_geo_targets
WHERE job_id = :id AND status = 'pending'
ORDER BY target_id
LIMIT 1
"""
),
{"id": job_id},
)
.mappings()
.first()
)
if not target:
break
tid = target["thematic_id"]
cad = target["cad_num"]
try:
# NSPD geo fetch теперь только через rosreestr2coord lib (v5+).
# Старая ветка с urllib-fetch (fetch_geoportal) удалена — community
# пакет нам надёжнее, авторы обновляют под WAF.
payload = {
"data": {
"features": [fetch_via_rosreestr2coord(cad, area_type=tid, timeout=15)]
}
}
payload["data"]["features"] = [f for f in payload["data"]["features"] if f]
# rosreestr2coord v5 убрал internal delay-параметр → rate limit
# делаем здесь, чтобы не словить WAF за высокую частоту.
if rate_ms > 0:
time.sleep(rate_ms / 1000.0)
n_requests += 1
consecutive_waf = 0
n_feats, saved_to = _persist_target(db, tid, payload, cad)
db.execute(
text(
"""
UPDATE nspd_geo_targets
SET status = 'done', result_features = :f,
saved_to_table = :s, fetched_at = NOW(),
attempts = attempts + 1
WHERE target_id = :tid
"""
),
{"f": n_feats, "s": saved_to or None, "tid": target["target_id"]},
)
db.commit()
done += 1
except NspdLiteWafError as e:
consecutive_waf += 1
n_waf += 1
wait = WAF_BACKOFF_BASE_S * min(consecutive_waf, 8)
logger.warning(
"WAF on %s (consecutive=%d), backoff %ds: %s",
cad,
consecutive_waf,
wait,
e,
)
# НЕ помечаем failed — остаётся pending для следующей итерации
_heartbeat(db, job_id, requests_count=n_requests, waf_blocked_count=n_waf)
_log(
db,
job_id,
"warn",
"waf",
f"WAF/429 (consecutive={consecutive_waf}), backoff {wait}s",
cad_num=cad,
)
time.sleep(wait)
if consecutive_waf >= 8:
# 8 подряд WAF — пауза job'а до следующего worker_ready
_finish_job(
db,
job_id,
"paused",
error=f"WAF blocked {consecutive_waf} consecutive",
)
_log(
db,
job_id,
"error",
"paused",
f"job paused after {consecutive_waf} consecutive WAF blocks",
)
return {"job_id": job_id, "paused": True, "reason": "waf"}
continue
except (NspdLiteError, Exception) as e:
logger.warning("fetch failed for %s: %s", cad, e)
_log(db, job_id, "warn", "fetch_fail", str(e)[:300], cad_num=cad)
db.execute(
text(
"""
UPDATE nspd_geo_targets
SET status = CASE WHEN attempts >= 2 THEN 'failed' ELSE 'pending' END,
attempts = attempts + 1,
error_msg = :em
WHERE target_id = :tid
"""
),
{"em": str(e)[:500], "tid": target["target_id"]},
)
db.commit()
if int(target["attempts"]) >= 2:
failed += 1
if (done + failed + skipped) % HEARTBEAT_EVERY == 0:
_heartbeat(
db,
job_id,
targets_done=done,
targets_failed=failed,
targets_skipped=skipped,
requests_count=n_requests,
waf_blocked_count=n_waf,
)
_heartbeat(
db,
job_id,
targets_done=done,
targets_failed=failed,
targets_skipped=skipped,
requests_count=n_requests,
waf_blocked_count=n_waf,
)
# done даже при failed targets — UI покажет failed_count отдельно
_finish_job(db, job_id, "done")
_log(
db,
job_id,
"info",
"done",
f"done: {done} ok / {failed} failed / {n_requests} req / {n_waf} WAF blocks",
)
logger.info(
"process_nspd_geo_job done: job=%s done=%d failed=%d req=%d waf=%d",
job_id,
done,
failed,
n_requests,
n_waf,
)
return {
"job_id": job_id,
"done": done,
"failed": failed,
"skipped": skipped,
"requests": n_requests,
"waf_blocked": n_waf,
}
except Exception as e:
logger.exception("process_nspd_geo_job crashed: job=%s: %s", job_id, e)
try:
_finish_job(db, job_id, "failed", error=f"{type(e).__name__}: {e}")
except Exception:
pass
raise
finally:
db.close()
@celery_app.task(name="tasks.nspd_geo.cleanup_zombies")
def cleanup_zombies() -> dict[str, Any]:
"""Periodic zombie cleanup — runs every minute via beat schedule.
Catches nspd_geo_jobs in status='running' / 'paused' with stale heartbeat
(>2 min) and re-enqueues them. Replaces the unreliable worker_ready signal
handler — beat is independent of worker lifecycle and fires every minute.
Idempotent: if no zombies, does nothing. If a job is genuinely active, its
heartbeat will be fresh and the WHERE clause won't match.
"""
db = SessionLocal()
resumed: list[int] = []
try:
rows = (
db.execute(
text(
"""
UPDATE nspd_geo_jobs
SET status = 'queued',
error = COALESCE(error, 'auto-resume by beat cleanup')
WHERE status IN ('running', 'paused')
AND heartbeat_at < NOW() - INTERVAL '2 minutes'
RETURNING job_id
"""
)
)
.mappings()
.all()
)
db.commit()
resumed = [int(r["job_id"]) for r in rows]
for jid in resumed:
try:
process_nspd_geo_job.apply_async(args=[jid], queue="geo")
logger.info("cleanup_zombies: re-enqueued job=%s", jid)
except Exception as e:
logger.warning("cleanup_zombies: enqueue job=%s failed: %s", jid, e)
if resumed:
logger.info("cleanup_zombies: resumed %d zombie job(s): %s", len(resumed), resumed)
except Exception as e:
logger.exception("cleanup_zombies failed: %s", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
return {"resumed_count": len(resumed), "resumed_job_ids": resumed}