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
+10 -3
View File
@@ -225,7 +225,7 @@ from __future__ import annotations
import json
import logging
import re
from collections.abc import AsyncIterator, Mapping, Sequence
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, cast
@@ -1268,7 +1268,7 @@ def _execute_tool(
async def run_agent(
llm: LLMClient,
db: Session,
db_factory: Callable[[], Session],
*,
system_prompt: str,
user_message: str,
@@ -1331,6 +1331,12 @@ async def run_agent(
them is rejected with :data:`ALREADY_IN_CONTEXT` (the phase-72
teaching line — answer from the text already in the prompt) — the
rejection counts in nothing, but it still consumes a round.
DB sessions (SEC-14-04): *db_factory* is a callable that returns a
new :class:`sqlalchemy.orm.Session` (e.g. ``lambda: SessionLocal()``).
Each tool call creates its own short-lived session via *db_factory*
and closes it after the tool result is produced — no session is held
across rounds, eliminating SSE-stream DB-connection pinning.
"""
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
@@ -1466,7 +1472,8 @@ async def run_agent(
# round, so at most one new entry — the loop still iterates the
# tail, so a future multi-call round stays correct).
trunc_before = len(holder.read_truncations)
result = _execute_tool(db, call, seed_docs, holder, settings)
with db_factory() as tool_db:
result = _execute_tool(tool_db, call, seed_docs, holder, settings)
rounds += 1 # every call the model emits consumes a round
logger.info(
"agent tool=%s args=%s round=%d/%d",