All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m1s
CI / backend-tests (pull_request) Successful in 16m4s
One layer's caught DB error left the shared §22 report Session in Postgres's "aborted transaction" state with no rollback, silently emptying every later layer on the same report. Wrap each failure point in db.begin_nested() (SAVEPOINT): orchestrator._safe_call, macro_series _query_key_rate_monthly / _query_inflation_monthly / _query_mortgage_monthly (per indicator), special_indices._run (per index), and sales_series _query_source_a / _query_source_b — so a sibling builder's failure no longer cascades. Also close the RELEASE-SAVEPOINT trap: inner helpers that swallowed a db.execute failure without their own savepoint left the tx aborted, so the outer builder's savepoint failed at RELEASE (illegal in an aborted tx) and poisoning still cascaded. Add savepoints at the inner catch sites — special_indices._query_parcel_centroid and district_resolver._admin_names / resolve_objective_districts. Refs #2464 (cluster A session-poisoning).
159 lines
9.3 KiB
Python
159 lines
9.3 KiB
Python
"""District-resolver — мост между ДВУМЯ районными вокабулярами ЕКБ (Step 2).
|
||
|
||
Контекст бага (ground-truthed на prod, см. `data/sql/131_district_vocab_map.sql`):
|
||
• §9.x market/supply/sales метрики фильтруют `objective_lots.district` (и
|
||
`objective_corpus_room_month.district`) — это НЕФОРМАЛЬНЫЕ МИКРО-районы ЕКБ
|
||
(«Втузгородок», «ЖБИ», «Уралмаш» … — 35 distinct значений).
|
||
• Но `/analyze` резолвит у участка ОФИЦИАЛЬНЫЙ АДМИН-район из `ekb_districts`
|
||
(8 полигонов: «Кировский», «Ленинский», «Орджоникидзевский» …).
|
||
• Админ-имя → 0 строк в objective_lots → молча пустой прогноз. Напр.
|
||
market_metrics('Кировский') → obj_count=0 (а 'Академический' случайно работает,
|
||
т.к. он есть в ОБОИХ вокабулярах).
|
||
|
||
Step 1 (PR #1053, УЖЕ НА ПРОДЕ) добавил таблицу-словарь `ekb_district_alias`
|
||
(admin_district → micro_district, note). Этот модуль — Step 2: резолвер, который
|
||
разворачивает админ-имя в список микро-районов, чтобы фильтры §9.x перестали
|
||
возвращать пустоту.
|
||
|
||
Семантика `resolve_objective_districts(db, district)` — см. docstring функции.
|
||
psycopg v3: все binds через `CAST(:x AS type)`, НИКОГДА `:x::type`. Read-only SELECT.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Сентинел «район не определён» — приходит из /analyze, когда участок не попал ни в
|
||
# один из 8 полигонов ekb_districts (вне ЕКБ / без координат). Трактуем как None →
|
||
# EKB-wide fallback (без district-фильтра), а не как буквальное имя района.
|
||
_UNDETERMINED_DISTRICT: str = "не определён"
|
||
|
||
# Список 8 официальных АДМИН-районов ЕКБ (district_name из ekb_districts с непустым
|
||
# geom). Кешируем per-process: он фактически статичен (8 полигонов, меняется только
|
||
# при пере-импорте границ), а резолвер бьётся per-analyze — лишний round-trip ни к
|
||
# чему. Lazy-init на первом вызове; сбрасывается через `_reset_admin_cache()` в тестах.
|
||
_ADMIN_NAMES_CACHE: frozenset[str] | None = None
|
||
|
||
# Имена 8 админ-районов: те, у кого в ekb_districts есть непустой geom (= ekb_districts_geom
|
||
# view). 'не определён' (9-я строка-dimension без geom) сюда НЕ попадает — и не должен.
|
||
_ADMIN_NAMES_SQL = text(
|
||
"""
|
||
SELECT district_name
|
||
FROM ekb_districts
|
||
WHERE geom IS NOT NULL
|
||
"""
|
||
)
|
||
|
||
# Чистые ЕКБ-микро для одного админ-района. note IS NULL отсекает cross-city
|
||
# (Н.Тагил/Каменск, собранные в bucket «Чкаловский») и ambiguous-пометки —
|
||
# нам нужны только однозначные ЕКБ-микро (см. 131_district_vocab_map.sql).
|
||
_MICROS_FOR_ADMIN_SQL = text(
|
||
"""
|
||
SELECT micro_district
|
||
FROM ekb_district_alias
|
||
WHERE admin_district = CAST(:d AS text)
|
||
AND note IS NULL
|
||
ORDER BY micro_district
|
||
"""
|
||
)
|
||
|
||
|
||
def _admin_names(db: Session) -> frozenset[str]:
|
||
"""Множество 8 официальных админ-имён ЕКБ (с непустым geom), кеш per-process.
|
||
|
||
На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
|
||
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
|
||
|
||
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): резолвер
|
||
зовётся §22-форсайт-билдерами (sales_series `_query_source_a/_b`,
|
||
`_query_artificial_demand`) на ОБЩЕЙ сессии отчёта, которые сами обёрнуты во
|
||
внешние SAVEPOINT'ы. БЕЗ `db.begin_nested():` здесь сбойный `db.execute`
|
||
проглатывается (→ frozenset()), оставляя транзакцию aborted; тогда внешний
|
||
SAVEPOINT при RELEASE падает (RELEASE запрещён в aborted-tx) и отравление
|
||
каскадит. Savepoint в точке перехвата откатывает сбой (ROLLBACK TO SAVEPOINT),
|
||
сессия остаётся рабочей.
|
||
"""
|
||
global _ADMIN_NAMES_CACHE
|
||
if _ADMIN_NAMES_CACHE is not None:
|
||
return _ADMIN_NAMES_CACHE
|
||
try:
|
||
with db.begin_nested():
|
||
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
|
||
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
|
||
except Exception:
|
||
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
|
||
logger.exception("district_resolver: admin-names lookup failed")
|
||
return frozenset()
|
||
return _ADMIN_NAMES_CACHE
|
||
|
||
|
||
def _reset_admin_cache() -> None:
|
||
"""Сбросить per-process кеш админ-имён (для тестов / форс-рефреша границ)."""
|
||
global _ADMIN_NAMES_CACHE
|
||
_ADMIN_NAMES_CACHE = None
|
||
|
||
|
||
def resolve_objective_districts(db: Session, district: str | None) -> list[str] | None:
|
||
"""Развернуть district-вход в набор МИКРО-районов для фильтра objective_lots.
|
||
|
||
Решает рассинхрон вокабуляров ЕКБ (admin polygons vs informal micro-districts).
|
||
Возвращаемое значение — контракт для вызывающего фильтра:
|
||
|
||
• ``None`` → фильтровать EKB-wide (БЕЗ district-фильтра). Это происходит
|
||
когда:
|
||
- вход ``None`` или ``'не определён'`` (участок вне 8
|
||
полигонов — нечего сужать), ЛИБО
|
||
- вход — валидное админ-имя, но у него НЕТ чистых алиасов
|
||
(на практике не случается: все 8 покрыты сидом; страховка).
|
||
• ``[micro, …]`` → фильтровать ``ol.district = ANY(...)``:
|
||
- вход — одно из 8 АДМИН-имён → его чистые микро
|
||
(note IS NULL: cross-city/ambiguous исключены), ЛИБО
|
||
- вход — НЕ админ-имя (raw МИКРО, переданный внутренним
|
||
legacy-вызовом) → ``[district]`` как есть (сохраняем
|
||
прежнее поведение фильтра-по-микро).
|
||
|
||
Никогда не возвращает пустой список — вместо «0 строк гарантированно» отдаёт
|
||
``None`` (EKB-wide), чтобы прогноз не схлопывался молча в пустоту (это и есть
|
||
суть фикса).
|
||
|
||
psycopg v3: bind через CAST(:d AS text). Read-only.
|
||
"""
|
||
if district is None or district == _UNDETERMINED_DISTRICT:
|
||
return None
|
||
|
||
admin_names = _admin_names(db)
|
||
if district not in admin_names:
|
||
# Raw микро-район (legacy внутренний вызов) — фильтруем по нему как есть.
|
||
return [district]
|
||
|
||
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
|
||
# SAVEPOINT на внутреннем swallow-сайте (#2464 cluster A, RELEASE-trap): см.
|
||
# `_admin_names` — тот же общий-сессионный вызов из §22-билдеров под внешним
|
||
# SAVEPOINT. `db.begin_nested():` откатывает сбой в точке перехвата, чтобы
|
||
# внешний RELEASE не упал в aborted-tx (RELEASE запрещён в aborted).
|
||
try:
|
||
with db.begin_nested():
|
||
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
|
||
micros = [str(m) for m in rows]
|
||
except Exception:
|
||
logger.exception(
|
||
"district_resolver: micro-lookup failed (admin=%s) — falling back to EKB-wide",
|
||
district,
|
||
)
|
||
return None
|
||
|
||
if not micros:
|
||
# Админ без чистых алиасов (дыра в словаре) → EKB-wide fallback, НЕ пустой набор.
|
||
logger.warning(
|
||
"district_resolver: admin '%s' has no clean micro aliases — EKB-wide fallback",
|
||
district,
|
||
)
|
||
return None
|
||
|
||
logger.debug("district_resolver: admin '%s' → micros %s", district, micros)
|
||
return micros
|