redis-py 8 поставил дефолт `max_connections=100`, а у нас на этом пуле висят не только команды, но и долгоживущие pub/sub-подписки комнаты — по одной на каждого участника, пока он в конференции. Сотый участник на воркер выгребал пул, и WS-хендшейк падал уже на `hgetall` очереди рук с `MaxConnectionsError`. Второй потолок того же рода, что и пул БД, только этажом ниже. Воспроизведён локально: при 99 одновременных WS вход переставал работать; с `redis_max_connections=500` те же 120 подключений проходят без единой ошибки. Соединения создаются по мере надобности, поэтому сам по себе поднятый лимит ничего не стоит.
178 lines
11 KiB
Python
178 lines
11 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
|
||
|
||
# --- Пул соединений с Redis ---
|
||
# Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты
|
||
# (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока
|
||
# человек сидит в конференции, — и берёт его из этого же пула, что и
|
||
# обычные команды. redis-py 8 поставил дефолт `max_connections=100`
|
||
# (раньше предел был условно бесконечным), поэтому сотый участник на
|
||
# воркер выгребал пул досуха и WS падал уже на `hgetall` очереди рук —
|
||
# воспроизведено локально при 99 одновременных подключениях.
|
||
# 500 — с запасом на инстанс, рассчитанный на пару сотен участников
|
||
# на воркер; соединения создаются по мере надобности, само по себе
|
||
# значение ничего не стоит. Потолок сверху — `maxclients` у Redis
|
||
# (дефолт 10000) на ВСЕ процессы вместе, включая Celery-воркеры.
|
||
redis_max_connections: int = 500
|
||
|
||
# --- Версия инстанса (релиз 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()
|