From f7c4fb41769f23722fd4ca24df7b65164828e786 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Sun, 9 Aug 2026 01:05:59 +0300 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=D0=BD=D0=B5=20=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B0=D1=82=D1=8C=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=20=D0=91=D0=94=20=D0=B2=D1=81?= =?UTF-8?q?=D1=8E=20=D0=B6=D0=B8=D0=B7=D0=BD=D1=8C=20WS-=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обработчик `WS /conferences/{id}/chat` получает `AsyncSession` через `Depends(get_session)`, а хендшейк делает четыре SELECT'а (тоггл чата, конференция, тоггл рук, история). SQLAlchemy открывает транзакцию на первом из них и держит её — вместе с соединением из пула — всё время, пока участник сидит в комнате. Соединений в пуле `db_pool_size + db_max_overflow` = 20 на воркер, то есть 40 на инстанс из двух воркеров: сороковой вошедший выгребал пул досуха. Ровно это положило вход на нагрузочном тесте 07.08.2026: 245 ошибок `QueuePool limit ... timed out`, 170 ответов 500 (из них 123 на резолве конференции и 21 на гостевом входе), а `pg_stat_activity` показывал рост `idle in transaction` 3 → 8 → 16 → 26 → 35 → 39 → 40 при одном `active`. Число открытых WS чата в логах backend растёт синхронно и упирается в те же 40 ровно к моменту первого таймаута пула. Соединение освобождается сразу после хендшейка: дальше оба насоса работают через Redis, а единственная запись в БД (`persist_and_publish`) открывает и коммитит собственную транзакцию. Замер на локальном стенде (один воркер, потолок пула 20), 15 посторонних запросов на каждой ступени: | участников | idle in transaction | 5xx | p95 | |------------|---------------------|-----|----------| | было 20 | 20 | 10 | 10.05 с | | стало 20 | 0 | 0 | 0.02 с | | стало 120 | 0 | 0 | 0.03 с | До правки 21-й участник не мог войти вовсе (500 на guest-join), в логе 40 ошибок `QueuePool limit`; после — ни одной на 120 участниках. --- backend/api/chat.py | 20 +++++++++++++++++++ backend/tests/test_chat_ws.py | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/backend/api/chat.py b/backend/api/chat.py index 357886e..4922ef7 100644 --- a/backend/api/chat.py +++ b/backend/api/chat.py @@ -87,6 +87,26 @@ async def chat_websocket( await pubsub.subscribe(channel, room_channel) try: history = await service.history(conference) + # 🔑 Вернуть соединение с БД в пул ДО входа в долгоживущие насосы. + # + # Хендшейк выше сделал несколько SELECT'ов (тоггл чата, конференция, + # тоггл рук, история) — SQLAlchemy открыла транзакцию на первом же из + # них и держала бы её, а с ней и соединение из пула, ВСЁ время жизни + # WS: участник сидит в комнате час — час занято соединение. Пул это + # `db_pool_size + db_max_overflow` на воркер (10 + 10), то есть + # 40 на инстанс из двух воркеров, и сороковой вошедший выгребал его + # досуха: `pg_stat_activity` показывал 40 соединений + # `idle in transaction` при одном `active`, а посторонние ручки + # (резолв, гостевой вход, логин, refresh) начинали падать в + # `QueuePool limit ... timed out` и отдавать 500. Ровно это положило + # вход на нагрузочном тесте 07.08.2026 при ~50 участниках. + # + # Соединение здесь больше не нужно: оба насоса ниже работают через + # Redis, а единственная запись в БД (`persist_and_publish`) сама + # открывает транзакцию и коммитит её, освобождая соединение сразу. + # ⚠️ Любое чтение из БД, добавленное между этой строкой и концом + # обработчика, обязано так же завершаться commit/rollback. + await session.commit() await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json")) seen_ids = {item.id for item in history} diff --git a/backend/tests/test_chat_ws.py b/backend/tests/test_chat_ws.py index be3e2f6..8a4116e 100644 --- a/backend/tests/test_chat_ws.py +++ b/backend/tests/test_chat_ws.py @@ -237,6 +237,42 @@ async def test_no_duplicate_when_message_already_in_history( assert received["message"]["text"] == "genuinely new" +# --- Удержание соединения с БД ------------------------------------------------ + + +async def test_handshake_releases_db_connection( + db_session: AsyncSession, ws_client: WSFactory +) -> None: + """Regression: после хендшейка WS не держит открытую транзакцию БД. + + Обработчик получает `AsyncSession` на ВСЁ время жизни соединения, а + SELECT'ы хендшейка (тоггл чата, конференция, тоггл рук, история) + открывают транзакцию. Без явного `commit` она висела бы, пока участник + сидит в комнате: одно занятое соединение из пула на каждого человека + в конференции. На нагрузочном тесте 07.08.2026 это выгребло пул + (`db_pool_size + db_max_overflow` = 20 на воркер, 40 на инстанс) при + сорока участниках — и вход в систему начал отдавать 500 всем + остальным. Проверяем именно отсутствие открытой транзакции, а не + состояние пула: тестовая сессия привязана к своему соединению + (см. докстринг `tests/conftest.py`) и пул не задействует. + """ + conference = await _make_conference(db_session) + user = await _make_user(db_session) + await db_session.commit() + + ws = ws_client(_chat_path(conference.id)) + await _connect_and_auth(ws, _user_token(conference, user)) + + assert not db_session.in_transaction() + + # Запись сообщения открывает транзакцию заново — и тоже обязана её + # закрыть, иначе первый же чат вернул бы прежнее поведение. + await ws.send_json({"type": "message", "text": "проверка"}) + echo = await ws.receive_json() + assert echo["type"] == "message" + assert not db_session.in_transaction() + + # --- Auth: коды закрытия ----------------------------------------------------