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:
@@ -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 —
|
||||
20–25 секунд внутри открытой транзакции вебхука. На нагрузочном тесте
|
||||
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-транспорт дожидается фоновых
|
||||
задач), а сама суть: пока открыта транзакция вебхука, сетевого вызова не
|
||||
происходит — обработчик только планирует задачу. Именно это разгружает пул
|
||||
соединений: до правки вызов жил внутри транзакции и держал соединение
|
||||
20–25 секунд, когда 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 это было 20–25 секунд на каждый микрофонный
|
||||
трек. Живой 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user