2 Commits

Author SHA1 Message Date
f39c21e7e1 release: версия 0.0.17
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:20:31 +03:00
84b7f807f7 fix(auth): проверка пароля больше не блокирует весь backend
На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
2026-08-01 23:19:52 +03:00
24 changed files with 282 additions and 35 deletions

View File

@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.16
VIDCONF_VERSION=0.0.17
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,27 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.17] — 2026-08-01
Массовый вход в систему и в конференцию перестаёт упираться в проверку пароля.
### Исправлено
- Проверка пароля больше не останавливает весь backend. Хэширование argon2 —
это десятки миллисекунд счёта, и выполнялось оно синхронно внутри
асинхронного обработчика: пока считался один пароль, процесс не обслуживал
ничего другого. На нагрузочном тесте 31.07 с примерно семью десятками
одновременных входов это дало p95 логина 7.28 секунды, p95 входа в
конференцию 7.06 секунды и 33 соединения к базе, висящих в открытой
транзакции при одном активном запросе. Страдали и посторонние запросы —
вход в конференцию отказывал, хотя пароль там не проверялся вовсе.
Теперь хэширование считается в пуле потоков.
- Параметры argon2id приведены к рекомендации OWASP (t=2, m=19 МБ, p=1)
вместо дефолтов библиотеки (t=3, m=64 МБ, p=4): 95 мс против 42 мс на
проверку. Прежнее значение `parallelism=4` вдобавок занимало все четыре
ядра сервера — те же, на которых работает медиа-сервер.
- Пароли, сохранённые со старыми параметрами, продолжают работать и
перевыпускаются автоматически при первом успешном входе.
## [0.0.16] — 2026-08-01
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.

View File

@@ -1 +1 @@
0.0.16
0.0.17

View File

