phase: 110_fix_sse_db_pool_exhaustion
Build and Push Containers / build-and-push-app (push) Successful in 2m14s
Build and Push Containers / build-and-push-db (push) Successful in 13s

---

**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:
2026-09-14 15:55:13 -04:00
parent 35d65d2f25
commit 3a4035fc96
43 changed files with 2886 additions and 444 deletions
+23 -6
View File
@@ -35,10 +35,11 @@ import asyncio
import json
import logging
import uuid
from collections.abc import AsyncGenerator, AsyncIterator, Sequence
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
from copy import deepcopy
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import Session
@@ -133,21 +134,37 @@ class ScriptedLLM:
yield StreamPiece("content", tail)
def _mock_session() -> Session:
"""A minimal mock Session for unit tests (DB accessors are monkeypatched).
The mock works as a context manager: ``__enter__`` returns itself so
``with db_factory() as tool_db:`` binds *tool_db* to the same mock.
"""
mock = cast("Session", MagicMock())
mock.__enter__ = MagicMock(return_value=mock)
mock.__exit__ = MagicMock(return_value=False)
mock.scalar = MagicMock(return_value=None)
mock.execute = MagicMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
return mock
async def _run(
llm: ScriptedLLM | FailingLLM,
holder: AgentHolder,
settings: Settings,
seed_docs: list[Document] | None = None,
history: Sequence[dict[str, Any]] = (),
db_factory: Callable[[], Session] | None = None,
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
"""Consume one ``run_agent`` turn; *history* (phase 74) is the
client's prior turns spliced between system and user (default
``()`` — the pre-phase-74 two-message request). Phase 95: the loop
may also yield a ``ToolResultPiece`` (a truncated ``read``)."""
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
factory = db_factory or (lambda: _mock_session())
async for piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
factory,
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=seed_docs or [],
@@ -693,7 +710,7 @@ def test_ls_nested_folder_scope_lists_one_level_deeper(
root), counted; the fetchers are the source-scoped ones."""
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
assert (source, db) == ("Homelab", None)
assert source == "Homelab" # db is a mock session (SEC-14-04)
return [
("networking/lan.md", "LAN", "2024-06-15"),
("networking/vpn.md", "VPN", "2024-06-15"),
@@ -2552,7 +2569,7 @@ def test_round_failure_after_first_piece_is_terminal(monkeypatch: pytest.MonkeyP
with pytest.raises(LLMError, match="mid-stream drop"):
async for piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
lambda: _mock_session(),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
@@ -2614,7 +2631,7 @@ def test_zero_retries_is_one_plain_attempt(monkeypatch: pytest.MonkeyPatch) -> N
with pytest.raises(LLMError, match="connection refused"):
async for piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
lambda: _mock_session(),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
@@ -2651,7 +2668,7 @@ def test_abandon_mid_retry_sleep_leaks_nothing(monkeypatch: pytest.MonkeyPatch)
async def run() -> None:
gen = run_agent(
cast("LLMClient", llm),
cast("Session", None),
lambda: _mock_session(),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],