Пул создавался с дефолтом 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 и состояния в памяти процесса не держит.
164 lines
9.7 KiB
Python
164 lines
9.7 KiB
Python
"""Конфигурация приложения, загруженная из переменных окружения / файла .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 это давало по 20–25
|
||
# секунд на каждый вызов. Ждать столько бессмысленно: если 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` по выбранному пресету (1–5), 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, пресеты 1–4;
|
||
`.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()
|