@@ -217,7 +217,7 @@ async def create_user(
user = await repo.create(
email=data.email,
name_user=data.name_user,
password_hash=hash_password(data.password),
password_hash=await hash_password(data.password),
team_id=data.team_id,
)
user.email_verified = True

View File

@@ -101,11 +101,11 @@ async def change_current_user_password(
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
`docs/architecture/adr/005-password-reset-deferred.md`).
"""
if not verify_password(data.current_password, user.password_hash):
if not await verify_password(data.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
)
user.password_hash = hash_password(data.new_password)
user.password_hash = await hash_password(data.new_password)
await session.commit()

View File

@@ -1,33 +1,85 @@
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from core.config import get_settings
JWT_ALGORITHM = "HS256"
_hasher = PasswordHasher()
# Параметры argon2id по рекомендации OWASP (Password Storage Cheat Sheet):
# t=2, m=19 МБ, p=1. Раньше использовались дефолты argon2-cffi
# (t=3, m=64 МБ, p=4) — это был не выбор, а «что было в коробке».
#
# Замер на боевом сервере (4 ядра): 95 мс против 42 мс на одну проверку.
# Отдельно важен `parallelism`: при p=4 ОДНА проверка пароля занимала все
# четыре ядра, конкурируя с LiveKit за то же железо ровно в момент, когда
# люди массово заходят в конференцию.
#
# Существующие хэши не ломаются: параметры хранятся внутри самой строки хэша
# и читаются при verify. Старые хэши перевыпускаются постепенно — см.
# `needs_rehash` и его использование при успешном входе.
_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
def hash_password(password: str) -> str:
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
def _hash_password_sync(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
def _verify_password_sync(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
async def hash_password(password: str) -> str:
"""Захэшировать пароль алгоритмом argon2 для хранения в БД.
Считается в отдельном потоке — argon2 это CPU-bound работа на десятки
миллисекунд, и в event loop ей не место (см. `verify_password`).
"""
return await asyncio.to_thread(_hash_password_sync, password)
async def verify_password(password: str, password_hash: str) -> bool:
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.
Выполняется в пуле потоков, а не в event loop. Причина — нагрузочный тест
31.07.2026: синхронный вызов останавливал весь процесс на 95155 мс, и при
массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
33 соединения к БД в состоянии `idle in transaction` при одном активном
запросе и отказы на совершенно посторонних ручках — включая вход в
конференцию, где никакого пароля не проверялось.
Потоки здесь работают по-настоящему параллельно: argon2-cffi — это
C-расширение, освобождающее GIL на время вычисления.
"""
return await asyncio.to_thread(_verify_password_sync, password, password_hash)
def needs_rehash(password_hash: str) -> bool:
"""Проверить, что хэш выпущен устаревшими параметрами argon2.
Дешёвая операция: разбор строки хэша, без вычислений. Вызывается после
успешной проверки пароля — только тогда у нас на руках открытый пароль,
которым можно перевыпустить хэш.
Невалидную строку считаем требующей перевыпуска: если в базе оказался
мусор, лучше заменить его корректным хэшем, чем падать при каждом входе.
"""
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
def create_access_token(user_id: uuid.UUID, role: str) -> str:
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
settings = get_settings()

View File

@@ -24,6 +24,7 @@ from core.security import (
create_refresh_token,
decode_token,
hash_password,
needs_rehash,
verify_password,
)
from models.email_verification import EmailVerificationToken
@@ -131,7 +132,7 @@ class AuthService:
user = await self._users.create(
email=email,
name_user=name_user,
password_hash=hash_password(password),
password_hash=await hash_password(password),
team_id=team_id,
)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
@@ -161,10 +162,21 @@ class AuthService:
async def login(self, *, email: str, password: str) -> TokenPair:
"""Проверить учётные данные и выдать пару access/refresh токенов."""
user = await self._users.get_by_email(email)
if user is None or not verify_password(password, user.password_hash):
if user is None or not await verify_password(password, user.password_hash):
raise InvalidCredentialsError
if not user.email_verified:
raise EmailNotVerifiedError
# Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
# параметры зашиты в саму строку хэша, поэтому старые записи так и
# проверялись бы вдвое дольше. Открытый пароль есть только здесь и
# только сейчас — другого места для перевыпуска не будет.
if needs_rehash(user.password_hash):
user.password_hash = await hash_password(password)
# Явный commit: выдача токенов идёт через Redis и БД не трогает,
# поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
await self._session.commit()
return await self._issue_token_pair(user.id, user.role)
async def refresh(self, refresh_token: str) -> TokenPair:

View File

@@ -27,7 +27,7 @@ class InvalidPasswordError(Exception):
"""Указанный пароль не совпадает с паролем закрытой конференции."""
def ensure_joinable(conference: Conference, *, password: str | None) -> None:
async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
"""Проверить, что в конференцию можно войти прямо сейчас.
Бросает `ConferenceEndedError` для терминального статуса `ended`
@@ -42,7 +42,7 @@ def ensure_joinable(conference: Conference, *, password: str | None) -> None:
return
if conference.password_hash is None or password is None:
raise PasswordRequiredError
if not verify_password(password, conference.password_hash):
if not await verify_password(password, conference.password_hash):
raise InvalidPasswordError

View File

@@ -101,7 +101,7 @@ class ConferenceService:
повторением без явного `scheduled_at` — плановая конференция,
ожидающая своего первого вхождения, а не мгновенный вход.
"""
password_hash = hash_password(data.password) if data.password else None
password_hash = await hash_password(data.password) if data.password else None
is_instant = data.scheduled_at is None and data.recurrence is None
conference_status = "active" if is_instant else "scheduled"
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
@@ -242,7 +242,7 @@ class ConferenceService:
) -> JoinOut:
"""Войти в конференцию зарегистрированным пользователем."""
conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=password)
await ensure_joinable(conference, password=password)
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
return build_join(
conference,
@@ -256,7 +256,7 @@ class ConferenceService:
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=data.password)
await ensure_joinable(conference, password=data.password)
guest = GuestAccess(
conference_id=conference.id, display_name=data.display_name, email=data.email
@@ -330,7 +330,7 @@ class ConferenceService:
if data.is_closed is not None:
conference.is_closed = data.is_closed
if data.password is not None:
conference.password_hash = hash_password(data.password)
conference.password_hash = await hash_password(data.password)
if "summary_recipients" in data.model_fields_set:
# Явная передача (в т.ч. `null`) — сбросить/установить
# переопределение; отсутствие поля в запросе значение не трогает.

View File

@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Admin API Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)

