Files
vidconf/backend/core/config.py
Max Ronzhin 6d65b620fe perf(backend): явный пул соединений БД и несколько воркеров uvicorn
Пул создавался с дефолтом SQLAlchemy (5 + 10) и под нагрузкой выгребался
за секунды. Теперь параметры заданы явно и вынесены в настройки:
DB_POOL_SIZE=10, DB_MAX_OVERFLOW=10, DB_POOL_TIMEOUT=10. Таймаут снижен с
дефолтных 30 секунд намеренно — пусть запрос падает быстро и показывает
проблему, а не висит полминуты.

Backend запускался одним процессом uvicorn: любой блокирующий вызов
останавливал и параллельные запросы, и WS-чат всех участников. Добавлен
UVICORN_WORKERS с дефолтом 2 — не по числу ядер, потому что на
четырёхъядерном сервере ядра делятся с LiveKit, а медиа важнее API.

Бюджет соединений считается на весь инстанс: каждый воркер держит свой
пул, поэтому UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW) должно
оставаться заметно ниже max_connections у Postgres.

Многопроцессность безопасна: бутстрап настроек в lifespan идемпотентен
(INSERT ... ON CONFLICT DO NOTHING), а WS-чат разносит сообщения через
Redis pub/sub и состояния в памяти процесса не держит.
2026-07-28 19:00:05 +03:00

