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` явно, как и остальные тесты этой группы.
This commit is contained in:
@@ -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,13 +47,17 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
|
||||
api_secret=settings.livekit_api_secret,
|
||||
)
|
||||
try:
|
||||
info = await lkapi.egress.start_track_egress(
|
||||
api.TrackEgressRequest(
|
||||
room_name=room_name,
|
||||
track_id=track_sid,
|
||||
file=api.DirectFileOutput(filepath=filepath),
|
||||
# Без таймаута вызов висит до собственного таймаута psrpc LiveKit
|
||||
# (20–25 с, когда egress-воркера в деплое нет). `TimeoutError`
|
||||
# ловит вызывающая сторона наравне с прочими ошибками запуска.
|
||||
async with asyncio.timeout(settings.egress_start_timeout_s):
|
||||
info = await lkapi.egress.start_track_egress(
|
||||
api.TrackEgressRequest(
|
||||
room_name=room_name,
|
||||
track_id=track_sid,
|
||||
file=api.DirectFileOutput(filepath=filepath),
|
||||
)
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@@ -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` занимало
|
||||
соединение на 20–25 секунд, и на нагрузочном тесте 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,
|
||||
)
|
||||
|
||||
@@ -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-контейнера в деплое нет) каждый микрофон превращался в
|
||||
# заведомо безнадёжный сетевой вызов длиной в 20–25 секунд.
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user