Complements the NULL-geom 500 fix. cad_exists_in_db (docstring: "is there
GEOMETRY") checked only row existence, not geom IS NOT NULL — so for the ~964
meta-but-NULL-geom parcels it returned True. Consequence after the 500 fix:
such a parcel fell into the analyze fallback, find_or_enqueue_fetch step 2 saw
cad_exists_in_db=True → returned ("ready", None) → NO NSPD fetch enqueued →
analyze looped to a 202 with job_id=null and the parcel was stuck "fetching"
forever (never pulled real geometry, never resolved).
Fix: add `AND geom IS NOT NULL` to all three EXISTS branches (aligns the
function with its docstring). Now a NULL-geom parcel → cad_exists_in_db=False →
a real NSPD fetch is enqueued (202 + real job_id) → geometry populates →
re-poll → analyze succeeds (or 404 not_in_nspd if NSPD lacks it). No more
stuck-202. Valid-geom parcels unaffected. All 3 callers want geometry-presence
semantics. 37 analyze/fetch/by-bbox tests green. Refs #944.
355 lines
15 KiB
Python
355 lines
15 KiB
Python
"""On-demand cadastre geometry fetching for /site-finder.
|
||
|
||
Issue #93: пользователь вводит cad-номер которого нет в БД (cad_quarters_geom /
|
||
cad_buildings / cad_parcels_geom) — раньше получал 404 «Загрузи через NSPD geo»
|
||
(dead-end для non-admin). Теперь:
|
||
|
||
1. `find_or_enqueue_fetch(db, cad_num)` — atomic helper:
|
||
- Если cad есть в БД → return ("ready", None)
|
||
- Если уже есть active on-demand job → return ("fetching", job_id)
|
||
- Иначе создаёт новый NSPD job + enqueues Celery task → return ("fetching", job_id)
|
||
2. `fetch_status(db, cad_num)` — polling endpoint helper:
|
||
- Smart status: проверяет и БД (ready), и nspd_geo_jobs (fetching/failed),
|
||
и nspd_geo_targets (per-cad status, distinguishes not_in_nspd vs failed).
|
||
|
||
Дедупликация: `pg_try_advisory_xact_lock(hashtext(cad_num))` оборачивает
|
||
шаги "check active job → enqueue new" в `find_or_enqueue_fetch`. Lock держится
|
||
до COMMIT текущей транзакции (xact_lock — transaction-scoped). Параллельные
|
||
запросы на тот же cad: второй увидит lock=false и фолбэк-чит активный job уже
|
||
от первого транзакта (после первого COMMIT).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Literal
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
FetchStatus = Literal["ready", "fetching", "failed", "not_in_nspd", "invalid_format"]
|
||
|
||
# Source kind для on-demand jobs — отдельный от bulk-операций ("rosreestr_pending",
|
||
# "manual_list"), позволяет фильтровать в admin UI и в analytics queries.
|
||
ON_DEMAND_SOURCE_KIND = "auto_on_demand"
|
||
|
||
|
||
def detect_thematic_id(cad_num: str) -> int | None:
|
||
"""3-segment cad → quarter (thematic_id=2); 4-segment → parcel (thematic_id=1).
|
||
|
||
Building cad (5-segment) обычно не вводится пользователем напрямую,
|
||
но если придёт — попробуем как parcel.
|
||
"""
|
||
parts = cad_num.split(":")
|
||
n = len(parts)
|
||
if n == 3:
|
||
return 2 # quarter
|
||
if n == 4:
|
||
return 1 # parcel
|
||
if n == 5:
|
||
return 5 # building
|
||
return None
|
||
|
||
|
||
def validate_cad_format(cad_num: str) -> bool:
|
||
"""Проверка формата: NN:NN:NNNNNN[:NN[:NN]].
|
||
|
||
Каждый сегмент — только цифры (1+).
|
||
"""
|
||
parts = cad_num.split(":")
|
||
if not (3 <= len(parts) <= 5):
|
||
return False
|
||
return all(p.isdigit() and len(p) >= 1 for p in parts)
|
||
|
||
|
||
def cad_exists_in_db(db: Session, cad_num: str) -> bool:
|
||
"""Проверка: есть ли НЕПУСТАЯ geometry для cad_num в любой из 3 таблиц.
|
||
|
||
Использует EXISTS short-circuit — самая дешёвая проверка.
|
||
|
||
NB: `geom IS NOT NULL` обязателен — есть ~964 строк в cad_parcels_geom с meta,
|
||
но NULL-геометрией. Без фильтра функция врала бы «geometry есть» → analyze
|
||
fallback решал бы "ready"/не ставил NSPD-fetch → участок навсегда застревал в
|
||
202 (никогда не подтянул бы реальную геометрию). С фильтром null-geom участок
|
||
корректно идёт в fetch → НСПД отдаёт геометрию → analyze работает (или 404).
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT 1 WHERE EXISTS (
|
||
SELECT 1 FROM cad_quarters_geom g
|
||
WHERE g.cad_number = :c AND g.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT 1 FROM cad_buildings b
|
||
WHERE b.cad_num = :c AND b.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT 1 FROM cad_parcels_geom p
|
||
WHERE p.cad_num = :c AND p.geom IS NOT NULL
|
||
) LIMIT 1
|
||
"""
|
||
),
|
||
{"c": cad_num},
|
||
).first()
|
||
return row is not None
|
||
|
||
|
||
def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
|
||
"""Найти существующий on-demand job (queued/running) для этого cad.
|
||
|
||
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
|
||
секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
|
||
отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через
|
||
`fetch_status` отличит этот случай как `not_in_nspd`.
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT j.job_id
|
||
FROM nspd_geo_jobs j
|
||
JOIN nspd_geo_targets t ON t.job_id = j.job_id
|
||
WHERE j.source_kind = :src
|
||
AND j.status IN ('queued', 'running')
|
||
AND t.cad_num = :c
|
||
ORDER BY j.created_at DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"src": ON_DEMAND_SOURCE_KIND, "c": cad_num},
|
||
).first()
|
||
return int(row[0]) if row else None
|
||
|
||
|
||
def find_recent_completed_job(db: Session, cad_num: str) -> dict | None:
|
||
"""Last on-demand job (done/failed) для этого cad — для smart status.
|
||
|
||
Используется в `fetch_status` чтобы отличить:
|
||
- target.status='done' + cad есть в БД → ready
|
||
- target.status='done' + cad нет в БД → not_in_nspd (НСПД не вернул geometry)
|
||
- target.status='failed' → failed (rate-limit/WAF/timeout)
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT j.job_id, j.status AS job_status,
|
||
t.status AS target_status, t.error_msg
|
||
FROM nspd_geo_jobs j
|
||
JOIN nspd_geo_targets t ON t.job_id = j.job_id
|
||
WHERE j.source_kind = :src
|
||
AND t.cad_num = :c
|
||
ORDER BY j.created_at DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"src": ON_DEMAND_SOURCE_KIND, "c": cad_num},
|
||
).first()
|
||
if not row:
|
||
return None
|
||
return {
|
||
"job_id": int(row[0]),
|
||
"job_status": row[1],
|
||
"target_status": row[2],
|
||
"error_msg": row[3],
|
||
}
|
||
|
||
|
||
def enqueue_on_demand_fetch(db: Session, cad_num: str, thematic_id: int) -> int:
|
||
"""Создать новый on-demand NSPD job + enqueue Celery task.
|
||
|
||
Возвращает job_id. Использует существующий `enqueue_geo_job` helper из
|
||
`app.workers.tasks.nspd_geo` для единообразия с bulk операциями.
|
||
"""
|
||
from app.services.job_settings import get_setting_value
|
||
from app.workers.tasks.nspd_geo import enqueue_geo_job, process_nspd_geo_job
|
||
|
||
name = f"on-demand: {cad_num}"
|
||
job_kind_map = {1: "parcels", 2: "quarters", 5: "buildings"}
|
||
job_kind = job_kind_map.get(thematic_id, "mixed")
|
||
|
||
job_id = enqueue_geo_job(
|
||
name=name,
|
||
job_kind=job_kind,
|
||
source_kind=ON_DEMAND_SOURCE_KIND,
|
||
source_params={"cad_num": cad_num, "trigger": "site-finder"},
|
||
cad_nums_with_thematic=[(cad_num, thematic_id)],
|
||
# On-demand priority — rate_ms ниже чем у bulk (быстрее), чтобы
|
||
# пользователь получал ответ за ~10-20с.
|
||
rate_ms=200,
|
||
triggered_by="auto_on_demand",
|
||
)
|
||
# Enqueue в high-priority queue если есть, иначе fallback "geo"
|
||
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
|
||
process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue, priority=9)
|
||
|
||
logger.info(
|
||
"on-demand cadastre fetch enqueued: cad=%s job=%s thematic=%s",
|
||
cad_num,
|
||
job_id,
|
||
thematic_id,
|
||
)
|
||
return job_id
|
||
|
||
|
||
def find_or_enqueue_fetch(db: Session, cad_num: str) -> tuple[FetchStatus, int | None, str | None]:
|
||
"""Главный entrypoint для analyze fallback flow.
|
||
|
||
Returns: (status, job_id, error_msg).
|
||
|
||
- ("ready", None, None) — cad уже в БД, можно делать analyze
|
||
- ("invalid_format", None, msg) — bad format
|
||
- ("fetching", job_id, None) — fetch инициирован или уже идёт
|
||
- ("not_in_nspd", None, msg) — последний fetch вернул empty (НСПД нет cad)
|
||
- ("failed", job_id, msg) — последний fetch упал (rate-limit/WAF)
|
||
|
||
Caller (analyze endpoint) использует status чтобы решить:
|
||
- "ready" → продолжить с analyze (path existing)
|
||
- "fetching" → 202 + job_id для polling
|
||
- "not_in_nspd" / "invalid_format" → 404 с понятным сообщением
|
||
- "failed" → 503 с retry-after
|
||
|
||
Дедупликация (через pg advisory lock):
|
||
Шаги 3-6 ниже (check active → check recent → enqueue) оборачиваются
|
||
`pg_try_advisory_xact_lock(hashtext(cad_num))`. Lock automatically
|
||
released at xact end. Параллельные запросы на тот же cad:
|
||
- Первый запрос получает lock → выполняет enqueue → COMMIT → release.
|
||
- Второй запрос lock=false → re-проверяет active job (тот что enqueue'нул
|
||
первый и уже видим в DB после COMMIT) → возвращает ("fetching", job_id1).
|
||
"""
|
||
# 1) Format check (cheap, lock-free)
|
||
if not validate_cad_format(cad_num):
|
||
return (
|
||
"invalid_format",
|
||
None,
|
||
("Неверный формат кадастрового номера. Ожидается NN:NN:NNNNNN или NN:NN:NNNNNN:NN"),
|
||
)
|
||
|
||
# 2) Already in DB? (lock-free read)
|
||
if cad_exists_in_db(db, cad_num):
|
||
return ("ready", None, None)
|
||
|
||
# 3-6) Critical section: advisory lock на hashtext(cad_num) гарантирует
|
||
# один enqueue per cad даже при concurrent requests.
|
||
got_lock = db.execute(
|
||
text("SELECT pg_try_advisory_xact_lock(hashtext(:c))"),
|
||
{"c": cad_num},
|
||
).scalar()
|
||
|
||
# Lock acquired or not — в обоих случаях сначала проверяем active job
|
||
# (если другой xact уже enqueue'нул — мы видим его после его COMMIT).
|
||
active_job_id = find_active_on_demand_job(db, cad_num)
|
||
if active_job_id is not None:
|
||
return ("fetching", active_job_id, None)
|
||
|
||
if not got_lock:
|
||
# Lock потерян concurrent xact но active job ещё не закоммичен.
|
||
# Возвращаем "fetching" с None job_id — frontend начнёт polling и
|
||
# подцепит job через /fetch-status (там тоже проверка active job).
|
||
logger.info("advisory lock contention for cad=%s — caller will poll status", cad_num)
|
||
return ("fetching", None, None)
|
||
|
||
# 4) Recent completed job? Distinguish not_in_nspd vs failed.
|
||
recent = find_recent_completed_job(db, cad_num)
|
||
if recent:
|
||
if recent["target_status"] == "done":
|
||
# NSPD не вернул geometry (target_status=done but cad не появился в БД)
|
||
return (
|
||
"not_in_nspd",
|
||
recent["job_id"],
|
||
f"Кадастровый номер {cad_num} не найден в НСПД.",
|
||
)
|
||
elif recent["target_status"] == "failed":
|
||
# Re-enqueue новый job — даём ещё попытку
|
||
logger.info("retry on-demand fetch after prior failure: cad=%s", cad_num)
|
||
# fall through to enqueue ниже
|
||
|
||
# 5) Determine thematic_id from cad format
|
||
thematic_id = detect_thematic_id(cad_num)
|
||
if thematic_id is None:
|
||
return (
|
||
"invalid_format",
|
||
None,
|
||
f"Cannot determine cadastre type from {cad_num}",
|
||
)
|
||
|
||
# 6) Enqueue new on-demand fetch (lock освободится на COMMIT после return)
|
||
try:
|
||
job_id = enqueue_on_demand_fetch(db, cad_num, thematic_id)
|
||
return ("fetching", job_id, None)
|
||
except Exception as e: # pragma: no cover
|
||
logger.exception("on-demand enqueue failed for cad=%s", cad_num)
|
||
return ("failed", None, f"Не удалось поставить fetch в очередь: {e}")
|
||
|
||
|
||
def fetch_status(db: Session, cad_num: str) -> dict:
|
||
"""Polling endpoint helper. Возвращает dict для FastAPI JSON response.
|
||
|
||
Контракт для frontend:
|
||
{
|
||
"status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format",
|
||
"job_id": int | None,
|
||
"error_msg": str | None,
|
||
"eta_seconds": int | None, # для fetching — оценка ожидания
|
||
}
|
||
"""
|
||
if not validate_cad_format(cad_num):
|
||
return {
|
||
"status": "invalid_format",
|
||
"job_id": None,
|
||
"error_msg": "Неверный формат кадастрового номера",
|
||
"eta_seconds": None,
|
||
}
|
||
|
||
# Recheck DB first — может быть NSPD job уже отработал
|
||
if cad_exists_in_db(db, cad_num):
|
||
return {
|
||
"status": "ready",
|
||
"job_id": None,
|
||
"error_msg": None,
|
||
"eta_seconds": None,
|
||
}
|
||
|
||
# Active in-flight job?
|
||
active_job_id = find_active_on_demand_job(db, cad_num)
|
||
if active_job_id is not None:
|
||
# Estimate ETA — depends on queue depth, simplified to ~15s
|
||
return {
|
||
"status": "fetching",
|
||
"job_id": active_job_id,
|
||
"error_msg": None,
|
||
"eta_seconds": 15,
|
||
}
|
||
|
||
# Recent completed job for diagnosis
|
||
recent = find_recent_completed_job(db, cad_num)
|
||
if recent:
|
||
if recent["target_status"] == "done":
|
||
# Job finished but cad still not in DB → NSPD не отдал geometry
|
||
return {
|
||
"status": "not_in_nspd",
|
||
"job_id": recent["job_id"],
|
||
"error_msg": (
|
||
f"Кадастровый номер {cad_num} не найден в НСПД. "
|
||
"Проверьте формат: NN:NN:NNNNNN[:NN]"
|
||
),
|
||
"eta_seconds": None,
|
||
}
|
||
elif recent["target_status"] == "failed":
|
||
return {
|
||
"status": "failed",
|
||
"job_id": recent["job_id"],
|
||
"error_msg": recent["error_msg"]
|
||
or "НСПД временно недоступен. Попробуйте через минуту.",
|
||
"eta_seconds": None,
|
||
}
|
||
|
||
# No job at all — first request without prior history
|
||
return {
|
||
"status": "not_in_nspd",
|
||
"job_id": None,
|
||
"error_msg": (
|
||
f"Кадастровый номер {cad_num} не загружен. "
|
||
"Откройте /site-finder и введите cad — он будет загружен автоматически."
|
||
),
|
||
"eta_seconds": None,
|
||
}
|