gendesign/backend/app/services/site_finder/cadastre_fetch.py
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

373 lines
16 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.

"""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/paused) для этого cad.
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через
`fetch_status` отличит этот случай как `not_in_nspd`.
NB (issue #1356): 'paused' тоже считается active. Job переходит в 'paused'
при WAF (consecutive>=8) или Celery soft_time_limit (6h) — нетронутые targets
остаются 'pending', а job авто-резюмится через cleanup_zombies/worker_ready.
Это in-flight состояние, а не «не найдено»: без него paused job проваливался
бы в неверный ответ 'not_in_nspd' в `fetch_status`.
"""
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', 'paused')
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,
}
else:
# Non-terminal target (issue #1356): чаще всего 'pending' у paused/queued
# job, который ещё не дошёл до этого cad. Активный job обычно ловится выше
# в `find_active_on_demand_job`, но на гонке статусов сюда может прилететь
# job с pending-target — это in-flight, а НЕ 'not_in_nspd'. Консервативно
# отдаём 'fetching', чтобы frontend продолжил polling, а не показал 404.
return {
"status": "fetching",
"job_id": recent["job_id"],
"error_msg": None,
"eta_seconds": 15,
}
# 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,
}