View File

@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Team API Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)

View File

@@ -9,6 +9,7 @@ from typing import Annotated
import httpx
import pytest_asyncio
from argon2 import PasswordHasher
from fastapi import Depends, FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.auth import get_auth_service
from core.db import get_session
from core.redis import redis_client
from core.security import needs_rehash
from models.team import Team
from models.user import User
from services.auth import AuthService
@@ -491,3 +493,35 @@ async def test_register_no_reply_to_when_contact_email_disabled(
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None
async def test_login_rehashes_legacy_password(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Вход с паролем, захэшированным старыми параметрами, перевыпускает хэш.
Параметры argon2 зашиты в саму строку хэша, поэтому смена настроек
(0.0.17: дефолты библиотеки → рекомендации OWASP) сама по себе не ускоряет
проверку уже существующих паролей. Миграция идёт лениво — при первом
успешном входе, когда открытый пароль есть на руках.
"""
email = "legacy-hash@example.com"
password = "supersecret1"
await _register_and_verify(client, email_backend, email=email, password=password)
# Подменяем хэш на выданный прежними параметрами (t=3, m=64 МБ, p=4).
legacy_hash = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4).hash(password)
user = await db_session.scalar(select(User).where(User.email == email))
assert user is not None
user.password_hash = legacy_hash
await db_session.commit()
response = await client.post(
"/api/v1/auth/token", data={"username": email, "password": password}
)
assert response.status_code == 200, response.text
await db_session.refresh(user)
assert user.password_hash != legacy_hash, "старый хэш не был перевыпущен"
assert "m=19456" in user.password_hash
assert needs_rehash(user.password_hash) is False

View File

@@ -44,7 +44,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> Use
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)

View File

@@ -19,7 +19,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Invitee Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)

View File

@@ -25,7 +25,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Service Tester") ->
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)

View File

@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Conference Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)
@@ -67,7 +67,7 @@ async def _make_conference(
status=status,
is_pinned=is_pinned,
is_closed=is_closed,
password_hash=hash_password(password) if password else None,
password_hash=await hash_password(password) if password else None,
ended_at=ended_at,
scheduled_at=scheduled_at,
duration_minutes=duration_minutes,

View File

@@ -28,7 +28,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> Use
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)

View File

@@ -131,7 +131,7 @@ async def _make_user(session: AsyncSession, email: str) -> User:
user = User(
email=email,
name_user="Participant",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)

View File

@@ -53,7 +53,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Mute Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)

View File

@@ -0,0 +1,128 @@
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
Синхронный `verify_password` останавливал весь процесс backend на 95155 мс.
При массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
33 соединения к БД в состоянии `idle in transaction` при одном активном запросе
и отказы на посторонних ручках — включая вход в конференцию, где пароль вообще
не проверялся. Разбор — `.forcc/LOGIN-BOTTLENECK.md`.
Тесты ниже проверяют не скорость (она зависит от железа), а **свойства**:
event loop остаётся живым, проверки идут параллельно, старые хэши мигрируют.
"""
import asyncio
import time
from argon2 import PasswordHasher
from core.security import hash_password, needs_rehash, verify_password
PASSWORD = "correct-horse-battery-staple"
async def test_verify_password_does_not_block_event_loop() -> None:
"""Пока считается argon2, event loop продолжает обслуживать другие задачи.
Это главное свойство правки. Фоновая корутина тикает каждую миллисекунду;
если проверка пароля выполняется синхронно в loop, тиков за её время будет
ноль или единицы — именно так и вело себя приложение до исправления.
"""
password_hash = await hash_password(PASSWORD)
ticks = 0
stop = False
async def ticker() -> None:
nonlocal ticks
while not stop:
ticks += 1
await asyncio.sleep(0.001)
ticker_task = asyncio.create_task(ticker())
await asyncio.sleep(0.005) # даём тикеру стартовать
ticks_before = ticks
assert await verify_password(PASSWORD, password_hash) is True
ticks_during = ticks - ticks_before
stop = True
await ticker_task
# Даже на быстром железе argon2 занимает десятки миллисекунд — за это время
# loop обязан прокрутить заметное число тиков. Порог намеренно щадящий:
# при блокировке тиков будет 01, а не «мало».
assert ticks_during >= 5, (
f"event loop простоял во время проверки пароля: {ticks_during} тиков — "
"похоже, argon2 снова считается синхронно"
)
async def test_parallel_verifications_are_concurrent() -> None:
"""Параллельные проверки идут одновременно, а не выстраиваются в очередь.
argon2-cffi — C-расширение и освобождает GIL, поэтому пул потоков даёт
настоящий параллелизм. Проверяем, что 8 проверок занимают заметно меньше,
чем 8 последовательных: иначе массовый вход снова упрётся в сериализацию.
"""
password_hash = await hash_password(PASSWORD)
start = time.perf_counter()
await verify_password(PASSWORD, password_hash)
single = time.perf_counter() - start
start = time.perf_counter()
results = await asyncio.gather(*(verify_password(PASSWORD, password_hash) for _ in range(8)))
parallel = time.perf_counter() - start
assert all(results)
# На 4-ядерном сервере 8 проверок идеально легли бы в 2×single; берём 5×
# с большим запасом на шум CI и разное железо — важно лишь то, что это
# НЕ 8× (последовательное выполнение).
assert parallel < single * 5, (
f"8 параллельных проверок заняли {parallel:.3f} с при {single:.3f} с на одну — "
"похоже, они выполняются последовательно"
)
async def test_wrong_password_is_rejected() -> None:
"""Асинхронная обёртка не сломала саму проверку."""
password_hash = await hash_password(PASSWORD)
assert await verify_password(PASSWORD, password_hash) is True
assert await verify_password("wrong-password", password_hash) is False
async def test_hasher_uses_owasp_parameters() -> None:
"""Параметры argon2id — по рекомендации OWASP, а не дефолт библиотеки.
Дефолт argon2-cffi (t=3, m=64 МБ, p=4) стоил 95 мс на проверку, причём
`parallelism=4` занимал все четыре ядра сервера — те же, на которых
работает LiveKit.
"""
password_hash = await hash_password(PASSWORD)
# Параметры зашиты в саму строку хэша: $argon2id$v=19$m=19456,t=2,p=1$...
assert "m=19456" in password_hash
assert "t=2" in password_hash
assert "p=1" in password_hash
async def test_legacy_hash_is_verified_and_marked_for_rehash() -> None:
"""Хэш со старыми параметрами проверяется, но помечается на перевыпуск.
Гарантия обратной совместимости: пароли, выданные до смены параметров,
продолжают работать. `AuthService.login` перевыпускает такой хэш при
первом же успешном входе — другого момента, когда открытый пароль есть
на руках, не будет.
"""
legacy_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
legacy_hash = legacy_hasher.hash(PASSWORD)
assert await verify_password(PASSWORD, legacy_hash) is True
assert needs_rehash(legacy_hash) is True
fresh_hash = await hash_password(PASSWORD)
assert needs_rehash(fresh_hash) is False
async def test_broken_hash_is_marked_for_rehash() -> None:
"""Мусор вместо хэша не роняет вход, а помечается на замену."""
assert needs_rehash("not-a-valid-argon2-hash") is True

View File

@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Test User",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
role=role,
email_verified=True,
)

View File

@@ -18,7 +18,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Teams API Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)

View File

@@ -24,7 +24,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="List Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)
@@ -98,7 +98,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500(
user = User(
email=legacy_email,
name_user="Legacy Admin",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
role="admin",
)
@@ -380,7 +380,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
match = User(
email=f"{unique_marker}@example.com",
name_user=f"Findable {unique_marker}",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
db_session.add(match)

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.16}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.17}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул