11 Commits

Author SHA1 Message Date
270926cc96 release: версия 0.0.14
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-29 00:17:19 +03:00
e018837a1d fix(livekit): анонсировать клиентам внешний TURN — relay не работал совсем
coturn поднимался, был healthy и слушал 3478 — но клиенты о нём никогда не
узнавали: встроенный TURN выключен (`turn.enabled: false`), внешний в
конфигурации не объявлен, фронтенд `iceServers` не задаёт. За всё время
работы сервера в логах coturn нет ни одной аллокации.

Следствие: у участников из сетей, где прямое UDP-соединение не проходит,
не было relay-фолбэка вообще — только прямой UDP и TCP 7881. На
нагрузочном тесте 28.07 все разрывы `PEER_CONNECTION_DISCONNECTED`
пришлись на внешних участников и ни одного — на офисных.

Добавлена секция `rtc.turn_servers` (UDP и TCP на 3478). Эти серверы
только анонсируются клиенту в списке ICE — сам SFU через них не ходит
(см. iceServersForParticipant в LiveKit). Credentials генерируются по
механизму TURN REST API из общего `TURN_STATIC_AUTH_SECRET`, поэтому
`render-templates.sh` теперь подставляет его и `TURN_EXTERNAL_IP` также в
конфигурацию LiveKit.

TLS (5349/443) намеренно не анонсируется: сертификаты в coturn не
смонтированы, а неработающий `turns:` заставил бы клиента ждать таймаута
перед переходом к рабочему кандидату. Что нужно для его включения —
описано в разделе 8 руководства.

Там же исправлено умолчание в правилах ufw: помимо 3478 нужен диапазон
relay-аллокаций `49160:49200/udp`. Без него TURN отвечает на запросы, но
релей не работает, причём в логах coturn при этом тишина.
2026-07-29 00:17:19 +03:00
705f160912 release: версия 0.0.13
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 23:26:41 +03:00
7a5e9d2d8a perf(deploy): не пересобирать окружение uv в рантайме контейнеров
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Образ собран с `uv sync --frozen --no-dev`, но `uv run` перед каждым запуском
заново синхронизирует venv и подтягивает dev-группу. В логах старта
vidconf-backend-1 и vidconf-worker-1 на проде это видно как «Downloading ruff /
mypy / pygments» и «Installed 12 packages». Хуже всего healthcheck'и: они
выполняют ту же синхронизацию каждые 15 секунд всю жизнь контейнера.

Проверено на локально собранном образе, одна и та же команда:

  uv run             — качает 12 пакетов, venv 456 → 574 МБ
  uv run --no-sync   — не качает ничего, venv остаётся 456 МБ

Флаг добавлен во все вызовы в прод-путях: CMD образа, command/entrypoint/
healthcheck всех сервисов compose, миграции и seed в install.sh, те же команды
в docs/deploy. Локальная разработка (dev-setup.md, backend/README.md, CI) не
затронута — там dev-зависимости нужны. Заодно убрано устаревшее объяснение
ретрая `up -d --wait`: первый старт больше не синхронизирует окружение.

Версия uv в образе — 0.11.33, `--no-sync` поддерживается.
2026-07-28 23:25:56 +03:00
eb4e5ea83f perf(frontend): включить adaptiveStream и dynacast
Оба флага в LiveKit по умолчанию выключены: каждый клиент был подписан на
полное качество всех чужих треков независимо от размера плитки, а каждый
паблишер слал все слои симулкаста, даже если их никто не смотрит. На тесте
28.07 (19 участников, ~8 камер) это дало устойчивые 140–169 Мбит/с исходящего
трафика при пике 240, 662 события `remote bwe: channel congestion detected` и
146 переходов аллокатора STABLE → DEFICIENT — то есть видимый участникам лаг.

Замер на локальном стенде (9 участников, паблишеры 720p, одинаковый состав
комнаты, приращение bytesReceived по getStats клиента):

  без флагов   5 потоков 1280x720 @33 fps  — 11 110 кбит/с
  с флагами    4 потока   320x150 @17 fps  —    778 кбит/с

Заодно начинает экономить уже написанный код, который до сих пор не давал
выигрыша: «скрыть остальных» не рендерит карусель (TX контейнера LiveKit
0.76 → 0.03 Мбит/с), пагинация StageGrid рендерит только текущую страницу,
а pauseVideoInBackground (дефолт true) работает лишь при adaptiveStream.

Демонстрация экрана, режимы показа и листание страниц проверены — регрессий нет.
2026-07-28 23:25:43 +03:00
b528785249 docs(deploy): правило ufw для скрейпа node-exporter из docker-сети
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
node-exporter переведён в host-сеть (иначе отдавал сетевые метрики
контейнера вместо серверных), и порт 9100 теперь слушается на хосте —
а значит Prometheus из docker-сети упирается в политику ufw по умолчанию.
Без этого правила таргет `node` остаётся down с `context deadline exceeded`.

Правило узкое: только из внутренних docker-подсетей (172.16.0.0/12 не
маршрутизируется в интернете) и только на 9100. Снаружи порт закрыт.

Инсталлятор firewall не настраивает — это ручной шаг документации,
поэтому строка добавлена именно сюда, иначе на новой инсталляции
мониторинг хоста молча останется без сетевых метрик.
2026-07-28 19:25:33 +03:00
a53ba7c827 fix(monitoring): node-exporter отдавал сетевые метрики контейнера вместо хоста
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
`node_network_*` показывал трафик собственного `eth0` экспортера (56 МБ)
вместо хостового `enp3s0` (39.8 ГБ). При разборе нагрузочного теста 28.07
сетевых метрик хоста не оказалось вовсе — весь анализ трафика пришлось
вести по метрикам контейнеров.

Причина не в конфигурации экспортера, а в устройстве procfs: bind-mount
`/proc` хоста достаточен для CPU, памяти и диска, но `/proc/net` — это
симлинк на `self/net`, который резолвится в сетевом namespace читающего
процесса. Никакое монтирование это не обходит, нужен host network
namespace. Прежний комментарий в compose утверждал обратное — исправлен.

Порт 9100 теперь слушается на хосте, наружу не торчит: ufw пропускает
только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit. Prometheus
обращается к экспортеру через `host.docker.internal` (`extra_hosts:
host-gateway`), потому что по имени сервиса в docker-сети он больше не
резолвится.

Дашборд `host.json` правок не требует: сетевые панели фильтруют
интерфейсы по исключению (`device!~"lo|veth.*|docker.*|br-.*"`), под
которое `enp3s0` не подпадает. Алерты на имя instance не завязаны.
2026-07-28 19:22:36 +03:00
71f150d1b6 feat(monitoring): собирать метрики LiveKit в Prometheus
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В `livekit.yaml` порт метрик (6789) объявлен с самого начала, но job'а в
Prometheus не было — метрики SFU просто не собирались. Из-за этого разбор
нагрузочного теста 28.07.2026 пришлось вести по логам: `container-exporter`
показывает CPU, память и суммарный трафик контейнера, но не знает, что
внутри этого трафика.

Теперь доступны, в частности:
- `livekit_track_subscribed_total` / `livekit_track_published_total` —
  подписки против публикаций, то есть прямой эффект adaptiveStream;
- `livekit_participant_total`, `livekit_room_total` — нагрузка в участниках;
- `livekit_quality_score`, `livekit_packet_loss_percent`, `livekit_rtt_ms`,
  `livekit_jitter_us`, `livekit_nack_total`, `livekit_pli_total` — качество
  связи у клиентов вместо догадок по событиям congestion в логах;
- `livekit_webhook_queue_length` — очередь доставки вебхуков в backend.

Job включён, а не закомментирован, как `llm`: профиль `media` входит в
дефолтный набор COMPOSE_PROFILES. Конфиг проверен `promtool check config`.
2026-07-28 19:15:38 +03:00
7549b53ec9 release: версия 0.0.12
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 19:00:09 +03:00
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
32949ebc66 fix(webhook): запуск egress не блокирует транзакцию track_published
Обработчик `track_published` вызывал `start_track_egress` внутри своей
транзакции. На инстансе без профиля `transcribe` egress-сервиса нет, и
LiveKit ждал ответа воркера через Redis до собственного таймаута psrpc —
20–25 секунд на каждый микрофонный трек. Всё это время webhook удерживал
соединение с БД и открытую транзакцию.

На нагрузочном тесте с 19 участниками (28.07.2026) это дало 226 ошибок
`QueuePool limit of size 5 overflow 10 reached` и 37 ответов 500 на путях
входа в конференцию, а со стороны LiveKit — 33 дропнутых webhook при
очереди доставки до 56 секунд.

Что изменилось:
- запуск ушёл в фоновую задачу `run_track_egress` со своей сессией БД;
  обработчик только планирует её и отвечает 200 сразу;
- добавлен ранний выход по `transcriber.enabled` — симметрично guard'у,
  который уже был в `room_finished`;
- запуск ограничен таймаутом `egress_start_timeout_s` (по умолчанию 3 с).

Идемпотентность сохранена: проверка «трек уже пишется» осталась в
обработчике, а `AudioTrackRepository.create` — это INSERT ... ON CONFLICT
DO NOTHING.

Попутно: `test_room_finished_enqueues_pipeline` падал в зависимости от
того, что осталось в локальной БД, — теперь выставляет `transcriber`
явно, как и остальные тесты этой группы.
2026-07-28 18:59:53 +03:00
19 changed files with 715 additions and 103 deletions

View File

@@ -81,6 +81,20 @@ LIVEKIT_NODE_IP=127.0.0.1
# с точкой монтирования тома в обоих сервисах.
RECORDINGS_DIR=/recordings
# --- Производительность backend ---
# Число процессов uvicorn. Один процесс означает, что любой блокирующий вызов
# в обработчике останавливает весь event loop: параллельные входы в конференцию
# и WS-чат всех участников встают в очередь. Дефолт 2 рассчитан на 4-ядерный
# сервер, где ядра делятся с LiveKit (медиа важнее API).
UVICORN_WORKERS=2
# Пул соединений с БД НА КАЖДЫЙ воркер. Общий расход инстанса —
# UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW), плюс соединения Celery
# и alembic. Держите сумму заметно ниже max_connections у Postgres (по
# умолчанию 100), иначе вместо понятной ошибки приложения получите отказ БД.
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=10
# --- Email (рассылка саммари + .ics-приглашения) ---
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
# email берётся из логов); `smtp` — реальная отправка через aiosmtplib.
@@ -98,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.11
VIDCONF_VERSION=0.0.14
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,92 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.14] — 2026-07-28
TURN-фолбэк для участников из сетей с жёстким NAT.
### Исправлено
- LiveKit теперь анонсирует клиентам внешний TURN-сервер (`rtc.turn_servers`
в конфигурации, UDP и TCP на 3478). Раньше coturn поднимался и был healthy,
но клиенты о нём не знали: внешний TURN в конфиге объявлен не был, встроенный
выключен, а фронтенд `iceServers` не задаёт. За всё время работы в логах
coturn не было ни одной аллокации — то есть relay не использовался никогда,
и участники из сетей, где прямое UDP-соединение не проходит, теряли связь
(`PEER_CONNECTION_DISCONNECTED`). На нагрузочном тесте 28.07 все такие
разрывы пришлись на внешних участников и ни одного — на офисных.
- Credentials TURN генерируются по механизму TURN REST API из общего
`TURN_STATIC_AUTH_SECRET`, то есть тот же секрет, что и у coturn.
### Изменено
- `deploy/render-templates.sh` подставляет `TURN_EXTERNAL_IP` и
`TURN_STATIC_AUTH_SECRET` также в конфигурацию LiveKit.
- Раздел 8 руководства по развёртыванию переписан: TURN больше не «план на
будущее», а рабочая конфигурация. Отдельно описано правило ufw для
relay-диапазона `49160:49200/udp` — без него TURN отвечает на запросы, но
релей не работает, причём в логах coturn при этом тишина.
TURN over TLS (5349/443) по-прежнему не настроен: требует монтирования
сертификата в coturn. Неработающий `turns:` намеренно не анонсируется, иначе
клиент ждал бы таймаута перед переходом к рабочему кандидату.
## [0.0.13] — 2026-07-28
Снижение нагрузки на сеть: клиент перестаёт получать полное качество всех
чужих камер независимо от того, какого размера плитка на экране.
### Изменено
- Включены `adaptiveStream` и `dynacast` в опциях комнаты. Оба флага в LiveKit
выключены по умолчанию, из-за чего каждый участник был подписан на полное
качество всех чужих треков, а каждый паблишер слал все слои симулкаста, даже
когда их никто не смотрит. Теперь качество подписки выбирается по фактическому
размеру плитки, а неотрисованные треки уходят в паузу. На локальном стенде
(9 участников, паблишеры 720p) входящий поток одного клиента упал с
11 110 до 778 кбит/с.
Вместе с этим начинает экономить уже написанный код, который до сих пор не
давал выигрыша: «скрыть остальных» не рендерит карусель (исходящий трафик
LiveKit 0.76 → 0.03 Мбит/с), пагинация сетки участников рендерит только
текущую страницу, а пауза чужого видео в свёрнутой вкладке работает лишь
при включённом `adaptiveStream`.
- Контейнеры больше не пересобирают окружение Python при запуске: во все
вызовы `uv run` в прод-путях (CMD образа, `command`/`entrypoint`/`healthcheck`
сервисов, миграции и seed в `install.sh`) добавлен `--no-sync`. Раньше
окружение, собранное на этапе build с `--no-dev`, при каждом старте
синхронизировалось заново и подтягивало dev-группу: ~26 МБ загрузок,
замедленный старт и ruff/mypy/pytest в рантайме. Healthcheck'и делали то же
самое каждые 15 секунд всю жизнь контейнера. Размер `/app/.venv` после
запуска: 574 → 456 МБ. Локальная разработка не затронута — там dev-группа
нужна и ставится как раньше.
## [0.0.12] — 2026-07-28
Разблокировка backend под нагрузкой: вход в конференцию перестаёт отваливаться,
когда участники включают микрофоны.
### Исправлено
- Обработчик webhook `track_published` больше не запускает Track Egress внутри
своей транзакции. Раньше каждый опубликованный микрофон уходил в сетевой
вызов, а на инстансе без профиля `transcribe` (egress-сервиса в деплое нет)
этот вызов висел 2025 секунд, всё это время удерживая соединение с БД. На
нагрузочном тесте с 19 участниками пул соединений выгребался за секунды, и
вход в конференцию начинал отвечать 500. Теперь запуск уходит в фоновую
задачу со своей сессией, а webhook отвечает сразу — LiveKit перестаёт копить
очередь доставки и терять события.
- Track Egress не запускается вовсе, если транскрибация выключена в настройках
инстанса — тот же guard, что уже был в обработчике `room_finished`.
- Запуск Track Egress ограничен таймаутом (по умолчанию 3 секунды) вместо
ожидания собственного таймаута LiveKit.
### Изменено
- Пул соединений с БД задаётся явно (`DB_POOL_SIZE`, `DB_MAX_OVERFLOW`,
`DB_POOL_TIMEOUT`) вместо дефолта SQLAlchemy 5 + 10. Считайте бюджет на весь
инстанс: каждый воркер держит свой пул, и сумма должна оставаться заметно
ниже `max_connections` у Postgres.
- Backend запускается с несколькими процессами uvicorn (`UVICORN_WORKERS`,
по умолчанию 2). Одиночный процесс означал, что любой блокирующий вызов
останавливает и параллельные запросы, и WS-чат всех участников. Дефолт 2, а
не по числу ядер: на четырёхъядерном сервере ядра делятся с LiveKit.
## [0.0.11] — 2026-07-28
Выбор режима показа участников в конференции, скрытие остальных и круглая

View File

@@ -1 +1 @@
0.0.11
0.0.14

View File

@@ -37,4 +37,20 @@ EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
CMD ["uv", "run", "uvicorn", "main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
# Число воркеров — из окружения (`UVICORN_WORKERS`, см. docker-compose.yml).
# Один процесс означает, что любой блокирующий вызов в обработчике
# останавливает весь event loop: параллельные запросы и WS-чат всех
# участников встают в очередь. Дефолт 2, а не «по числу ядер»: медиа
# важнее API, и на 4-ядерном сервере LiveKit в пике забирает 1.6 ядра.
#
# `sh -c` нужен ради подстановки переменной (exec-форма её не делает),
# `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM.
#
# `--no-sync`: окружение уже собрано выше (`uv sync --frozen --no-dev`), и
# пересобирать его в рантайме незачем. Без флага `uv run` перед каждым
# запуском заново синхронизирует venv И ПОДТЯГИВАЕТ dev-группу (ruff, mypy,
# pytest — ~30 МБ загрузок на каждый старт контейнера, dev-инструменты в
# проде и раздутый venv). То же касается healthcheck'ов в
# deploy/docker-compose.yml, которые дёргают `uv run` каждые 15 секунд.
CMD ["sh", "-c", \
"exec uv run --no-sync uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"]

View File

@@ -3,7 +3,7 @@
import logging
from typing import Annotated, Any, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
from livekit import api
from sqlalchemy import CursorResult
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/api/v1/livekit", tags=["livekit"])
@router.post("/webhook")
async def receive_webhook(
request: Request,
background: BackgroundTasks,
session: Annotated[AsyncSession, Depends(get_session)],
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, str]:
@@ -30,6 +31,11 @@ async def receive_webhook(
Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в
`livekit_webhook_events` в одной транзакции с эффектами обработчика —
при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200.
Всё, что требует сети (запуск Track Egress), уходит в `background` и
выполняется уже после ответа: обработчик держит соединение с БД и
открытую транзакцию, а LiveKit при медленном ответе копит очередь
доставки и в итоге дропает события (см. `services.egress.run_track_egress`).
"""
settings = get_settings()
raw_body = await request.body()
@@ -57,7 +63,7 @@ async def receive_webhook(
await session.commit()
return {"status": "duplicate"}
dispatcher = WebhookDispatcher(session)
dispatcher = WebhookDispatcher(session, schedule=background.add_task)
await dispatcher.dispatch(event)
await session.commit()
return {"status": "ok"}

View File

@@ -20,6 +20,24 @@ class Settings(BaseSettings):
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` при каждой установке/обновлении — здесь только чтение готового
@@ -59,6 +77,13 @@ class Settings(BaseSettings):
# (см. `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),

View File

@@ -13,7 +13,16 @@ from core.config import get_settings
settings = get_settings()
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True)
engine: AsyncEngine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
# Параметры пула — в настройках (`core/config.py`, там же расчёт бюджета
# соединений на инстанс). Дефолт SQLAlchemy 5 + 10 под нагрузкой
# выгребался за секунды.
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)

View File

@@ -5,14 +5,24 @@
транскодирования в `.ogg` на общий volume `recordings_dir`. Финализация
результата (итоговый `location`/ошибка) приходит асинхронно через webhook
`egress_ended` — здесь только сам запуск и `egress_id`/`started_at` из ответа.
Запуск вынесен из тела webhook-обработчика в фоновую задачу
(`run_track_egress`) — см. докстринг этой функции.
"""
import asyncio
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from livekit import api
from core.config import get_settings
from core.db import async_session_maker
from repositories.conferences import AudioTrackRepository
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
@@ -37,6 +47,10 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
api_secret=settings.livekit_api_secret,
)
try:
# Без таймаута вызов висит до собственного таймаута psrpc LiveKit
# (2025 с, когда egress-воркера в деплое нет). `TimeoutError`
# ловит вызывающая сторона наравне с прочими ошибками запуска.
async with asyncio.timeout(settings.egress_start_timeout_s):
info = await lkapi.egress.start_track_egress(
api.TrackEgressRequest(
room_name=room_name,
@@ -56,3 +70,65 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
else datetime.now(UTC)
)
return EgressStartResult(egress_id=info.egress_id, started_at=started_at)
async def run_track_egress(
*,
room_name: str,
track_sid: str,
filepath: str,
session_id: uuid.UUID,
participant_id: uuid.UUID,
) -> None:
"""Запустить Track Egress и записать строку трека — ФОНОВАЯ задача вебхука.
Почему не внутри обработчика. `POST /api/v1/livekit/webhook` держит
соединение с БД и открытую транзакцию всё время своей работы (INSERT в
`livekit_webhook_events` сделан, commit — после обработчика). Пока здесь
жил сетевой вызов к egress, каждое событие `track_published` занимало
соединение на 2025 секунд, и на нагрузочном тесте 28.07.2026 пул
выгребался за секунды: 226 ошибок `QueuePool limit`, 37 ответов 500 на
путях входа в конференцию, 33 `dropped webhook` со стороны LiveKit
(очередь доставки росла до 56 секунд).
Поэтому обработчик отвечает 200 сразу, а сюда попадает только то, что
требует сети. Своя сессия БД (`async_session_maker`) обязательна: сессия
запроса к этому моменту уже закрыта вместе с ответом.
Идемпотентность сохраняется: `AudioTrackRepository.create` — это
`INSERT ... ON CONFLICT DO NOTHING` по `uq_session_track`, а проверка
«трек уже пишется» осталась в обработчике.
"""
try:
result = await start_track_egress(room_name, track_sid, filepath)
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять фон
# Деплой-профиль (блок D): egress — необязательный сервис профиля
# `transcribe`; без него запись просто не стартует для этого трека.
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
# по которому её мог бы финализировать `egress_ended`.
logger.warning(
"track_published: не удалось запустить egress для трека %s сеанса %s: %s",
track_sid,
session_id,
exc,
)
return
async with async_session_maker() as session:
await AudioTrackRepository(session).create(
session_id=session_id,
participant_id=participant_id,
track_sid=track_sid,
egress_id=result.egress_id,
file_path=filepath,
started_at=result.started_at,
)
await session.commit()
logger.info(
"track_published: сеанс=%s участник=%s трек=%s egress=%s",
session_id,
participant_id,
track_sid,
result.egress_id,
)

View File

@@ -13,6 +13,7 @@
import logging
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from livekit.protocol.egress import EgressStatus
@@ -26,7 +27,7 @@ from repositories.conferences import (
ConferenceRepository,
ConferenceSessionRepository,
)
from services.egress import start_track_egress
from services.egress import run_track_egress
from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline
@@ -53,13 +54,27 @@ def _egress_ns_to_datetime(nanoseconds: int) -> datetime | None:
class WebhookDispatcher:
"""Диспатчит `WebhookEvent` на обработчик по типу события."""
"""Диспатчит `WebhookEvent` на обработчик по типу события.
def __init__(self, session: AsyncSession) -> None:
`schedule` — планировщик фоновых задач: вызывается как
`schedule(coro_func, **kwargs)` и обязан вернуть управление немедленно,
не дожидаясь выполнения. В приложении это `BackgroundTasks.add_task`
FastAPI (задача стартует после отправки ответа), в тестах — вызовы
просто записываются. Через него уходит запуск Track Egress: сетевому
вызову не место внутри транзакции вебхука (см. `_on_track_published`).
"""
def __init__(
self,
session: AsyncSession,
*,
schedule: Callable[..., object],
) -> None:
self._conferences = ConferenceRepository(session)
self._sessions = ConferenceSessionRepository(session)
self._audio_tracks = AudioTrackRepository(session)
self._instance_settings = InstanceSettingsService(session)
self._schedule = schedule
async def dispatch(self, event: WebhookEvent) -> None:
"""Обработать одно webhook-событие; неизвестный тип события — no-op."""
@@ -161,15 +176,32 @@ class WebhookDispatcher:
)
async def _on_track_published(self, event: WebhookEvent) -> None:
"""Запустить Track Egress для опубликованного аудиотрека микрофона (ADR-002).
"""Запланировать Track Egress для опубликованного аудиотрека микрофона (ADR-002).
Видео/скриншеринг и т.п. — no-op (диаризация не нужна: транскрибируем
только речь, трек = спикер). Идемпотентно: если строка трека уже
существует (гонка повторной доставки), egress повторно не запускается.
Сам запуск уходит в фоновую задачу (`services.egress.run_track_egress`):
здесь остаются только быстрые проверки по БД, потому что обработчик
выполняется внутри открытой транзакции вебхука. Обоснование с цифрами —
в докстринге `run_track_egress`.
"""
if event.track.type != TrackType.AUDIO or event.track.source != TrackSource.MICROPHONE:
return
# Транскрибация выключена — записывать нечего. Тот же guard, что и в
# `_on_room_finished`: без него на инстансе без профиля `transcribe`
# (egress-контейнера в деплое нет) каждый микрофон превращался в
# заведомо безнадёжный сетевой вызов длиной в 2025 секунд.
cfg = await self._instance_settings.get()
if not cfg.transcriber.enabled:
logger.debug(
"livekit webhook track_published: транскрибация выключена — трек %s пропущен",
event.track.sid,
)
return
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
@@ -216,37 +248,13 @@ class WebhookDispatcher:
filepath = (
f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{event.track.sid}.ogg"
)
try:
result = await start_track_egress(event.room.name, event.track.sid, filepath)
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять webhook
# Деплой-профиль (блок D): egress — необязательный сервис профиля
# `transcribe`; без него запись просто не стартует для этого трека
# (риск «Потерян webhook track_published»).
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
# по которому её мог бы финализировать `egress_ended`.
logger.warning(
"livekit webhook track_published: не удалось запустить egress для трека %s "
"сеанса %s: %s",
event.track.sid,
session_record.id,
exc,
)
return
await self._audio_tracks.create(
self._schedule(
run_track_egress,
room_name=event.room.name,
track_sid=event.track.sid,
filepath=filepath,
session_id=session_record.id,
participant_id=participant.id,
track_sid=event.track.sid,
egress_id=result.egress_id,
file_path=filepath,
started_at=result.started_at,
)
logger.info(
"livekit webhook track_published: сеанс=%s участник=%s трек=%s egress=%s",
session_record.id,
participant.id,
event.track.sid,
result.egress_id,
)
async def _on_egress_ended(self, event: WebhookEvent) -> None:

View File

@@ -3,9 +3,13 @@
цикл закреплённой/незакреплённой конференции — на фикстурах payload'ов LiveKit.
"""
import asyncio
import base64
import hashlib
import json
import uuid
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, Mock
@@ -13,10 +17,13 @@ from unittest.mock import AsyncMock, Mock
import httpx
import jwt
import pytest
from google.protobuf.json_format import ParseDict
from livekit.protocol.webhook import WebhookEvent
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
import services.egress as egress_module
import services.webhook_handlers as webhook_handlers_module
from core.config import get_settings
from core.security import hash_password
@@ -54,6 +61,52 @@ def _sign(body: bytes) -> str:
return jwt.encode(payload, settings.livekit_api_secret, algorithm="HS256")
def _parse_fixture_event(name: str, **placeholders: str) -> WebhookEvent:
"""Разобрать фикстуру в `WebhookEvent` — для тестов диспатчера без HTTP-слоя."""
return ParseDict(json.loads(_load_fixture(name, **placeholders)), WebhookEvent())
async def _set_transcriber_enabled(session: AsyncSession, *, enabled: bool) -> None:
"""Выставить `instance_settings.transcriber.enabled`.
Пишется тем же `db_session` (savepoint), что и обработчик webhook (подмена
`get_session` в фикстуре `app`) — видна обработчику без реального коммита
в dev-БД. Тесты, которым важен `track_published`, обязаны выставлять флаг
ЯВНО: значение в dev-БД непредсказуемо, а обработчик с версии 0.0.12
выходит на выключенной транскрибации раньше всех остальных проверок.
"""
value: dict[str, object] = {
"enabled": enabled,
"provider": "faster_whisper_cpu" if enabled else "null",
"model": "small" if enabled else None,
"language": "ru",
"options": {},
}
await session.execute(
pg_insert(InstanceSetting)
.values(key="transcriber", value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
def _use_test_session_in_background(
monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
"""Заставить фоновую задачу egress работать с тестовой (savepoint) сессией.
`run_track_egress` намеренно берёт СВОЮ сессию (`async_session_maker`):
в бою сессия запроса к моменту фоновой задачи уже закрыта. В тестах такое
подключение шло бы мимо откатываемой транзакции и не увидело бы ни
конференции, ни участника — поэтому подменяем фабрику на тестовую сессию.
"""
@asynccontextmanager
async def _maker() -> AsyncGenerator[AsyncSession, None]:
yield db_session
monkeypatch.setattr(egress_module, "async_session_maker", _maker)
async def _post_webhook(client: httpx.AsyncClient, body: bytes) -> httpx.Response:
return await client.post(
WEBHOOK_URL,
@@ -319,8 +372,10 @@ async def test_track_published_by_guest_starts_egress_and_creates_track_row(
mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_guest_track", started_at=started_at)
)
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
guest = await _make_guest(db_session, conference)
await db_session.commit()
@@ -382,8 +437,10 @@ async def test_track_published_survives_egress_unavailable(
) -> None:
"""Недоступность egress не должна ронять webhook (блок D): 200 + warning, без строки трека."""
mock_start = AsyncMock(side_effect=RuntimeError("egress service unavailable"))
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-egress-down@example.com")
await db_session.commit()
@@ -417,13 +474,169 @@ async def test_track_published_survives_egress_unavailable(
assert tracks == []
async def test_track_published_skips_egress_when_transcription_disabled(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Транскрибация выключена → egress не дёргается вовсе (релиз 0.0.12).
Симметрично guard'у в `_on_room_finished`. Без него на инстансе без профиля
`transcribe` (egress-контейнера в деплое нет) каждый микрофонный трек
превращался в заведомо безнадёжный вызов длиной в таймаут psrpc LiveKit —
2025 секунд внутри открытой транзакции вебхука. На нагрузочном тесте
28.07.2026 это выгребало пул соединений и роняло вход в конференцию в 500.
"""
mock_start = AsyncMock()
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=False)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-transcriber-off@example.com")
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
assert (await _post_webhook(client, joined)).status_code == 200
track_sid = "TR_transcriber_off"
published = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
assert (await _post_webhook(client, published)).status_code == 200
mock_start.assert_not_awaited()
tracks = (
await db_session.scalars(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
)
).all()
assert tracks == []
async def test_track_published_does_not_call_egress_inside_transaction(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Запуск egress уходит в фон, а не выполняется внутри обработчика (релиз 0.0.12).
Проверяется не время ответа (в тестах ASGI-транспорт дожидается фоновых
задач), а сама суть: пока открыта транзакция вебхука, сетевого вызова не
происходит — обработчик только планирует задачу. Именно это разгружает пул
соединений: до правки вызов жил внутри транзакции и держал соединение
2025 секунд, когда egress-сервиса в деплое нет.
"""
mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_bg", started_at=datetime.now(UTC))
)
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
scheduled: list[tuple[object, dict[str, object]]] = []
def _schedule(func: object, **kwargs: object) -> None:
scheduled.append((func, kwargs))
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-background@example.com")
await db_session.commit()
dispatcher = webhook_handlers_module.WebhookDispatcher(db_session, schedule=_schedule)
joined_event = _parse_fixture_event(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
await dispatcher.dispatch(joined_event)
track_sid = "TR_background"
published_event = _parse_fixture_event(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
await dispatcher.dispatch(published_event)
# Сеть не тронута: обработчик только запланировал задачу.
mock_start.assert_not_awaited()
assert len(scheduled) == 1
func, kwargs = scheduled[0]
assert func is egress_module.run_track_egress
assert kwargs["room_name"] == conference.slug
assert kwargs["track_sid"] == track_sid
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
assert kwargs["session_id"] == session_record.id
# А вот запущенная задача действительно ходит в egress и пишет строку.
_use_test_session_in_background(monkeypatch, db_session)
await egress_module.run_track_egress(**kwargs) # type: ignore[arg-type]
mock_start.assert_awaited_once()
track_row = await db_session.scalar(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
)
assert track_row is not None
assert track_row.egress_id == "EG_bg"
async def test_start_track_egress_gives_up_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""Запуск egress не ждёт дольше `egress_start_timeout_s` (релиз 0.0.12).
Когда egress-воркера нет, LiveKit держит вызов до собственного таймаута
psrpc — на тесте 28.07.2026 это было 2025 секунд на каждый микрофонный
трек. Живой egress отвечает за доли секунды, ждать столько незачем.
"""
settings = get_settings()
monkeypatch.setattr(settings, "egress_start_timeout_s", 0.05, raising=False)
closed = False
class _HangingEgress:
async def start_track_egress(self, _request: object) -> object:
await asyncio.sleep(5)
raise AssertionError("вызов должен был прерваться по таймауту")
class _HangingApi:
def __init__(self, *_args: object, **_kwargs: object) -> None:
self.egress = _HangingEgress()
async def aclose(self) -> None:
nonlocal closed
closed = True
# Строковая форма: `api` в `services.egress` — реэкспорт из livekit SDK,
# обращение к нему атрибутом mypy считает неявным экспортом.
monkeypatch.setattr("services.egress.api.LiveKitAPI", _HangingApi)
with pytest.raises(TimeoutError):
await egress_module.start_track_egress("room", "TR_hang", "/recordings/x.ogg")
# Клиент закрывается и на неуспешном пути — иначе утекали бы соединения.
assert closed is True
async def test_track_published_video_is_noop(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№9 плана (часть 1): video-трек — no-op, egress не запускается."""
mock_start = AsyncMock()
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-video@example.com")
await db_session.commit()
@@ -462,8 +675,10 @@ async def test_track_published_repeated_webhook_creates_single_row(
mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_repeat", started_at=datetime.now(UTC))
)
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-repeat@example.com")
await db_session.commit()
@@ -508,6 +723,8 @@ async def test_egress_ended_finalizes_track_success_and_failure(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№10 плана (часть 1): `egress_ended` — 'recorded' на успехе, 'failed' на ошибке."""
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-egress-ended@example.com")
await db_session.commit()
@@ -527,7 +744,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
# Успешная запись.
ok_started = datetime.now(UTC)
monkeypatch.setattr(
webhook_handlers_module,
egress_module,
"start_track_egress",
AsyncMock(return_value=EgressStartResult(egress_id="EG_ok", started_at=ok_started)),
)
@@ -559,7 +776,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
# Ошибка записи.
monkeypatch.setattr(
webhook_handlers_module,
egress_module,
"start_track_egress",
AsyncMock(
return_value=EgressStartResult(egress_id="EG_fail", started_at=datetime.now(UTC))
@@ -597,6 +814,10 @@ async def test_room_finished_enqueues_pipeline(
mock_enqueue = Mock()
monkeypatch.setattr(webhook_handlers_module, "enqueue_pipeline", mock_enqueue)
# Флаг выставляется явно: `_on_room_finished` ставит задачу в очередь только
# при включённой транскрибации, а состояние `instance_settings` в dev-БД
# непредсказуемо (тест падал, если в базе оставалось `enabled: false`).
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()

View File

@@ -82,7 +82,12 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.11}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.14}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
# (DB_POOL_SIZE + DB_MAX_OVERFLOW), а у Postgres есть max_connections.
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно.
volumes:
@@ -120,7 +125,13 @@ services:
context: ../backend
dockerfile: Dockerfile
restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", "-B",
# `--no-sync` во ВСЕХ вызовах `uv run` в этом файле: окружение собрано на
# этапе build образа (`uv sync --frozen --no-dev`, backend/Dockerfile), а
# без флага `uv run` синхронизирует venv заново при каждом запуске — и
# тянет dev-группу (ruff, mypy, pytest), которой в проде делать нечего.
# Для healthcheck'ов это особенно дорого: они дёргаются каждые 15 секунд
# всю жизнь контейнера.
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-B",
"-Q", "celery,summarize,notify", "--loglevel=info"]
env_file:
- ../.env
@@ -147,7 +158,7 @@ services:
# нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect
# ping`, как рекомендует документация Celery.
healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
interval: 15s
timeout: 10s
retries: 5
@@ -181,7 +192,7 @@ services:
build:
context: ../backend
dockerfile: Dockerfile
entrypoint: ["uv", "run", "python", "/download-model.py"]
entrypoint: ["uv", "run", "--no-sync", "python", "/download-model.py"]
environment:
WHISPER_MODEL: ${WHISPER_MODEL:-small}
WHISPER_MODELS_ROOT: /models/whisper
@@ -216,7 +227,7 @@ services:
# `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ
# кластер — упавший worker-transcriber остался бы "healthy", потому что
# ответил бы базовый worker.
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber@localhost", "--loglevel=info"]
env_file:
@@ -247,7 +258,7 @@ services:
# HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname
# в command выше), а не первого ответившего в общем кластере.
healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
interval: 15s
timeout: 10s
retries: 5
@@ -272,7 +283,7 @@ services:
args:
WITH_GPU_EXTRA: "true"
restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"]
env_file:
@@ -305,7 +316,7 @@ services:
whisper-model-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
interval: 15s
timeout: 10s
retries: 5
@@ -657,6 +668,12 @@ services:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus
# node-exporter живёт в host-сети (см. комментарий у него) и по имени
# сервиса в docker-сети больше не резолвится. `host-gateway` — штатный
# способ дать контейнеру адрес хоста, не завязываясь на конкретный IP
# docker-моста.
extra_hosts:
- "host.docker.internal:host-gateway"
# Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется.
ports:
- "127.0.0.1:9090:9090"
@@ -706,13 +723,27 @@ services:
node-exporter:
# Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
# чего нет ни в одном из приложенческих экспортеров выше. Без
# `network_mode: host` (не нужен: читаем /proc,/sys,/ хоста через
# bind-mount, а Prometheus достаёт их по имени сервиса во внутренней
# сети compose — так безопаснее, не расширяет сетевой доступ контейнера).
# чего нет ни в одном из приложенческих экспортеров выше.
#
# `network_mode: host` ОБЯЗАТЕЛЕН, и вот почему (проверено 2026-07-28,
# до этого экспортер работал в bridge-сети и отдавал неверные данные).
# Bind-mount'а `/proc` достаточно для CPU, памяти и диска, но НЕ для сети:
# `/proc/net` — это симлинк на `self/net`, который резолвится в сетевом
# namespace ЧИТАЮЩЕГО процесса. В bridge-сети экспортер видел собственные
# `lo` и `eth0` (56 МБ трафика) вместо хостового `enp3s0` (39.8 ГБ), то
# есть `node_network_*` показывал трафик контейнера, а не сервера. При
# разборе нагрузочного теста 28.07 сетевых метрик хоста не оказалось
# вовсе — см. .forcc/LOAD-FINDINGS.md.
#
# Порт 9100 при этом слушается на хосте. Наружу он не торчит: ufw
# пропускает только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit
# (проверено `ufw status`). Prometheus обращается к нему через
# `host.docker.internal` (см. `extra_hosts` у сервиса prometheus и
# таргет `node` в deploy/monitoring/prometheus.yml).
image: prom/node-exporter:v1.8.2
restart: unless-stopped
pid: host
network_mode: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
@@ -722,9 +753,8 @@ services:
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
# Не публикуем порт наружу вообще (не 127.0.0.1:9100, а совсем без
# ports) — Prometheus ходит к нему по внутренней сети compose
# (`node-exporter:9100`), публикация на хост для этого не нужна.
# Секции `ports` нет и с host-сетью быть не может: контейнер слушает
# прямо на интерфейсах хоста. От внешнего мира порт закрывает ufw.
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
interval: 10s

View File

@@ -32,6 +32,40 @@ rtc:
use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP}
node_ip: ${LIVEKIT_NODE_IP}
# Внешний TURN (сервис coturn, профиль `media`) — АНОНС КЛИЕНТАМ.
# Сам SFU через эти серверы не ходит: LiveKit лишь отдаёт их браузеру в
# списке ICE-серверов при подключении (см. iceServersForParticipant в
# pkg/service/roommanager.go), а клиент уже решает, нужен ли ему relay.
#
# Зачем. До 28.07.2026 coturn работал, но КЛИЕНТЫ О НЁМ НЕ ЗНАЛИ: секция
# `turn` ниже выключена (встроенный TURN не поднимаем), внешний в конфиге
# объявлен не был, а фронтенд `iceServers` не задаёт. За всё время работы
# в логах coturn — ноль ALLOCATE. Итог: у клиентов из сетей с жёстким NAT
# не было relay-фолбэка вообще, только прямой UDP и TCP 7881. Именно так
# объясняются `PEER_CONNECTION_DISCONNECTED` на нагрузочном тесте — все
# у внешних участников, ни одного у офисных (.forcc/LOAD-FINDINGS.md,
# причина C).
#
# `secret` обязан совпадать с `static-auth-secret` в turnserver.conf —
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
#
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
# прежде чем перейти к рабочему кандидату.
turn_servers:
- host: ${TURN_EXTERNAL_IP}
port: 3478
protocol: udp
secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400
- host: ${TURN_EXTERNAL_IP}
port: 3478
protocol: tcp
secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
# его как pub/sub и key-value хранилище состояния запущенных записей;
# без него egress не может получать room/track-события от LiveKit

View File

@@ -38,9 +38,16 @@ scrape_configs:
# — сервис node-exporter). Единственный источник, который покажет
# нехватку памяти/CPU на сервере, если она не проявится как рост
# латентности API (см. дашборд host.json).
#
# Адрес `host.docker.internal`, а не `node-exporter:9100`: с 2026-07-28
# экспортер работает в host-сети и по имени сервиса в docker-сети не
# резолвится. Причина перевода — сетевые метрики: `/proc/net` это симлинк
# на `self/net`, поэтому в bridge-сети экспортер отдавал трафик
# собственного `eth0` вместо хостового `enp3s0`. Имя резолвится через
# `extra_hosts: host-gateway` у сервиса prometheus (deploy/docker-compose.yml).
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]
- targets: ["host.docker.internal:9100"]
# Метрики по каждому контейнеру (CPU/память/сеть отдельно у backend,
# worker, postgres и т.д. — профиль monitoring, сервис
@@ -53,6 +60,32 @@ scrape_configs:
static_configs:
- targets: ["container-exporter:9419"]
# LiveKit SFU (профиль `media`). Порт объявлен в самом LiveKit —
# `deploy/livekit/livekit.yaml.template`, секция `prometheus: port: 6789`;
# наружу он не публикуется, скрейп идёт по имени сервиса внутри docker-сети.
#
# Почему это важно отдельно от `container-exporter`: тот показывает CPU,
# память и суммарный трафик контейнера, но ничего не знает о том, ЧТО внутри
# этого трафика. Разбор нагрузочного теста 28.07.2026 пришлось делать по
# логам именно потому, что job'а здесь не было (см. .forcc/LOAD-FINDINGS.md).
#
# Ключевое, что отсюда появляется:
# livekit_track_subscribed_total / livekit_track_published_total —
# подписки против публикаций, то есть прямой эффект adaptiveStream;
# livekit_participant_total, livekit_room_total — нагрузка в участниках;
# livekit_quality_score, livekit_packet_loss_percent, livekit_rtt_ms,
# livekit_jitter_us, livekit_nack_total, livekit_pli_total — качество
# связи у клиентов, а не догадки по событиям congestion в логах;
# livekit_webhook_queue_length, livekit_webhook_dispatch_total — очередь
# доставки вебхуков в backend (на тесте она росла до 56 секунд).
#
# Профиль `media` входит в дефолтный набор COMPOSE_PROFILES (см. .env.example),
# поэтому job включён, а не закомментирован, как `llm`. На инсталляции без
# профиля `media` таргет не резолвится — закомментируйте секцию.
- job_name: livekit
static_configs:
- targets: ["livekit:6789"]
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни

View File

@@ -49,7 +49,10 @@ envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
echo "[render] deploy/coturn/turnserver.conf готов"
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD}' \
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
# обязан совпадать с `static-auth-secret` в turnserver.conf выше.
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
echo "[render] deploy/livekit/livekit.yaml готов"

View File

@@ -82,9 +82,25 @@ ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
# TURN (coturn) — только если включаете раздел 8:
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
# сам релей не работает — клиент получает кандидата и не может им
# воспользоваться, а в логах coturn при этом тишина.
# ufw allow 3478/tcp
# ufw allow 3478/udp
# ufw allow 49160:49200/udp
# Мониторинг (профиль `monitoring`): node-exporter работает в host-сети —
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
# сервера (`/proc/net` — симлинк на `self/net`, bind-mount `/proc` этого не
# обходит; см. комментарий у сервиса в deploy/docker-compose.yml). Порт
# слушается на хосте, поэтому Prometheus в docker-сети упирается в
# политику ufw по умолчанию. Правило разрешает скрейп ТОЛЬКО из внутренних
# docker-подсетей — снаружи 9100 остаётся закрыт (172.16.0.0/12 не
# маршрутизируется в интернете):
ufw allow from 172.16.0.0/12 to any port 9100 proto tcp comment 'node-exporter: скрейп Prometheus из docker-сети'
ufw enable
```
@@ -309,29 +325,48 @@ firewall) хватает для подавляющего большинства
(характерный симптом — конференция подключается по signaling, `connection
state: connected`, но собеседник не видит видео/не слышит звук).
По умолчанию `deploy/livekit/livekit.yaml.template` содержит
`turn.enabled: false`, и `rtc.turn_servers` не задан — standalone coturn
поднимается (профиль `media`), но LiveKit не раздаёт его клиентам как
ICE-фолбэк.
**С версии 0.0.14 LiveKit анонсирует coturn клиентам** — секция
`rtc.turn_servers` в `deploy/livekit/livekit.yaml.template` (UDP и TCP на
3478, credentials по механизму TURN REST API из общего
`TURN_STATIC_AUTH_SECRET`). Встроенный TURN LiveKit при этом остаётся
выключенным (`turn.enabled: false`), чтобы не поднимать два TURN-сервера.
Включение (правки шаблона `deploy/livekit/livekit.yaml.template` +
редеплой; **код-фикс не входит в это руководство без запроса** — обсудите
с командой перед изменением):
⚠️ **Чем это было до 0.0.14, если вы обновляетесь со старой версии.** coturn
поднимался и был healthy, но клиенты о нём не знали: в конфиге LiveKit
внешний TURN объявлен не был, а фронтенд `iceServers` не задаёт. За всё
время работы в логах coturn не было ни одного ALLOCATE — то есть relay не
использовался никогда, и участники из сетей с жёстким NAT просто теряли
соединение (`PEER_CONNECTION_DISCONNECTED`).
1. Открыть 443 для TURN/TLS (наиболее надёжный фолбэк — TURN через тот же
порт, что и остальной HTTPS-трафик, редко блокируется firewall'ами):
потребует отдельного TLS-сертификата для coturn (`cert-file`/`pkey-file`
в `deploy/coturn/turnserver.conf.template`) — можно переиспользовать тот
же Let's Encrypt сертификат, что и nginx (тот же `/etc/letsencrypt`, уже
смонтированный в nginx — coturn сейчас его не монтирует, потребуется
доп. volume).
2. В `livekit.yaml.template` включить `turn.enabled: true` и/или явно
прописать `rtc.turn_servers` со статическими credentials
(`TURN_STATIC_AUTH_SECRET` уже есть в `.env`).
3. `ufw allow 3478/tcp` + `ufw allow 3478/udp` (шаг 1, закомментированные
строки) — сейчас coturn поднят, но порт не проверялся как обязательный.
4. Передеплой (`docker compose ... up -d --force-recreate livekit coturn`)
и повторный кросс-сетевой тест именно с проблемной сетью.
Что нужно проверить на своей инсталляции:
1. **Порты в ufw — оба пункта** (см. шаг 1): `3478/tcp` + `3478/udp` для
сигнализации и `49160:49200/udp` для relay-аллокаций. Диапазон должен
совпадать с `min-port`/`max-port` в
`deploy/coturn/turnserver.conf.template`. Без него TURN отвечает на
запросы, но релей не работает — самый неприятный вариант, потому что в
логах coturn при этом тишина.
2. **`TURN_EXTERNAL_IP` в `.env`** — реальный внешний IP или домен сервера.
Именно это значение уезжает клиентам как адрес TURN-сервера, поэтому
`127.0.0.1` из dev-дефолта сделает анонс бесполезным.
3. После правок — `./deploy/render-templates.sh` (перерендерит конфиги из
шаблонов), затем `docker compose ... up -d --force-recreate livekit`.
⚠️ Перезапуск LiveKit **разрывает все активные конференции** — выбирайте
окно.
4. Проверка, что релей заработал: провести звонок из проблемной сети и
убедиться, что в логах появились аллокации:
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите ufw и `TURN_EXTERNAL_IP`.
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
`deploy/coturn/turnserver.conf.template`, добавить volume с
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
клиента ждать таймаута перед переходом к рабочему кандидату.
---
@@ -389,7 +424,7 @@ docker exec vidconf-postgres-1 pg_dump -U vidconf vidconf | gzip > db-$(date +%F
```bash
gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf
# Догнать миграции, если бэкап снят на более старой версии кода:
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run alembic upgrade head
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run --no-sync alembic upgrade head
docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker
```

View File

@@ -63,7 +63,7 @@ CPU-only — отдельного GPU-варианта профилей для
Поведение:
- **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД
(defs: `docker compose exec -T backend uv run python -m scripts.apply_preset_settings --force`)
(defs: `docker compose exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force`)
- **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env`
обновляются, но скрипт применения настроек НЕ запускается
- **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD)
@@ -111,7 +111,7 @@ Grafana/Prometheus не поднимаются автоматически — с
собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`).
4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта
backend** миграции и seed одноразовыми контейнерами:
`docker compose run --rm backend uv run alembic upgrade head` +
`docker compose run --rm backend uv run --no-sync alembic upgrade head` +
`... python -m scripts.seed`. Порядок критичен: `backend.lifespan`
бутстрапит `instance_settings` при каждом старте приложения, поэтому на
чистой БД таблицы обязаны существовать до первого запуска backend — иначе
@@ -122,9 +122,8 @@ Grafana/Prometheus не поднимаются автоматически — с
настройки инстанса не перетираются).
5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait`
(backend, worker, nginx с фронтом + сервисы активных профилей). Команда
идемпотентна и обёрнута в ретрай (до 3 попыток): первый старт backend/worker
включает `uv run` (синхронизация окружения + компиляция байткода), и на
слабой/загруженной машине healthcheck может не успеть за отведённые
идемпотентна и обёрнута в ретрай (до 3 попыток): на слабой/загруженной
машине healthcheck может не успеть за отведённые
retries — повтор лишь дожидается уже стартующих контейнеров.
6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора,
команда для `--profile monitoring`.

View File

@@ -63,7 +63,7 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
# Без -B: beat уже запущен на базовом worker (см. правило выше).
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"]
env_file:
- ../.env

View File

@@ -187,6 +187,22 @@ export function RoomPage() {
const { userChoices } = usePersistentUserChoices()
const roomOptions = useMemo<RoomOptions>(
() => ({
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
// подписан на полное качество всех чужих треков независимо от размера
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
// не смотрит. На тесте 28.07.2026 (19 участников, ~8 камер) это дало
// устойчивые 140169 Мбит/с исходящего трафика при пике 240, 662 события
// `remote bwe: channel congestion detected` и 146 переходов аллокатора
// STABLE → DEFICIENT — то есть видимый участникам лаг.
//
// adaptiveStream: подписка на слой по фактическому размеру плитки на
// экране + пауза треков, которые сейчас не отрисованы. Именно на нём
// начинает экономить уже написанный код: «скрыть остальных»
// (RoomStage) не рендерит карусель, а пагинация StageGrid рендерит
// только текущую страницу — неприаттаченные треки считаются невидимыми.
// dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков.
adaptiveStream: true,
dynacast: true,
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через

View File

@@ -526,13 +526,14 @@ docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --wait postgres r
# exist" и healthcheck (--wait) никогда не проходит. `compose run` запускает
# одноразовый контейнер с нужной командой, не поднимая uvicorn/lifespan.
echo "[install] Применяю миграции Alembic и seed (админ/справочники) — до старта backend"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run alembic upgrade head
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run python -m scripts.seed
# `--no-sync`: окружение собрано в образе, повторная синхронизация в рантайме
# только тянула бы dev-группу (см. комментарий в backend/Dockerfile).
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync alembic upgrade head
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync python -m scripts.seed
echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)"
# Первый старт backend/worker включает `uv run` (синхронизация окружения +
# компиляция байткода) — на слабой/загруженной машине healthcheck может не
# успеть пройти за отведённые retries, и `--wait` вернёт "container is
# На слабой/загруженной машине healthcheck может не успеть пройти за отведённые
# retries, и `--wait` вернёт "container is
# unhealthy", хотя сервис через несколько секунд становится healthy. Команда
# идемпотентна, поэтому повторяем её несколько раз: повтор лишь дожидается
# уже стартующих контейнеров, ничего не пересоздавая.
@@ -573,7 +574,7 @@ if [ "$FRESH_ENV" != "1" ]; then
fi
if [ "$APPLY_PRESET_SETTINGS" = "1" ]; then
echo "[install] Применяю настройки модулей инстанса под пресет ${PRESET}"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run python -m scripts.apply_preset_settings --force
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force
else
echo "[install] Настройки модулей инстанса НЕ изменены — сохранены ручные правки администратора"
fi