All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Правки по deep-ревью PR #3508. 1. HIGH. `app/core/auth_db.py` строил второй движок БЕЗ `connect_args`, а моё обоснование пропуска было ложным: на проде `IDENTITY_STORE=auth` во всех трёх сервисах образа и `AUTH_DB_PASSWORD` задан (сверено `printenv` в контейнерах), то есть реестр живой. Путь горячий: `core/rbac.py` резолвит session-cookie в middleware, синхронно на event loop'е, на каждом запросе с cookie — значит `ACCESS EXCLUSIVE` на `auth.sessions` вешал бы не четыре слота `/estimate`, а весь uvicorn-воркер (он один), включая `/health`. Потолок — та же константа `DB_CONNECT_ARGS`: одна на оба движка, а не защита на одном и мина на втором. Срабатывание безопасно — вызов уже под `except Exception` с фолбэком. 2. MEDIUM. Испорченный `options` (`statement_timeout=30000zz`) проходил ЗЕЛЁНЫМ: статическая проверка искала ПОДСТРОКУ (а `…=30000` — префикс испорченного), а живая глушила отказ коннекта голым `except` → skip → запись в allowlist. На проде это `FATAL: invalid value for parameter` на КАЖДОМ коннекте, то есть полный отказ продукта при зелёном сьюте. Теперь: сравнение `options` на РАВЕНСТВО, и `_live_engine` сначала пробует коннект БЕЗ `connect_args` — сервера нет это пропуск, а «сервер есть, наши options он не принял» это падение. 3. LOW. У проверки согласованности был пол и не было крыши: `300_000` (пять минут) зеленел. Добавлена симметричная граница `<= 2 ×` самого длинного объявленного бюджета. 4. LOW. Три факта в комментариях исправлены: * `pg_stat_statements` НЕ опора по планировщику — вытесняет записи с calls=1 (`dealloc` вырос за десять минут, из топа пропал `REFRESH MATERIALIZED VIEW` 30.85 с). Основная опора — `scrape_runs`; * самый длинный set-based statement через движок — матч ГАР→houses: 2.07 с с городским фильтром и 6.46 с без. Запас ~3×, а не 7×; * `idle in transaction` 29 с — это tgbot (`services/tgbot/bridge.py`, транзакция поверх long-poll Telegram; сверено 3 пробами: одна и та же сессия, `SELECT value FROM tg_support_state …`), а не свипы. Решение не ставить потолок на простой от этого только крепче. Мутационная проверка (обе лэйны краснеют на каждой): испорченный `options`, `_STATEMENT_TIMEOUT_MS = 300_000`, снятый `connect_args`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
11 KiB
Python
186 lines
11 KiB
Python
"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»).
|
||
|
||
Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине:
|
||
`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в
|
||
теле модуля). Сделай мы так же для БД `auth` — приложение начало бы падать на
|
||
старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас
|
||
ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе.
|
||
Здесь engine создаётся ЛЕНИВО, при первом реальном обращении.
|
||
|
||
Контракт (⚠️ после мержа прод обязан работать ТОЧНО как сейчас):
|
||
|
||
* `settings.identity_store == "tradein"` (дефолт) — в этот модуль не заходит
|
||
никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустая
|
||
конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни
|
||
одно соединение с БД `auth` не открывается.
|
||
* `settings.identity_store == "auth"` + не сконфигурированный реестр — первое
|
||
же обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом.
|
||
Именно исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат:
|
||
молчаливая деградация auth-пути означала бы «пользователь не найден» вместо
|
||
«конфигурация сломана», то есть массовый отказ входа под видом неверных
|
||
паролей — либо, в обратную сторону, анонимный доступ.
|
||
|
||
Сам DSN этот модуль НЕ выбирает и НЕ склеивает — берёт готовый у
|
||
`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из
|
||
`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто).
|
||
|
||
⚠️ В DSN — пароль роли `auth_app`. Он не логируется и не попадает в текст
|
||
исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже — константы, а ошибку
|
||
разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и
|
||
заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в
|
||
traceback. Добавляешь сюда `logger`/`raise ... {dsn}` — не добавляй.
|
||
|
||
`create_engine` сам по себе к серверу не ходит (connection pool ленивый), так что
|
||
даже после первого обращения реальный коннект открывается только на первом
|
||
запросе — но ошибку конфигурации мы обязаны отдать раньше, чем это станет
|
||
похоже на сетевую проблему.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
|
||
from sqlalchemy import Engine, create_engine
|
||
from sqlalchemy.exc import ArgumentError
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
|
||
from app.core.config import settings
|
||
from app.core.db import DB_CONNECT_ARGS
|
||
|
||
|
||
class AuthDatabaseNotConfiguredError(RuntimeError):
|
||
"""`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался."""
|
||
|
||
|
||
_NOT_CONFIGURED_MSG = (
|
||
"IDENTITY_STORE=auth, но реестр людей (БД `auth`) не сконфигурирован: пусты и "
|
||
"AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — подключаться не к чему. Задай в "
|
||
".env.runtime AUTH_DB_PASSWORD (пароль роли auth_app; остальные части DSN — "
|
||
"AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER — имеют прод-дефолты), "
|
||
"либо целиком AUTH_DATABASE_URL, либо верни IDENTITY_STORE=tradein (старое "
|
||
"поведение на tradein_users/tradein_sessions)."
|
||
)
|
||
|
||
# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную
|
||
# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`.
|
||
_MALFORMED_DSN_MSG = (
|
||
"DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан "
|
||
"явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема "
|
||
"обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ "
|
||
"подставлен: в нём пароль роли auth_app."
|
||
)
|
||
|
||
# Кеш engine/factory + защита от гонки: rbac_guard резолвит сессию на каждом
|
||
# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты
|
||
# уходят в threadpool). Без лока два одновременных первых запроса создали бы два
|
||
# engine — то есть два независимых пула коннектов, один из которых потеряется.
|
||
_LOCK = threading.Lock()
|
||
_engine: Engine | None = None
|
||
_session_factory: sessionmaker[Session] | None = None
|
||
|
||
|
||
def _build() -> tuple[Engine, sessionmaker[Session]]:
|
||
"""Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка.
|
||
|
||
DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*) —
|
||
здесь только «пусто или нет» и создание engine.
|
||
"""
|
||
dsn = settings.resolved_auth_database_url
|
||
if not dsn:
|
||
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
|
||
try:
|
||
engine = create_engine(
|
||
dsn,
|
||
pool_pre_ping=True,
|
||
future=True,
|
||
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
|
||
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
|
||
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
||
# сайты вызова разом, включая будущие.
|
||
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
||
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
||
hide_parameters=True,
|
||
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
|
||
# потолок на одном движке и мина на втором это не починка, а половина.
|
||
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
|
||
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
|
||
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
|
||
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
|
||
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
|
||
# --workers) — включая `/health`.
|
||
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
|
||
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
|
||
# же путь, что и любой другой сбой реестра, а не 500.
|
||
connect_args=DB_CONNECT_ARGS,
|
||
)
|
||
except (ArgumentError, ValueError):
|
||
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
|
||
# `int(port)` и падает с `invalid literal for int() with base 10: 'w'`,
|
||
# где 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None`
|
||
# обязателен: он гасит цепочку, иначе исходная ошибка (а с ней и этот
|
||
# кусок) печатается в traceback как «During handling of...».
|
||
raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None
|
||
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
||
return engine, factory
|
||
|
||
|
||
def _ensure_built() -> tuple[Engine, sessionmaker[Session]]:
|
||
global _engine, _session_factory
|
||
if _engine is not None and _session_factory is not None:
|
||
return _engine, _session_factory
|
||
with _LOCK:
|
||
if _engine is None or _session_factory is None:
|
||
_engine, _session_factory = _build()
|
||
return _engine, _session_factory
|
||
|
||
|
||
def get_auth_engine() -> Engine:
|
||
"""Engine БД `auth` (создаётся при первом вызове).
|
||
|
||
Raises:
|
||
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
|
||
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
|
||
"""
|
||
engine, _ = _ensure_built()
|
||
return engine
|
||
|
||
|
||
def get_auth_session_factory() -> sessionmaker[Session]:
|
||
"""Session-factory БД `auth` (создаётся при первом вызове).
|
||
|
||
Raises:
|
||
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
|
||
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
|
||
"""
|
||
_, factory = _ensure_built()
|
||
return factory
|
||
|
||
|
||
@contextmanager
|
||
def auth_session() -> Iterator[Session]:
|
||
"""Сессия к БД `auth`, закрывается на выходе из блока.
|
||
|
||
Прямой вызов из роутов/сервисов НЕ предполагается — ходи через
|
||
`app.services.identity_store.identity_session()`, он один знает, какая БД
|
||
сейчас является реестром.
|
||
"""
|
||
factory = get_auth_session_factory()
|
||
with factory() as db:
|
||
yield db
|
||
|
||
|
||
def reset_auth_db() -> None:
|
||
"""Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты).
|
||
|
||
Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а
|
||
держать в это время лок незачем — ссылки на него уже сняты.
|
||
"""
|
||
global _engine, _session_factory
|
||
with _LOCK:
|
||
stale = _engine
|
||
_engine = None
|
||
_session_factory = None
|
||
if stale is not None:
|
||
stale.dispose()
|