504 lines
26 KiB
Python
504 lines
26 KiB
Python
"""Кэш ПЗЗ-градрегламента по индексу зоны + числовой экстракт предельных параметров (#1067 C8b).
|
||
|
||
Регламент ПЗЗ идентичен для всех участков одной терзоны, а зон на город ~100 → резолвим из
|
||
ЕКБ-геопортала urbanCard ОДИН раз на (city, zone_index) и кэшируем в ``zone_regulation_cache``
|
||
(м.133). Предельные параметры urbanCard отдаёт СВОБОДНЫМ ТЕКСТОМ (``otherDescription``) — тут же
|
||
их числовой экстракт (regex) в ключевые поля застройки для capacity-оценки.
|
||
|
||
Слои:
|
||
* :func:`parse_limit_params` — чистый экстрактор (regex, без IO; основной тест-таргет).
|
||
* :func:`upsert_zone_regulation` / :func:`get_cached_zone_regulation` — запись/чтение кэша.
|
||
* :func:`get_or_fetch_zone_regulation` — get-or-fetch (cache miss → urbanCard → upsert).
|
||
|
||
Текст предельных параметров (live-verified 2026-06-06, vault
|
||
research/EKB_Geoportal_PZZ_Content_API_Jun06) — пример (зона Ц-1):
|
||
'минимальная площадь земельного участка - 1500 кв. м;\\n
|
||
максимальный процент застройки ... – 100%;\\n
|
||
коэффициент строительного использования земельного участка – 4;\\n
|
||
процент застройки подземного пространства – 100%;\\n ...'
|
||
Каждый параметр часто повторяется per-ВРИ (среднеэтажная 1500 / многоэтажная 3000) и сплошь
|
||
встречается «не подлежат установлению» (числа нет → НЕ матчим). Поэтому params_numeric хранит
|
||
СПИСКИ значений, а headline-скаляры — most-permissive агрегат (capacity best-case).
|
||
|
||
Graceful: при отсутствии таблицы/ошибке БД чтение возвращает None, analyze не падает (wiring D9b).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
import threading
|
||
import time
|
||
from collections import OrderedDict
|
||
from typing import Any
|
||
|
||
from shapely.geometry import shape
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import SessionLocal
|
||
from app.services.scrapers.ekb_geoportal_client import (
|
||
EKBFeature,
|
||
EKBGeoportalClient,
|
||
ZoneRegulation,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Мемоизация coord → zone_index (WFS-lookup) (#1850) ───────────────────────
|
||
# Маппинг «координата → индекс терзоны» СТАТИЧЕН: границы зон не двигаются. Поэтому
|
||
# единственный живой WFS-вызов резолвера (``client.zone_index_at``) кэшируется
|
||
# процесс-локально. Важно: кэшируется ТОЛЬКО индекс зоны — СОДЕРЖИМОЕ регламента всё
|
||
# равно читается свежим из ``zone_regulation_cache`` на каждый вызов, поэтому корректность
|
||
# обновления кэша регламента сохраняется (refresh-воркер обновляет БД, не этот memo).
|
||
#
|
||
# Ключ — координата, округлённая до 5 знаков (≈1 м): безопасно мельче границ зон,
|
||
# но достаточно грубо чтобы соседние /analyze одного участка (один centroid) попадали
|
||
# в один ключ. Округлять КРУПНЕЕ нельзя — рискуем перепрыгнуть границу зоны.
|
||
#
|
||
# Bounded LRU (OrderedDict, FIFO-эвикция самого старого) — клиент не хешируемый, поэтому
|
||
# ``functools.lru_cache`` неприменим; держим собственный OrderedDict-LRU.
|
||
# THREAD-SAFETY (#1850): /analyze — sync def → исполняется в Starlette threadpool, т.е.
|
||
# несколько потоков мутируют этот module-global OrderedDict ОДНОВРЕМЕННО. Свой LRU (в
|
||
# отличие от functools.lru_cache с внутренним локом) НЕ атомарен: check-then-act между
|
||
# ``key in memo`` и ``move_to_end``/``[]`` против чужого ``popitem`` даёт KeyError. Поэтому
|
||
# мутации под Lock'ом; WFS-вызов — ВНЕ лока (иначе медленный геопортал сериализует все потоки).
|
||
_ZONE_INDEX_MEMO_MAXSIZE = 4096
|
||
_zone_index_memo: OrderedDict[tuple[float, float], str | None] = OrderedDict()
|
||
_zone_index_memo_lock = threading.Lock()
|
||
|
||
# Sentinel «координата вне терзоны» — отличает закэшированный негативный результат
|
||
# (None-зона, не пере-дёргаем WFS для участков вне ЕКБ) от «ключа нет в memo».
|
||
_NO_ZONE = "\x00__no_zone__\x00"
|
||
|
||
|
||
def _memoized_zone_index(client: EKBGeoportalClient, lon: float, lat: float) -> str | None:
|
||
"""Индекс терзоны в точке с процесс-локальной мемоизацией (#1850).
|
||
|
||
Кэширует И положительный (индекс зоны), И отрицательный (None → участок вне терзоны/ЕКБ)
|
||
результат, чтобы повтор по тем же/близким координатам не делал WFS round-trip.
|
||
"""
|
||
key = (round(lon, 5), round(lat, 5))
|
||
with _zone_index_memo_lock:
|
||
if key in _zone_index_memo:
|
||
_zone_index_memo.move_to_end(key) # LRU: освежаем недавно использованный ключ
|
||
cached = _zone_index_memo[key]
|
||
return None if cached == _NO_ZONE else cached
|
||
# WFS-вызов ВНЕ лока: сетевой round-trip не должен сериализовать тёплые потоки.
|
||
# Дубль-вызов на одном холодном ключе из двух потоков безвреден (lookup идемпотентен).
|
||
zone_index = client.zone_index_at(lon, lat)
|
||
with _zone_index_memo_lock:
|
||
_zone_index_memo[key] = _NO_ZONE if zone_index is None else zone_index
|
||
_zone_index_memo.move_to_end(key)
|
||
if len(_zone_index_memo) > _ZONE_INDEX_MEMO_MAXSIZE:
|
||
_zone_index_memo.popitem(last=False) # эвикция самого старого
|
||
return zone_index
|
||
|
||
|
||
def _reset_zone_index_memo() -> None:
|
||
"""Очистить memo coord→zone_index (для тестов; prod — процесс-локальный кэш)."""
|
||
_zone_index_memo.clear()
|
||
|
||
|
||
# bbox агломерации ЕКБ (minlon, minlat, maxlon, maxlat) EPSG:4326 — границы для WFS-перечисления
|
||
# терзон. Слой territorial_zone геопортала — ЕКБ-only, поэтому bbox это просто bounds-обёртка
|
||
# (а не фильтр города). Шире чем нужно — лучше захватить лишнее, дедуп по zone_index снимет повтор.
|
||
_EKB_CITY_BBOX: tuple[float, float, float, float] = (60.0, 56.6, 61.1, 57.1)
|
||
|
||
# ── Числовой экстракт предельных параметров (regex) ──────────────────────────
|
||
# Числовой токен: тысячные разделены пробелом/nbsp ('50 000'), десятичная — запятая ('2,4').
|
||
# Сначала grouped-тысячи, иначе простое число. `.` по умолчанию НЕ переходит \n — параметры
|
||
# разделены \n, поэтому non-greedy `[^\n]*?` не перепрыгнет на число следующего параметра.
|
||
_NUM = r"(\d{1,3}(?:[ ]\d{3})+(?:[.,]\d+)?|\d+(?:[.,]\d+)?)"
|
||
# Тире в реальном тексте варьируется: дефис, en-dash, em-dash.
|
||
_DASH = r"[-–—]"
|
||
|
||
# мин. площадь ЗУ — 'минимальная площадь земельного участка - 1500 кв. м'
|
||
_RE_MIN_AREA = re.compile(
|
||
r"минимальн\w*\s+площад\w*\s+земельн\w*\s+участка\s*" + _DASH + r"\s*" + _NUM + r"\s*кв",
|
||
re.IGNORECASE,
|
||
)
|
||
# макс. % застройки — 'максимальный процент застройки ... – 100%'
|
||
_RE_BUILD_PCT = re.compile(
|
||
r"максимальн\w*\s+процент\s+застройки[^\n]*?" + _DASH + r"\s*" + _NUM + r"\s*%",
|
||
re.IGNORECASE,
|
||
)
|
||
# КСИ — 'коэффициент строительного использования земельного участка ... – 4' / '– 2,4'
|
||
_RE_FAR = re.compile(
|
||
r"коэффициент\s+строительного\s+использования\s+земельн\w*\s+участка[^\n]*?"
|
||
+ _DASH
|
||
+ r"\s*"
|
||
+ _NUM,
|
||
re.IGNORECASE,
|
||
)
|
||
# подземная застройка — 'процент застройки подземного пространства – 100%'
|
||
_RE_UNDERGROUND = re.compile(
|
||
r"процент\s+застройки\s+подземного\s+пространства\s*" + _DASH + r"\s*" + _NUM + r"\s*%",
|
||
re.IGNORECASE,
|
||
)
|
||
# этажность — 'предельная этажность ... – 5 этажей' / 'предельное количество этажей ... – 5'
|
||
_RE_FLOORS = re.compile(
|
||
r"(?:предельн\w*\s+этажност\w*|предельное\s+количество\s+этажей)[^\n]*?"
|
||
+ _DASH
|
||
+ r"\s*"
|
||
+ _NUM
|
||
+ r"(?:\s*этаж\w*)?",
|
||
re.IGNORECASE,
|
||
)
|
||
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
||
_RE_HEIGHT = re.compile(
|
||
r"предельн\w*\s+высот\w*[^\n]*?" + _DASH + r"\s*" + _NUM + r"\s*м",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
# param-ключ → (compiled regex, агрегатор headline-скаляра по списку значений).
|
||
# min_parcel_area — НАИМЕНЬШИЙ минимум (самый разрешительный); прочие — max (capacity best-case).
|
||
_PARAM_SPECS: tuple[tuple[str, re.Pattern[str], Any], ...] = (
|
||
("max_far", _RE_FAR, max),
|
||
("max_building_pct", _RE_BUILD_PCT, max),
|
||
("underground_pct", _RE_UNDERGROUND, max),
|
||
("min_parcel_area_m2", _RE_MIN_AREA, min),
|
||
("max_floors", _RE_FLOORS, max),
|
||
("max_height_m", _RE_HEIGHT, max),
|
||
)
|
||
|
||
|
||
def _to_number(raw: str) -> float:
|
||
"""Нормализовать захваченный токен в float: убрать пробелы/nbsp тысяч, запятая → точка."""
|
||
s = raw.replace(" ", "").replace(" ", "").replace(",", ".")
|
||
return float(s)
|
||
|
||
|
||
def parse_limit_params(texts: list[str] | None) -> dict[str, list[float]]:
|
||
"""Числовой экстракт предельных параметров из сырых ``otherDescription``-строк.
|
||
|
||
Args:
|
||
texts: список свободно-текстовых блоков предельных параметров (limit_params).
|
||
|
||
Returns:
|
||
``{param_key: [значения...]}`` только для параметров, где найдено ≥1 число.
|
||
Значения в порядке появления, дубликаты сохраняются (per-ВРИ повторы осмысленны).
|
||
Параметры: ``max_far`` (КСИ), ``max_building_pct``, ``underground_pct``,
|
||
``min_parcel_area_m2``, ``max_floors``, ``max_height_m``.
|
||
"""
|
||
out: dict[str, list[float]] = {}
|
||
blob = "\n".join(t for t in (texts or []) if t)
|
||
if not blob:
|
||
return out
|
||
for key, rx, _agg in _PARAM_SPECS:
|
||
vals = [_to_number(m) for m in rx.findall(blob)]
|
||
if vals:
|
||
out[key] = vals
|
||
return out
|
||
|
||
|
||
def _headline_scalars(params_numeric: dict[str, list[float]]) -> dict[str, float | int | None]:
|
||
"""Агрегировать списки значений в headline-скаляры (most-permissive для capacity)."""
|
||
scalars: dict[str, float | int | None] = {}
|
||
for key, _rx, agg in _PARAM_SPECS:
|
||
vals = params_numeric.get(key) or []
|
||
if not vals:
|
||
scalars[key] = None
|
||
continue
|
||
v = agg(vals)
|
||
scalars[key] = int(v) if key == "max_floors" else float(v)
|
||
return scalars
|
||
|
||
|
||
# ── Кэш-таблица zone_regulation_cache (м.133) ────────────────────────────────
|
||
|
||
_UPSERT_SQL = text(
|
||
"""
|
||
INSERT INTO zone_regulation_cache (
|
||
city, zone_index, zone_full_name,
|
||
main_vri, conditional_vri, auxiliary_vri,
|
||
limit_params_raw, params_numeric,
|
||
max_far, max_building_pct, underground_pct,
|
||
min_parcel_area_m2, max_floors, max_height_m,
|
||
source, fetched_at
|
||
) VALUES (
|
||
:city, :zone_index, :zone_full_name,
|
||
CAST(:main_vri AS jsonb), CAST(:conditional_vri AS jsonb), CAST(:auxiliary_vri AS jsonb),
|
||
CAST(:limit_params_raw AS jsonb), CAST(:params_numeric AS jsonb),
|
||
:max_far, :max_building_pct, :underground_pct,
|
||
:min_parcel_area_m2, :max_floors, :max_height_m,
|
||
:source, now()
|
||
)
|
||
ON CONFLICT (city, zone_index) DO UPDATE SET
|
||
zone_full_name = EXCLUDED.zone_full_name,
|
||
main_vri = EXCLUDED.main_vri,
|
||
conditional_vri = EXCLUDED.conditional_vri,
|
||
auxiliary_vri = EXCLUDED.auxiliary_vri,
|
||
limit_params_raw = EXCLUDED.limit_params_raw,
|
||
params_numeric = EXCLUDED.params_numeric,
|
||
max_far = EXCLUDED.max_far,
|
||
max_building_pct = EXCLUDED.max_building_pct,
|
||
underground_pct = EXCLUDED.underground_pct,
|
||
min_parcel_area_m2 = EXCLUDED.min_parcel_area_m2,
|
||
max_floors = EXCLUDED.max_floors,
|
||
max_height_m = EXCLUDED.max_height_m,
|
||
source = EXCLUDED.source,
|
||
fetched_at = now()
|
||
"""
|
||
)
|
||
|
||
_SELECT_SQL = text(
|
||
"""
|
||
SELECT city, zone_index, zone_full_name,
|
||
main_vri, conditional_vri, auxiliary_vri,
|
||
limit_params_raw, params_numeric,
|
||
max_far, max_building_pct, underground_pct,
|
||
min_parcel_area_m2, max_floors, max_height_m,
|
||
source, fetched_at
|
||
FROM zone_regulation_cache
|
||
WHERE city = :city AND zone_index = :zone_index
|
||
"""
|
||
)
|
||
|
||
|
||
def upsert_zone_regulation(
|
||
db: Session, reg: ZoneRegulation, *, city: str = "ekb", source: str = "ekb_geoportal"
|
||
) -> dict[str, list[float]] | None:
|
||
"""Записать регламент зоны в кэш (UPSERT по (city, zone_index)) + числовой экстракт.
|
||
|
||
Returns:
|
||
params_numeric (числовой экстракт) при успехе; None если у регламента нет zone_index
|
||
или таблица недоступна (graceful).
|
||
"""
|
||
if not reg.zone_index:
|
||
return None
|
||
params_numeric = parse_limit_params(reg.limit_params)
|
||
scalars = _headline_scalars(params_numeric)
|
||
payload = {
|
||
"city": city,
|
||
"zone_index": reg.zone_index,
|
||
"zone_full_name": reg.zone_full_name,
|
||
"main_vri": json.dumps(reg.main_vri, ensure_ascii=False),
|
||
"conditional_vri": json.dumps(reg.conditional_vri, ensure_ascii=False),
|
||
"auxiliary_vri": json.dumps(reg.auxiliary_vri, ensure_ascii=False),
|
||
"limit_params_raw": json.dumps(reg.limit_params, ensure_ascii=False),
|
||
"params_numeric": json.dumps(params_numeric, ensure_ascii=False),
|
||
"source": source,
|
||
**scalars,
|
||
}
|
||
try:
|
||
with db.begin_nested():
|
||
db.execute(_UPSERT_SQL, payload)
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
logger.warning("upsert_zone_regulation: zone_regulation_cache недоступна, skip: %s", exc)
|
||
return None
|
||
return params_numeric
|
||
|
||
|
||
def get_cached_zone_regulation(
|
||
db: Session, zone_index: str | None, *, city: str = "ekb"
|
||
) -> dict[str, Any] | None:
|
||
"""Прочитать кэшированный регламент зоны. None при отсутствии/недоступности таблицы."""
|
||
if not zone_index:
|
||
return None
|
||
try:
|
||
row = db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first()
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc)
|
||
return None
|
||
return dict(row) if row else None
|
||
|
||
|
||
def get_or_fetch_zone_regulation(
|
||
db: Session,
|
||
lon: float,
|
||
lat: float,
|
||
*,
|
||
city: str = "ekb",
|
||
client: EKBGeoportalClient | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Get-or-fetch регламент зоны в точке: кэш → (miss) urbanCard → upsert → кэш.
|
||
|
||
Сначала индекс зоны по точке (WFS, дёшево); если он уже в кэше — отдаём кэш без urbanCard.
|
||
Иначе полный резолв через urbanCard + запись. None если в точке нет терзоны/регламента.
|
||
"""
|
||
client = client or EKBGeoportalClient()
|
||
try:
|
||
# Мемоизированный coord→zone_index: повтор (тот же centroid в рамках запроса или
|
||
# между запросами) не делает WFS round-trip (#1850).
|
||
zone_index = _memoized_zone_index(client, lon, lat)
|
||
except Exception as exc:
|
||
logger.warning("get_or_fetch_zone_regulation: zone_index_at failed: %s", exc)
|
||
return None
|
||
if zone_index:
|
||
cached = get_cached_zone_regulation(db, zone_index, city=city)
|
||
if cached is not None:
|
||
return cached
|
||
try:
|
||
reg = client.zone_regulation_at(lon, lat)
|
||
except Exception as exc:
|
||
logger.warning("get_or_fetch_zone_regulation: zone_regulation_at failed: %s", exc)
|
||
return None
|
||
if reg is None or not reg.zone_index:
|
||
return None
|
||
# #1850 item 4: апсёрт изолирован в СОБСТВЕННУЮ короткую write-сессию (SessionLocal()),
|
||
# чтобы durable-запись кэша НЕ трогала границы транзакции вызывающего. Резолвер получает
|
||
# SHARED Session от хэндлера (/analyze коммитит её РОВНО один раз в persist_analysis_run),
|
||
# поэтому db.commit() здесь раньше преждевременно коммитил весь in-flight analyze-ран.
|
||
# Свежая сессия коммитит самостоятельно → под READ COMMITTED последующий read-back на
|
||
# SHARED db видит только что записанную строку. Канонический паттерн — как в
|
||
# workers/tasks/zone_regulation_refresh.py (SessionLocal → upsert → commit → finally close).
|
||
write_db = SessionLocal()
|
||
try:
|
||
params_numeric = upsert_zone_regulation(write_db, reg, city=city)
|
||
if params_numeric is not None:
|
||
write_db.commit()
|
||
else:
|
||
write_db.rollback() # graceful: таблица недоступна / нечего персистить
|
||
except Exception as exc:
|
||
logger.warning("get_or_fetch_zone_regulation: cache write failed: %s", exc)
|
||
write_db.rollback()
|
||
finally:
|
||
write_db.close()
|
||
return get_cached_zone_regulation(db, reg.zone_index, city=city)
|
||
|
||
|
||
# ── Backfill: проактивный прогрев ВСЕХ терзон ЕКБ (эпик #1881) ────────────────
|
||
|
||
|
||
def _representative_point(geometry: dict[str, Any] | None) -> tuple[float, float] | None:
|
||
"""Точка (lon, lat) гарантированно ВНУТРИ полигона зоны.
|
||
|
||
``representative_point`` (не ``centroid``) гарантирует попадание внутрь даже у вогнутых
|
||
полигонов → searchByGeom/zone_regulation_at стабильно находит терзону. None — битая
|
||
геометрия (фичу пропускаем).
|
||
"""
|
||
if not geometry:
|
||
return None
|
||
try:
|
||
pt = shape(geometry).representative_point()
|
||
return (float(pt.x), float(pt.y))
|
||
except Exception as exc: # битая геометрия фичи — пропускаем зону, не валим batch
|
||
logger.warning("backfill_ekb_zone_regulations: bad geometry, skip: %s", exc)
|
||
return None
|
||
|
||
|
||
def _dedup_zones_by_index(features: list[EKBFeature]) -> dict[str, EKBFeature]:
|
||
"""Сгруппировать фичи по ``urban_index``, выбрав одну фичу С ВАЛИДНОЙ геометрией на индекс.
|
||
|
||
Регламент идентичен для всех полигонов одной терзоны → резолвим индекс один раз.
|
||
Предпочитаем фичу с непустой геометрией (первая встретившаяся побеждает); фича без
|
||
геометрии берётся только если иной для индекса нет (всё равно отсеется в backfill).
|
||
"""
|
||
by_index: dict[str, EKBFeature] = {}
|
||
for feat in features:
|
||
idx = feat.properties.get("urban_index")
|
||
if not idx:
|
||
continue
|
||
key = str(idx)
|
||
existing = by_index.get(key)
|
||
if existing is None:
|
||
by_index[key] = feat
|
||
elif existing.geometry is None and feat.geometry is not None:
|
||
by_index[key] = feat # апгрейд до фичи с валидной геометрией
|
||
return by_index
|
||
|
||
|
||
def backfill_ekb_zone_regulations(
|
||
db: Session,
|
||
*,
|
||
client: EKBGeoportalClient | None = None,
|
||
bbox: tuple[float, float, float, float] = _EKB_CITY_BBOX,
|
||
limit: int | None = None,
|
||
rate_delay_s: float = 1.0,
|
||
city: str = "ekb",
|
||
) -> dict[str, int]:
|
||
"""Закэшировать регламент ВСЕХ терзон ЕКБ в ``zone_regulation_cache`` (эпик #1881).
|
||
|
||
Перечисляет терзоны через WFS, дедуплицирует по ``urban_index`` и для каждого УНИКАЛЬНОГО
|
||
индекса, которого ещё нет в кэше, резолвит регламент (центроид полигона → urbanCard) и
|
||
апсёртит. Идемпотентно: уже закэшированные зоны пропускаются (cached skip), повторный
|
||
прогон ничего не дофетчит. Per-zone try/except — сбой одной зоны не валит batch.
|
||
|
||
Args:
|
||
db: SQLAlchemy-сессия (sync).
|
||
client: WFS-клиент геопортала; None → дефолтный ``EKBGeoportalClient``.
|
||
bbox: (minlon, minlat, maxlon, maxlat) EPSG:4326 — bounds для WFS-перечисления.
|
||
limit: максимум УНИКАЛЬНЫХ незакэшированных зон к фетчу (None = все). Для теста/частичного
|
||
прогона.
|
||
rate_delay_s: пауза (сек) между live-fetch'ами зон — вежливость к геопорталу.
|
||
city: ключ кэша (по умолчанию 'ekb').
|
||
|
||
Returns:
|
||
{'enumerated_features', 'unique_zones', 'cached_before', 'fetched', 'upserted', 'failed'}.
|
||
"""
|
||
client = client or EKBGeoportalClient()
|
||
features = client.features_in_bbox("territorial_zone", bbox)
|
||
by_index = _dedup_zones_by_index(features)
|
||
logger.info(
|
||
"backfill_ekb_zone_regulations: фич=%d уникальных_зон=%d",
|
||
len(features),
|
||
len(by_index),
|
||
)
|
||
|
||
cached_before = 0
|
||
fetched = 0
|
||
upserted = 0
|
||
failed = 0
|
||
fetched_this_run = 0 # сколько зон уже сходили в live-fetch (для rate_delay между ними)
|
||
|
||
for zone_index, feat in by_index.items():
|
||
# Идемпотентность: уже в кэше → не дёргаем геопортал.
|
||
if get_cached_zone_regulation(db, zone_index, city=city) is not None:
|
||
cached_before += 1
|
||
continue
|
||
if limit is not None and fetched >= limit:
|
||
break
|
||
|
||
try:
|
||
point = _representative_point(feat.geometry)
|
||
if point is None:
|
||
continue
|
||
# Вежливость к геопорталу: пауза ПЕРЕД каждым fetch кроме первого.
|
||
if fetched_this_run > 0 and rate_delay_s > 0:
|
||
time.sleep(rate_delay_s)
|
||
fetched_this_run += 1
|
||
lon, lat = point
|
||
reg = client.zone_regulation_at(lon, lat)
|
||
fetched += 1
|
||
if reg is None or not reg.zone_index:
|
||
continue
|
||
if upsert_zone_regulation(db, reg, city=city) is not None:
|
||
db.commit()
|
||
upserted += 1
|
||
except Exception as exc:
|
||
# Per-zone изоляция: один сбойный фетч/апсёрт не должен ронять весь прогон.
|
||
# rollback() (не begin_nested): db здесь — СОБСТВЕННАЯ сессия таска
|
||
# backfill_zone_regulations.py (SessionLocal() + try/finally close), не
|
||
# shared request-scoped Session — orphan'ить нечего, следующая итерация
|
||
# цикла (get_cached_zone_regulation / upsert_zone_regulation) на этом же db
|
||
# иначе поймает "current transaction is aborted" после сбойного db.commit()
|
||
# выше (#2464 cluster A finding 6; сиблинг — write_db.rollback() в
|
||
# get_or_fetch_zone_regulation, тоже owned-сессия).
|
||
db.rollback()
|
||
logger.warning("backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc)
|
||
failed += 1
|
||
continue
|
||
|
||
result = {
|
||
"enumerated_features": len(features),
|
||
"unique_zones": len(by_index),
|
||
"cached_before": cached_before,
|
||
"fetched": fetched,
|
||
"upserted": upserted,
|
||
"failed": failed,
|
||
}
|
||
logger.info("backfill_ekb_zone_regulations: %s", result)
|
||
return result
|
||
|
||
|
||
__all__ = [
|
||
"_reset_zone_index_memo",
|
||
"backfill_ekb_zone_regulations",
|
||
"get_cached_zone_regulation",
|
||
"get_or_fetch_zone_regulation",
|
||
"parse_limit_params",
|
||
"upsert_zone_regulation",
|
||
]
|