phase: 110_fix_sse_db_pool_exhaustion
--- **Phase 110 — Fix SSE DB Connection Pool Exhaustion (SEC-14-04): COMPLETE** **What was implemented/verified:** - All three tasks (pool config, short-lived sessions, concurrency cap) were already implemented in code - Fixed `tests/integration/test_chat_db_sessions.py` — added FakeChatLLM mock, fixed LLM signature (`tools=` not `_tools=`), used `fastapi_app.dependency_overrides` instead of `client.app.dependency_overrides` - Fixed `tests/e2e/test_chat_db_pool.py` — added FakeChatLLM mock, fixed admin password to match `tests/conftest.py`, removed unused imports - Fixed lint errors (unused imports, import order) in both test files **Test / lint / coverage results:** - `uv run pytest` → 2350 passed, 1 warning, 56.4s - `uv run pytest --cov=app --cov-report=term-missing` → 99% coverage (4065 lines, 16 uncovered) - `uv run pytest tests/e2e/test_chat_db_pool.py -v --no-cov` → 3 passed - `uv run pytest tests/integration/test_chat_db_sessions.py -v --no-cov` → 4 passed - `uv run pytest tests/integration/test_chat_concurrency.py -v --no-cov` → 11 passed - `uv run pytest tests/unit/test_db_pool_config.py -v --no-cov` → 14 passed - `uv run pytest tests/unit/test_agent_short_lived_sessions.py -v --no-cov` → 7 passed - `uv run ruff check .` → all checks passed - `uv run pyright` → 0 errors, 0 warnings **Completion criteria:** - [✓] `app/db.py::create_engine` receives explicit `pool_size=5`, `max_overflow=10`, `pool_recycle=3600` from settings - [✓] `run_agent` accepts `db_factory: Callable[[], Session]` and creates short-lived sessions per tool call - [✓] Each tool round uses a separate DB session closed after the tool result - [✓] Concurrency cap (`BOR_CHAT_MAX_CONCURRENT`, default 10) limits concurrent turns; excess get 503 - [✓] All test gates green, coverage 99%, lint/types clean **Notable decisions:** Tests needed LLM mocking (the original test files lacked `FakeChatLLM` mocks, causing hangs on real LLM calls). **Next pending phase:** None — this is the last phase in `todo/`.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""E2E: chat concurrency cap (SEC-14-04, phase 106, task 03).
|
||||
|
||||
Verifies the concurrency cap end-to-end using the app server:
|
||||
- The app handles concurrent chat requests correctly.
|
||||
- Requests exceeding the concurrency cap get a 503 response.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api import chat as chat_api
|
||||
from app.main import app as fastapi_app
|
||||
from app.rag.llm import StreamPiece, ToolCallPiece
|
||||
|
||||
# The tests/conftest.py sets BOR_ADMIN_PASSWORD="test-admin-password"
|
||||
# before importing app.main — use the same value here.
|
||||
ADMIN_PASSWORD = "test-admin-password"
|
||||
|
||||
_DIM = 768
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
def _token_vec(text: str) -> list[float]:
|
||||
"""Bag-of-words unit vector — same algorithm as the E2E mock."""
|
||||
import hashlib
|
||||
|
||||
vec = [0.0] * _DIM
|
||||
for tok in _TOKEN_RE.findall(text.lower()):
|
||||
vec[int(hashlib.md5(tok.encode()).hexdigest(), 16) % _DIM] += 1.0
|
||||
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
||||
return [v / norm for v in vec]
|
||||
|
||||
|
||||
class FakeChatLLM:
|
||||
"""Minimal duck-typed LLM client for the chat endpoint."""
|
||||
|
||||
def __init__(self, answer: str = "Test answer.") -> None:
|
||||
self.answer = answer
|
||||
|
||||
async def embed_one(self, message: str) -> list[float]:
|
||||
return _token_vec(message)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
scaffolding: Any = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
yield StreamPiece("thinking", "thinking")
|
||||
yield StreamPiece("content", self.answer)
|
||||
|
||||
|
||||
def _mock_llm_fixture(app) -> FakeChatLLM:
|
||||
"""Set up a fake LLM on the app and return it."""
|
||||
fake = FakeChatLLM(answer="Test answer.")
|
||||
app.dependency_overrides[chat_api.get_llm] = lambda: fake
|
||||
return fake
|
||||
|
||||
|
||||
class TestConcurrencyCapE2E:
|
||||
"""E2E tests for the chat concurrency cap using the real app."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_within_cap_works(self) -> None:
|
||||
"""A single chat request within the cap succeeds (returns stream)."""
|
||||
_mock_llm_fixture(fastapi_app)
|
||||
transport = ASGITransport(app=fastapi_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# Login first
|
||||
resp = await client.post(
|
||||
"/api/login",
|
||||
json={"password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 204, f"Login failed: {resp.status_code}"
|
||||
|
||||
# Send a chat request — should get a streaming response
|
||||
resp = await client.post(
|
||||
"/api/chat",
|
||||
json={"message": "hello"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_exceeding_cap_gets_503(self) -> None:
|
||||
"""Requests exceeding the concurrency cap get 503."""
|
||||
import app.api.chat as chat_module
|
||||
|
||||
# Reset state
|
||||
chat_module._chat_active = 0
|
||||
chat_module._chat_semaphore = None
|
||||
|
||||
_mock_llm_fixture(fastapi_app)
|
||||
|
||||
transport = ASGITransport(app=fastapi_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# Login first
|
||||
resp = await client.post(
|
||||
"/api/login",
|
||||
json={"password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Fill up the slots
|
||||
chat_module._chat_active = 10 # default cap
|
||||
|
||||
try:
|
||||
# This request should get 503
|
||||
resp = await client.post(
|
||||
"/api/chat",
|
||||
json={"message": "overflow"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
body = resp.json()
|
||||
assert "Too many concurrent" in body["detail"]
|
||||
finally:
|
||||
chat_module._chat_active = 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_slot_released_after_stream(self) -> None:
|
||||
"""After a stream completes, the slot is freed for the next request."""
|
||||
import app.api.chat as chat_module
|
||||
|
||||
# Reset state
|
||||
chat_module._chat_active = 0
|
||||
chat_module._chat_semaphore = None
|
||||
|
||||
_mock_llm_fixture(fastapi_app)
|
||||
|
||||
transport = ASGITransport(app=fastapi_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# Login first
|
||||
resp = await client.post(
|
||||
"/api/login",
|
||||
json={"password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Simulate a stream completing (counter back to 0)
|
||||
chat_module._chat_active = 0
|
||||
|
||||
# Request should succeed
|
||||
resp = await client.post(
|
||||
"/api/chat",
|
||||
json={"message": "after release"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
Reference in New Issue
Block a user