--- **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/`.
257 lines
9.3 KiB
Python
257 lines
9.3 KiB
Python
"""Integration: the phase-106 D5 date surfaces against REAL Postgres
|
|
rows (task 06).
|
|
|
|
The two tool surfaces the model reads carry the document's creation
|
|
date: the ``read`` result's SECOND line (``date: YYYY-MM-DD`` — the
|
|
FIRST line stays the byte-identical ``Document {source}/{path}:``
|
|
header the E2E mock's ``_READ_RESULT_PREFIX`` contract keys on) and
|
|
the ``ls`` FILE line's APPENDED `` | date: YYYY-MM-DD`` field (the
|
|
mock's ``_CATALOG_LINE_RE`` ``title: .+$`` tail absorbs it). The rows
|
|
carry DISTINCT fixed ``created_at`` values, so the pins prove the date
|
|
is the ROW's date (per row), not a constant.
|
|
|
|
Requires: podman compose up -d db
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from copy import deepcopy
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
import pytest
|
|
from sqlalchemy import delete, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.db import SessionLocal
|
|
from app.models import Document, GitSource
|
|
from app.rag.agent import AgentHolder, run_agent
|
|
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece, ToolResultPiece
|
|
|
|
if TYPE_CHECKING:
|
|
from app.rag.scaffolding import ScaffoldingFilter
|
|
|
|
#: DISTINCT fixed creation dates — the per-row date pins (task 06):
|
|
#: each document renders ITS OWN row's UTC date part.
|
|
D1 = datetime(2019, 6, 15, 3, 4, 6, tzinfo=UTC) # "2019-06-15"
|
|
D2 = datetime(2020, 1, 2, 5, 0, 0, tzinfo=UTC) # "2020-01-02"
|
|
D3 = datetime(2024, 6, 15, 23, 59, 59, tzinfo=UTC) # "2024-06-15" (late UTC instant)
|
|
|
|
D1_STR, D2_STR, D3_STR = "2019-06-15", "2020-01-02", "2024-06-15"
|
|
|
|
|
|
def _doc(
|
|
db: Session,
|
|
source: str,
|
|
path: str,
|
|
title: str,
|
|
content: str,
|
|
created_at: datetime,
|
|
) -> Document:
|
|
doc = Document(
|
|
id=uuid.uuid4(),
|
|
source=source,
|
|
path=path,
|
|
full_path=f"/tmp/{source}/{path}",
|
|
title=title,
|
|
content=content,
|
|
content_hash="0" * 64,
|
|
created_at=created_at, # D1: explicit — the pins prove per-row dates
|
|
)
|
|
db.add(doc)
|
|
return doc
|
|
|
|
|
|
@pytest.fixture()
|
|
def kb(db) -> Iterator[None]:
|
|
"""Fresh documents table (chunks first — the FK)."""
|
|
db.execute(text("TRUNCATE chunks, documents"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE chunks, documents"))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture()
|
|
def src(db) -> Iterator[GitSource]:
|
|
"""One registered git source — the scoped ``ls`` source-name check
|
|
reads the real registry (``repo_name`` resolves the URL to
|
|
``Homelab``)."""
|
|
row = GitSource(url="https://github.com/reese/Homelab.git", kind="git")
|
|
db.add(row)
|
|
db.commit()
|
|
yield row
|
|
db.execute(delete(GitSource).where(GitSource.id == row.id))
|
|
db.commit()
|
|
|
|
|
|
class ScriptedToolLLM:
|
|
"""One scripted tool-call stream, then one canned answer stream.
|
|
Records every ``chat_stream`` request's messages and tools."""
|
|
|
|
def __init__(self, call: ToolCallPiece) -> None:
|
|
self.call = call
|
|
self.requests: list[
|
|
tuple[list[dict[str, Any]], list[dict[str, Any]] | None]
|
|
] = []
|
|
|
|
async def chat_stream(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
tools: list[dict[str, Any]] | None = None,
|
|
scaffolding: ScaffoldingFilter | None = None,
|
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
|
self.requests.append((deepcopy(messages), deepcopy(tools)))
|
|
if len(self.requests) == 1:
|
|
yield self.call
|
|
else:
|
|
yield StreamPiece("content", "ans")
|
|
|
|
|
|
def _settings(**kwargs: Any) -> Settings:
|
|
kwargs.setdefault("_env_file", None)
|
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
|
|
|
|
|
def _run_call(
|
|
db: Session, name: str, arguments: dict[str, Any]
|
|
) -> tuple[AgentHolder, ScriptedToolLLM]:
|
|
"""Drive one scripted tool call through ``run_agent``."""
|
|
holder = AgentHolder()
|
|
llm = ScriptedToolLLM(ToolCallPiece(id="call_1", name=name, arguments=arguments))
|
|
asyncio.run(_consume(cast("LLMClient", llm), db, holder))
|
|
return holder, llm
|
|
|
|
|
|
async def _consume(
|
|
llm: LLMClient, db: Session, holder: AgentHolder
|
|
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
|
"""SEC-14-04: uses a short-lived session per tool call."""
|
|
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
|
async for piece in run_agent(
|
|
llm,
|
|
lambda: SessionLocal(), # SEC-14-04: session factory (short-lived sessions)
|
|
system_prompt="SYSTEM_PROMPT",
|
|
user_message="QUESTION",
|
|
seed_docs=[],
|
|
settings=_settings(),
|
|
holder=holder,
|
|
):
|
|
out.append(piece)
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# read — the date is the stored row's date, on the SECOND line
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
def test_read_result_second_line_is_stored_date(kb, db) -> None:
|
|
"""A real row (distinct ``created_at``): the ``read`` result's
|
|
SECOND line is the stored date (the UTC date part), the FIRST line
|
|
stays the byte-identical header, and the content follows whole."""
|
|
created = _doc(
|
|
db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT", D2
|
|
)
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "read", {"path": "Alpha/deep/nested/doc.md"})
|
|
|
|
content = llm.requests[1][0][3]["content"]
|
|
lines = content.splitlines()
|
|
assert lines[0] == "Document Alpha/deep/nested/doc.md:" # byte-identical header
|
|
assert lines[1] == f"date: {D2_STR}" # the STORED date (row's UTC date part)
|
|
assert lines[2:] == ["FULL-TEXT"]
|
|
# SEC-14-04: short-lived session loads fresh copies
|
|
assert [d.id for d in holder.read_docs] == [created.id]
|
|
assert holder.tool_calls == 1
|
|
|
|
|
|
def test_read_result_date_is_the_row_date_not_a_constant(kb, db) -> None:
|
|
"""Two rows with DISTINCT dates: each ``read`` renders its OWN
|
|
row's date (a late-UTC instant renders its date part, no time)."""
|
|
a = _doc(db, "Alpha", "a.md", "A", "A-TEXT", D1)
|
|
b = _doc(db, "Alpha", "b.md", "B", "B-TEXT", D3)
|
|
db.commit()
|
|
|
|
holder_a, llm_a = _run_call(db, "read", {"path": "Alpha/a.md"})
|
|
assert llm_a.requests[1][0][3]["content"] == (
|
|
f"Document Alpha/a.md:\ndate: {D1_STR}\nA-TEXT"
|
|
)
|
|
holder_b, llm_b = _run_call(db, "read", {"path": "Alpha/b.md"})
|
|
assert llm_b.requests[1][0][3]["content"] == (
|
|
f"Document Alpha/b.md:\ndate: {D3_STR}\nB-TEXT"
|
|
)
|
|
# SEC-14-04: short-lived sessions
|
|
assert [d.id for d in holder_a.read_docs] == [a.id]
|
|
assert [d.id for d in holder_b.read_docs] == [b.id]
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# ls — every FILE line carries its date in the appended field
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
def test_ls_drill_file_lines_carry_their_dates(kb, src, db) -> None:
|
|
"""A source drill against real rows: EVERY file line ends with the
|
|
appended `` | date: YYYY-MM-DD`` field — each row's OWN stored date
|
|
— while the header and subfolder lines stay date-free."""
|
|
_doc(db, "Homelab", "backups/cron.md", "Cron", "CRON", D1)
|
|
_doc(db, "Homelab", "backups/restic.md", "Restic", "RESTIC", D2)
|
|
_doc(db, "Homelab", "networking/lan.md", "LAN", "LAN", D3)
|
|
_doc(db, "Homelab", "readme.md", "Readme", "README", D1)
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "ls", {"path": "Homelab"})
|
|
|
|
content = llm.requests[1][0][3]["content"]
|
|
lines = content.splitlines()
|
|
# The root level: one direct file (readme.md — D1) + the two
|
|
# subfolder lines (date-free) + the date-free header.
|
|
assert lines[0] == "Homelab — 1 documents, 2 folders:"
|
|
assert lines[2] == " backups/ — 2 documents" # subfolder: no date
|
|
assert lines[3] == " networking/ — 1 documents" # subfolder: no date
|
|
assert lines[5] == (
|
|
f"source: Homelab | path: readme.md | title: Readme | date: {D1_STR}"
|
|
)
|
|
assert holder.tool_calls == 1
|
|
|
|
# Drill into backups: BOTH files list, each with its OWN date.
|
|
holder2, llm2 = _run_call(db, "ls", {"path": "Homelab/backups"})
|
|
lines2 = llm2.requests[1][0][3]["content"].splitlines()
|
|
assert lines2[0] == "Homelab/backups — 2 documents, 0 folders:"
|
|
assert lines2[2] == (
|
|
f"source: Homelab | path: backups/cron.md | title: Cron | date: {D1_STR}"
|
|
)
|
|
assert lines2[3] == (
|
|
f"source: Homelab | path: backups/restic.md | title: Restic | date: {D2_STR}"
|
|
)
|
|
assert holder2.tool_calls == 1
|
|
|
|
|
|
def test_ls_top_level_source_lines_carry_no_date(kb, db) -> None:
|
|
"""The top level (source lines) is UNCHANGED in shape — sources are
|
|
not documents, so no date rides them (only FILE lines do). The
|
|
registry is FRESH (truncated + the one source re-registered), so
|
|
the top level is exactly the one source block."""
|
|
db.execute(text("TRUNCATE git_sources"))
|
|
db.commit()
|
|
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git"))
|
|
db.commit()
|
|
try:
|
|
_doc(db, "Homelab", "a.md", "A", "A-TEXT", D1)
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "ls", {})
|
|
|
|
content = llm.requests[1][0][3]["content"]
|
|
assert content == "1 sources:\n\nHomelab — 1 documents"
|
|
assert "date" not in content
|
|
assert holder.tool_calls == 1
|
|
finally:
|
|
db.execute(text("TRUNCATE git_sources"))
|
|
db.commit()
|