gendesign/backend/app/workers/tasks/scrape_cadastre.py
bot-backend 14f3ef2019
All checks were successful
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m20s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-17 17:13:38 +00:00

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

"""Celery tasks для bulk cadastre harvest (GH issue #168, PR 3/5).
Архитектура:
- enqueue_cadastre_harvest(job_id) — читает cadastre_jobs.raw_targets,
запускает bulk_harvest_quarter_task для каждого квартала
- bulk_harvest_quarter_task(quarter, job_id) — per-quarter Celery task,
оборачивает async harvest_quarter() через asyncio.run()
Saga state в cadastre_jobs.phase_state JSONB:
{phase, quarter, parcels_total_meta, buildings_total_meta, overflow_cats, ...}
acks_late=True + idempotent upsert (ON CONFLICT) → resume после redeploy.
WAF 403 → no retry, increment waf_blocked_count.
"""
from __future__ import annotations
import asyncio
import datetime as _dt
import json
import logging
from typing import Any
from sqlalchemy import text
from app.core.db import SessionLocal
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError, NspdBulkWafError
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Очередь для cadastre задач
CADASTRE_QUEUE = "geo"
# ── Saga state helpers ───────────────────────────────────────────────────────
def _update_job(
db: Any,
job_id: int,
*,
status: str | None = None,
started_at: _dt.datetime | None = None,
finished_at: _dt.datetime | None = None,
phase_state_update: dict[str, Any] | None = None,
heartbeat: bool = False,
targets_done_inc: int = 0,
targets_failed_inc: int = 0,
requests_count_inc: int = 0,
waf_blocked_inc: int = 0,
last_error: str | None = None,
) -> None:
"""Атомарный UPDATE одной строки cadastre_jobs.
Все параметры опциональны — только переданные поля обновляются.
phase_state_update мержится поверх текущего JSONB.
"""
sets: list[str] = []
params: dict[str, Any] = {"id": job_id}
if heartbeat or heartbeat is True:
sets.append("heartbeat_at = NOW()")
if status is not None:
sets.append("status = :status")
params["status"] = status
if started_at is not None:
sets.append("started_at = COALESCE(started_at, :started_at)")
params["started_at"] = started_at
if finished_at is not None:
sets.append("finished_at = :finished_at")
params["finished_at"] = finished_at
if last_error is not None:
sets.append("error = :last_error")
params["last_error"] = last_error[:2000]
if phase_state_update is not None:
# Merge JSONB: текущий phase_state || новый partial update
sets.append(
"phase_state = COALESCE(phase_state, CAST('{}' AS jsonb)) "
"|| CAST(:phase_state_update AS jsonb)"
)
params["phase_state_update"] = json.dumps(phase_state_update, ensure_ascii=False)
if targets_done_inc:
sets.append("targets_done = targets_done + :done_inc")
params["done_inc"] = targets_done_inc
if targets_failed_inc:
sets.append("targets_failed = targets_failed + :failed_inc")
params["failed_inc"] = targets_failed_inc
if requests_count_inc:
sets.append("requests_count = requests_count + :req_inc")
params["req_inc"] = requests_count_inc
if waf_blocked_inc:
sets.append("waf_blocked_count = waf_blocked_count + :waf_inc")
params["waf_inc"] = waf_blocked_inc
if not sets:
return
db.execute(
text(f"UPDATE cadastre_jobs SET {', '.join(sets)} WHERE job_id = :id"),
params,
)
def _is_job_cancelled(db: Any, job_id: int) -> bool:
"""Проверить не отменён ли job (воркер должен stop обработку)."""
row = (
db.execute(
text("SELECT status FROM cadastre_jobs WHERE job_id = :id"),
{"id": job_id},
)
.mappings()
.first()
)
return row is not None and row["status"] == "cancelled"
# ── Основной per-quarter task ────────────────────────────────────────────────
@celery_app.task(
bind=True,
name="tasks.cadastre.harvest_quarter",
acks_late=True,
max_retries=3,
# autoretry_for=(Exception,) ловит ВСЁ кроме явных исключений ниже. Issue #252:
# квартал-уровневый NspdBulkServerError (HTTP 500 / WMS ServiceException на
# Phase 1 search_by_quarter) — подкласс Exception и НЕ в dont_autoretry_for,
# значит ретраится с backoff (GlitchTip BACKEND-8/10/11/.../37). Per-cell 500
# внутри grid-walk квартал НЕ валят — слой пропускается с harvest_meta-флагом
# (см. bulk_harvest._grid_walk_category).
autoretry_for=(Exception,),
retry_backoff=True,
retry_backoff_max=60,
dont_autoretry_for=(NspdBulkWafError,),
soft_time_limit=600, # 10 мин: 225 grid-walk запросов × ~0.1с + overhead
time_limit=900,
)
def bulk_harvest_quarter_task(self: Any, quarter: str, job_id: int) -> dict[str, Any]:
"""Celery wrapper для harvest_quarter одного кадастрового квартала.
Sync→async bridge через asyncio.run().
Saga state пишется в cadastre_jobs.phase_state через update_progress callback.
WAF 403 → не retry, waf_blocked_count++.
"""
from app.services.cadastre.bulk_harvest import harvest_quarter
db = SessionLocal()
try:
# Проверить не отменён ли job
if _is_job_cancelled(db, job_id):
logger.info(
"bulk_harvest_quarter_task: job=%d cancelled, skip quarter=%s",
job_id,
quarter,
)
db.commit()
return {"quarter": quarter, "job_id": job_id, "skipped": "cancelled"}
# Обновить heartbeat + started_at
_update_job(
db,
job_id,
status="running",
started_at=_dt.datetime.now(_dt.UTC),
heartbeat=True,
)
db.commit()
def progress_cb(state: dict[str, Any]) -> None:
"""Callback: пишет phase_state в DB при каждом шаге фазы."""
try:
_update_job(db, job_id, phase_state_update=state, heartbeat=True)
db.commit()
except Exception as cb_err:
logger.warning(
"progress_cb failed for job=%d quarter=%s: %s",
job_id,
quarter,
cb_err,
)
try:
db.rollback()
except Exception:
pass
# Запустить async harvest_quarter в синхронном контексте Celery
async def _run() -> dict[str, Any]:
from app.scrapers.nspd_bulk_client import NSPDBulkClient
async with NSPDBulkClient() as client:
result = await harvest_quarter(
db=db,
client=client,
quarter=quarter,
job_id=job_id,
update_progress=progress_cb,
)
return {
"quarter": quarter,
"job_id": job_id,
"parcels": result.parcels_upserted,
"buildings": result.buildings_upserted,
"constructions": result.constructions_upserted,
"oncs": result.oncs_upserted,
"enks": result.enks_upserted,
"zouit": result.zouit_upserted,
"snapshot_requests": result.snapshot_requests,
"grid_walk_requests": result.grid_walk_requests,
"total_requests": result.total_requests,
# Issue #252: слои, чей grid-walk целиком сбойнул на 500 — для
# observability в результате task (квартал при этом успешен).
"failed_layers": result.failed_layers,
}
result_dict = asyncio.run(_run())
# Успех: increment targets_done. requests_count учитывает ВСЕ NSPD HTTP calls
# (Phase 1 search + Phase 1.5 per-cat probes + Phase 2-3 grid-walk), не только
# grid_walk_requests — иначе snapshot-only quarters давали бы 0 запросов в metrics.
_update_job(
db,
job_id,
targets_done_inc=1,
requests_count_inc=result_dict.get("total_requests", 0),
heartbeat=True,
)
# Проверить не завершён ли job (все targets done)
_maybe_finish_job(db, job_id)
db.commit()
logger.info(
"bulk_harvest_quarter_task done: quarter=%s job=%d parcels=%d buildings=%d",
quarter,
job_id,
result_dict.get("parcels", 0),
result_dict.get("buildings", 0),
)
return result_dict
except NspdBulkWafError as e:
# WAF 403 — не retry, только счётчик
logger.warning(
"bulk_harvest_quarter_task WAF block: quarter=%s job=%d: %s",
quarter,
job_id,
e,
)
_update_job(
db,
job_id,
waf_blocked_inc=1,
targets_failed_inc=1,
last_error=f"WAF: {e}",
heartbeat=True,
)
db.commit()
# NspdBulkWafError в dont_autoretry_for → не retry
raise
except Exception as e:
logger.exception(
"bulk_harvest_quarter_task failed: quarter=%s job=%d: %s",
quarter,
job_id,
e,
)
# Bug #1236: финальная (после исчерпания autoretry) попытка обязана
# учесть квартал в targets_failed и попытаться закрыть job. Иначе
# done+failed < total → _maybe_finish_job не срабатывает → cadastre_jobs
# вечно 'running' с протухшим heartbeat. Промежуточные autoretry-попытки
# (retries < max_retries) счётчики НЕ трогают — Celery re-enqueue'ит task,
# финальный исход учтётся ровно один раз.
max_retries = getattr(self, "max_retries", 0) or 0
current_retries = getattr(getattr(self, "request", None), "retries", 0) or 0
is_final_attempt = current_retries >= max_retries
try:
_update_job(
db,
job_id,
last_error=f"{type(e).__name__}: {e}"[:500],
targets_failed_inc=1 if is_final_attempt else 0,
heartbeat=True,
)
if is_final_attempt:
_maybe_finish_job(db, job_id)
db.commit()
except Exception as inner_e:
logger.warning("failed to update job state: %s", inner_e)
try:
db.rollback()
except Exception:
pass
raise
finally:
db.close()
def _maybe_finish_job(db: Any, job_id: int) -> None:
"""Отметить job как done если targets_done + targets_failed == targets_total."""
row = (
db.execute(
text("""
SELECT targets_total, targets_done, targets_failed, status
FROM cadastre_jobs WHERE job_id = :id
"""),
{"id": job_id},
)
.mappings()
.first()
)
if not row:
return
if row["status"] in ("cancelled", "done", "failed"):
return
total = row["targets_total"] or 0
done = row["targets_done"] or 0
failed = row["targets_failed"] or 0
if total > 0 and (done + failed) >= total:
db.execute(
text("""
UPDATE cadastre_jobs
SET status = 'done', finished_at = NOW()
WHERE job_id = :id AND status NOT IN ('cancelled', 'done', 'failed')
"""),
{"id": job_id},
)
logger.info(
"cadastre job %d finished (done=%d failed=%d total=%d)", job_id, done, failed, total
)
# ── Enqueue task ─────────────────────────────────────────────────────────────
@celery_app.task(
bind=True,
name="tasks.cadastre.enqueue_harvest",
)
def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
"""Прочитать cadastre_jobs.raw_targets и запустить per-quarter task'и.
Идемпотентен: повторный вызов просто повторно enqueue'ит те же quarters.
Это OK т.к. bulk_harvest_quarter_task idempotent (ON CONFLICT DO UPDATE +
phase_state='done' early exit).
"""
db = SessionLocal()
try:
job = (
db.execute(
text("SELECT * FROM cadastre_jobs WHERE job_id = :id"),
{"id": job_id},
)
.mappings()
.first()
)
if not job:
logger.error("enqueue_cadastre_harvest: job_id=%d not found", job_id)
return {"error": "job_not_found", "job_id": job_id}
if job["status"] == "cancelled":
return {"job_id": job_id, "skipped": "cancelled"}
raw_targets = job["raw_targets"] or {}
quarters: list[str] = raw_targets.get("quarters", [])
if not quarters:
logger.warning("enqueue_cadastre_harvest: job=%d имеет пустой список quarters", job_id)
return {"job_id": job_id, "enqueued": 0}
# Fix H: per-quarter freshness skip — не переобрабатывать quarters с свежим
# cad_quarter_stats.fetched_at (последний успешный обход <FRESHNESS_HOURS).
# raw_targets.skip_fresh_hours=N управляет порогом; 0 → отключает skip.
# Хранится в существующем `raw_targets` JSONB (no schema change).
# Это сэкономит ~30k NSPD calls при повторных запусках того же scope.
skip_fresh_hours = int(raw_targets.get("skip_fresh_hours", 1) or 0)
skipped_fresh: set[str] = set()
if skip_fresh_hours > 0:
threshold = _dt.datetime.now(_dt.UTC) - _dt.timedelta(hours=skip_fresh_hours)
rows = (
db.execute(
text(
"SELECT cad_number FROM cad_quarter_stats "
"WHERE cad_number = ANY(CAST(:qs AS text[])) "
"AND fetched_at >= :t"
),
{"qs": quarters, "t": threshold},
)
.scalars()
.all()
)
skipped_fresh = set(rows)
if skipped_fresh:
# Корректируем targets_total — skipped quarters не входят в прогресс.
# Bug #1654: блок выполняется при КАЖДОМ вызове (resume re-enqueue'ит
# тот же enqueue_cadastre_harvest), а уже обработанные кварталы тоже
# получают свежий cad_quarter_stats.fetched_at → попадают в
# skipped_fresh повторно. Поэтому:
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
# • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
# кварталы (len(quarters) skipped_fresh). При resume уже
# обработанные попадают в skipped_fresh, поэтому к остатку
# добавляем уже учтённый прогресс (done + failed), иначе total
# занижается и _maybe_finish_job помечает job 'done' после первого
# доработанного квартала (#1654-followup). GREATEST с самим
# прогрессом сохраняет монотонность (total не уменьшается).
db.execute(
text(
"UPDATE cadastre_jobs SET "
"targets_total = GREATEST("
":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), "
"COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)"
"), "
"targets_skipped = :sk "
"WHERE job_id = :id"
),
{
"new_total": len(quarters) - len(skipped_fresh),
"sk": len(skipped_fresh),
"id": job_id,
},
)
# Обновить статус на running
_update_job(db, job_id, status="running", started_at=_dt.datetime.now(_dt.UTC))
db.commit()
enqueued = 0
for quarter in quarters:
if quarter in skipped_fresh:
continue
bulk_harvest_quarter_task.apply_async(
args=[quarter, job_id],
queue=CADASTRE_QUEUE,
)
enqueued += 1
logger.info(
"enqueue_cadastre_harvest: job=%d enqueued %d (skipped_fresh=%d, threshold=%dh)",
job_id,
enqueued,
len(skipped_fresh),
skip_fresh_hours,
)
return {"job_id": job_id, "enqueued": enqueued, "skipped_fresh": len(skipped_fresh)}
finally:
db.close()
# ── Zombie cleanup ───────────────────────────────────────────────────────────
@celery_app.task(name="tasks.cadastre.cleanup_zombies")
def cleanup_cadastre_zombies() -> dict[str, Any]:
"""Periodic: найти stale cadastre_jobs (heartbeat > 5 мин) и переставить в паузу.
Запускается через beat (beat_schedule.py). Idempotent.
"""
db = SessionLocal()
stale_ids: list[int] = []
try:
rows = (
db.execute(
text("""
UPDATE cadastre_jobs
SET status = 'paused',
error = COALESCE(error, 'auto-paused: stale heartbeat')
WHERE status = 'running'
AND heartbeat_at < NOW() - INTERVAL '5 minutes'
RETURNING job_id
""")
)
.mappings()
.all()
)
db.commit()
stale_ids = [int(r["job_id"]) for r in rows]
if stale_ids:
logger.warning("cleanup_cadastre_zombies: paused stale jobs: %s", stale_ids)
except Exception as e:
logger.exception("cleanup_cadastre_zombies failed: %s", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
return {"stale_paused": stale_ids}
# ── Geom backfill (issue #200) ───────────────────────────────────────────────
@celery_app.task(
bind=True,
name="tasks.cadastre.backfill_parcel_geom",
acks_late=True,
max_retries=2,
# WAF/429 — не наш баг, не ретраим (как bulk_harvest_quarter_task). Прочие
# ошибки докачки тоже не ретраим автоматически: backfill идемпотентен и
# запускается вручную/по расписанию, повторный прогон возьмёт остаток.
autoretry_for=(),
soft_time_limit=900,
time_limit=1200,
)
def backfill_parcel_geom_task(self: Any, limit: int = 500) -> dict[str, Any]:
"""Докачать geom для участков cad_parcels с geom IS NULL (issue #200).
Sync→async bridge через asyncio.run() (как bulk_harvest_quarter_task).
Один прогон обрабатывает до `limit` участков; idempotent — повторный вызов
берёт следующую порцию (вылеченные уходят из выборки, систематически
недоступные исключаются флагом geom_unavailable).
Запуск вручную (CLI/admin) или через beat, если нужно периодически дочищать
хвост NULL-geom после bulk-harvest. Возвращает счётчики GeomBackfillResult.
"""
from app.services.cadastre.bulk_harvest import backfill_parcel_geom
db = SessionLocal()
try:
async def _run() -> dict[str, Any]:
from app.scrapers.nspd_bulk_client import NSPDBulkClient
async with NSPDBulkClient() as client:
res = await backfill_parcel_geom(db=db, client=client, limit=limit)
return {
"quarters_scanned": res.quarters_scanned,
"parcels_targeted": res.parcels_targeted,
"parcels_healed": res.parcels_healed,
"parcels_marked_unavailable": res.parcels_marked_unavailable,
"grid_walk_requests": res.grid_walk_requests,
}
result_dict = asyncio.run(_run())
logger.info("backfill_parcel_geom_task done: %s", result_dict)
return result_dict
except (NspdBulkWafError, NspdBulkRateLimitError) as e:
# WAF 403 / исчерпанный rate-limit — прерываем прогон без retry. Уже
# вылеченные участки закоммичены поквартально внутри backfill_parcel_geom,
# следующий ручной/плановый запуск добьёт остаток.
logger.warning("backfill_parcel_geom_task aborted (WAF/rate-limit): %s", e)
try:
db.rollback()
except Exception:
pass
return {"aborted": "waf_or_rate_limit", "error": str(e)[:300]}
except Exception as e:
logger.exception("backfill_parcel_geom_task failed: %s", e)
try:
db.rollback()
except Exception:
pass
raise
finally:
db.close()