164 lines
9.7 KiB
Python
Raw Permalink 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.
"""Конфигурация приложения, загруженная из переменных окружения / файла .env."""
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from core.plugins.config import AiLevel
class Settings(BaseSettings):
"""Центральные параметры приложения.
Значения читаются из переменных окружения (или файла `.env`).
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf"
redis_url: str = "redis://localhost:6379/0"
plugins_config_path: str = "../config/plugins.yaml"
# --- Пул соединений с БД ---
# Дефолт SQLAlchemy (5 + 10) на нагрузочном тесте 28.07.2026 выгребался
# за секунды: 226 ошибок `QueuePool limit of size 5 overflow 10 reached`
# и 37 ответов 500 на путях входа в конференцию.
#
# ⚠️ Бюджет соединений считается на ВЕСЬ инстанс, а не на процесс: каждый
# воркер uvicorn (`UVICORN_WORKERS`) держит собственный пул, плюс
# соединения нужны Celery-воркерам и alembic при миграциях. При
# `max_connections=100` у Postgres и двух воркерах 2 × (10 + 10) = 40
# оставляет запас. Поднимая значения на более крупном сервере, поднимайте
# и `max_connections` — иначе вместо понятной ошибки приложения получите
# отказ Postgres, который диагностируется куда хуже.
db_pool_size: int = 10
db_max_overflow: int = 10
# 10 секунд вместо дефолтных 30 — сознательно: пусть запрос падает быстро
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
db_pool_timeout: int = 10
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из файла `VERSION` (корень репозитория) в
# `.env` при каждой установке/обновлении — здесь только чтение готового
# значения. Отдаётся в `GET /api/health` (футер админки, Блок 4).
vidconf_version: str = "0.0.0"
# Домен не должен попадать в список special-use/reserved (RFC 6761,
# напр. `.local`/`.test`): email-validator (`EmailStr`) их отклоняет, а
# раньше это ловилось и на выходе — старый дефолт `admin@vidconf.local`
# ронял `GET /users/me` 500 `ResponseValidationError`, пока `UserOut.email`
# был `EmailStr`. `.example` (RFC 2606) email-validator пропускает.
seed_admin_email: str = "admin@vidconf.example"
seed_admin_password: str = "change-me"
# --- Auth (JWT + email-подтверждение) ---
jwt_secret: str = "dev-only-insecure-secret-change-me"
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 14
email_verification_ttl_hours: int = 24
frontend_url: str = "http://localhost:5173"
# Флаг Secure для refresh-cookie. false нужен только для dev по
# http://localhost (Safari, в отличие от Chrome, не сохраняет
# Secure-cookie без HTTPS); в проде обязательно true.
auth_cookie_secure: bool = True
# --- LiveKit ---
livekit_api_key: str = "devkey"
livekit_api_secret: str = "change-me-livekit-secret"
livekit_public_url: str = "ws://localhost:7880"
# Внутренний server-to-server URL для вызовов LiveKit RoomService (Celery
# maintenance-задача); в отличие от `livekit_public_url` не проксируется
# через nginx/TLS для браузера. LiveKit SDK сам нормализует ws:// в http://.
livekit_url: str = "ws://localhost:7880"
# --- Пайплайн транскрибации ---
# Общий volume между LiveKit Egress и celery-воркером `transcription`
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
recordings_dir: str = "/recordings"
# Таймаут запуска Track Egress. Когда egress-сервиса в деплое нет (профиль
# `transcribe` не поднят), LiveKit ждёт ответа воркера через Redis до
# собственного таймаута psrpc — на тесте 28.07.2026 это давало по 2025
# секунд на каждый вызов. Ждать столько бессмысленно: если egress жив, он
# отвечает за доли секунды.
egress_start_timeout_s: float = 3.0
# --- Email (SMTP-бэкенд) ---
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),
# переключатель бэкенда — тоже переменная окружения, а не настройка в БД
# (`instance_settings`).
email_backend: str = "console"
smtp_host: str = "localhost"
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
smtp_start_tls: bool = True
smtp_use_tls: bool = False
smtp_from: str = "VidConf <no-reply@vidconf.example>"
smtp_timeout_s: int = 30
# --- Медиа (аватары пользователей) ---
# Каталог, куда сохраняются загруженные файлы (аватары — `avatars/{user_id}.{ext}`);
# раздаётся статикой по `/media` (`main.py`, dev) либо через nginx `location /media/`
# в проде (`deploy/nginx/nginx.conf`, volume `media`). Относительный путь по
# умолчанию — рабочая директория backend (аналог `recordings_dir`, но без
# требования root для локального запуска вне Docker).
media_root: str = "media"
# --- Автодетект железа: install.sh определяет `nproc`/`free -m`/
# `nvidia-smi` и пишет в `.env`; читает `services/ai_levels.py` для детекта
# доступности уровней AI (ADR-004) без torch/nvidia-smi внутри процесса
# backend/воркеров. `None` — install.sh не запускался (dev-окружение) либо
# GPU не обнаружен (`hw_gpu_name`/`hw_vram_mb`).
hw_cpus: int | None = None
hw_ram_mb: int | None = None
hw_gpu_name: str | None = None
hw_vram_mb: int | None = None
# --- Матрица «пресет → настройки» инсталлятора: install.sh пишет эти три
# переменные в `.env` по выбранному пресету (15), lifespan backend
# передаёт их бутстрапу `instance_settings` (`services/instance_settings.py`,
# `bootstrap_overrides_from_settings`) как overrides дефолтов
# `plugins.yaml` — БЕЗ этого механизма бутстрап всегда включал чат и
# AI-модули независимо от пресета. `None` — install.sh не запускался
# (dev-окружение) либо переменная не установлена для этого пресета:
# бутстрап тогда использует дефолты `plugins.yaml` как раньше.
bootstrap_chat_enabled: bool | None = None
bootstrap_transcription_enabled: bool | None = None
bootstrap_ai_level: AiLevel | None = None
@field_validator(
"hw_cpus",
"hw_ram_mb",
"hw_gpu_name",
"hw_vram_mb",
"bootstrap_chat_enabled",
"bootstrap_transcription_enabled",
"bootstrap_ai_level",
mode="before",
)
@classmethod
def _empty_hw_string_to_none(cls, value: object) -> object:
"""Пустая строка env (`KEY=`, а не отсутствие переменной) → `None`.
`docker-compose` подставляет `env_file` дословно: `HW_VRAM_MB=` в `.env`
(пишет `install.sh` на любой машине без NVIDIA GPU, пресеты 14;
`.env.example` — все четыре `HW_*` пустыми по умолчанию) превращается в
переменную окружения со значением `""`, а не в отсутствующую переменную —
без этой нормализации pydantic не парсит `""` как `int` и роняет
`Settings()` уже на импорте модуля (`main.py`, `workers/celery_app.py`),
не давая контейнеру стартовать. Та же проблема для `BOOTSTRAP_*`
(`.env.example` — пустыми по умолчанию, install.sh заполняет по пресету).
"""
if value == "":
return None
return value
@lru_cache
def get_settings() -> Settings:
"""Вернуть кэшированный экземпляр `Settings`."""
return Settings()