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:
@@ -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