Files
DocsGPT/tests/conftest.py
T
Alex 47e53a71c4 feat: embed on the worker, and stop batching the ONNX pass
The API embeds every query it serves, so it held its own copy of the model:
~890 MB it never needed. EMBEDDINGS_DELEGATE_TO_WORKER (on by default) sends
the text to the Celery worker instead and gets the vector back, taking an API
process from 1176 MB to 285 MB with no ONNX Runtime imported at all. The client
embeds locally when it finds itself inside a worker task, so the worker never
dispatches to itself -- the same self-deadlock DOCUMENT_PARSE_QUEUE avoids on
the parsing side. EMBEDDINGS_BASE_URL still wins over it, and remains the right
answer for production.

ensure_vector_schema was constructing the embeddings instance purely to read
.dimension off it, loading several hundred MB of ONNX into every API and worker
process at import. For a model the registry describes that is a lookup; only an
unregistered name now falls back to loading.

EMBEDDINGS_BATCH_SIZE was sizing two unrelated things: chunks per store
transaction (and per remote embed request) and documents per ONNX forward pass.
Each pass pads every input up to its longest, and that waste grows with the
square of chunk length, so at the 1250-token default a batch of 32 peaked at
6.6 GB and took 326s where a batch of 1 peaked at 2.9 GB and took 90s. The
forward pass is now sized by EMBEDDINGS_MODEL_BATCH_SIZE, defaulting to 1;
storage and remote batching are unchanged at 32.

reembed embeds in-process: a batch job that walks the whole index should not
round-trip every chunk through a broker, and loading the model there reports a
real failure instead of timing out against an empty queue.

Also drops the mpnet zip download from the docs and the devcontainer, which
pointed at a SentenceTransformers export with no ONNX graph and had been inert
since the FastEmbed swap; corrects the claim that any sentence-transformers
model works; and settles the Configuring/Settings pages on what the registry
and the repository metadata actually decide.
2026-08-28 12:23:18 +01:00

326 lines
11 KiB
Python

"""Root pytest fixtures for the DocsGPT backend suite.
Postgres fixture strategy
-------------------------
Regular unit tests get a Postgres connection from the ``pg_conn`` fixture
below, which is backed by ``pytest-postgresql``. That plugin spins up an
ephemeral ``pg_ctl``-managed cluster in a temp directory and tears it
down at the end of the session, so CI only needs Postgres *binaries*
installed, not a running service.
Alembic migrations run ONCE per session (per xdist worker) into the
cluster's template database via the ``load=`` hook on ``postgresql_proc``.
Every per-test database handed out by the ``postgresql`` fixture is then
cloned from that template (``CREATE DATABASE .. TEMPLATE ..`` — a cheap
file-level copy), so each test still starts from a pristine schema at
head without replaying the migration chain.
Tests under ``tests/storage/db/`` intentionally override ``pg_conn`` in
their own conftest to point at a real, long-running Postgres instance
(DBngin locally, a service container in CI). Those are integration/e2e
tests and are marked with ``@pytest.mark.integration``.
No mongomock. The ``mock_mongo_db`` fixture that used to live here was
removed as part of the Mongo→Postgres cutover. Tests that still
reference it will fail with "fixture not found" until the corresponding
route handler is migrated to a repository read.
"""
from __future__ import annotations
import os
# Disable the app's self-bootstrap (AUTO_CREATE_DB / AUTO_MIGRATE) before
# any ``application.*`` module is imported. ``application/app.py`` runs
# ``ensure_database_ready`` at import time using whatever ``POSTGRES_URI``
# is set in the environment — which in dev is the operator's local DB, not
# the ephemeral ``pytest-postgresql`` cluster that the fixtures below spin
# up. Tests manage their own schema via the ``pg_engine`` fixture
# (subprocess ``alembic upgrade head`` against the per-test URI), so the
# import-time bootstrap would at best be redundant and at worst would
# mutate the operator's dev DB. ``setdefault`` so a test run can still
# opt back in by setting the env var explicitly.
os.environ.setdefault("AUTO_MIGRATE", "false")
os.environ.setdefault("AUTO_CREATE_DB", "false")
os.environ.setdefault("AUTO_VECTOR_SCHEMA", "false")
import subprocess
import sys
from pathlib import Path
from unittest.mock import Mock
import pytest
from pytest_postgresql import factories
from sqlalchemy import create_engine
# ---------------------------------------------------------------------------
# Postgres fixtures (ephemeral cluster via pytest-postgresql)
# ---------------------------------------------------------------------------
_ALEMBIC_INI = Path(__file__).resolve().parent.parent / "application" / "alembic.ini"
def _migrate_template_db(host, port, user, dbname, password) -> None:
"""Run alembic ``upgrade head`` into the session's template database.
Called once per session by pytest-postgresql's ``load=`` hook (against
the template DB, before any test runs). Runs in a subprocess so the
parent process never imports application settings with this URI cached.
"""
url = (
f"postgresql+psycopg://{user}:{password or ''}@{host}:{port}/{dbname}"
)
subprocess.check_call(
[sys.executable, "-m", "alembic", "-c", str(_ALEMBIC_INI), "upgrade", "head"],
timeout=120,
env={**os.environ, "POSTGRES_URI": url},
)
# ``postgresql_proc`` starts a fresh ``pg_ctl`` cluster once per session and
# migrates its template database (see ``_migrate_template_db``). ``postgresql``
# hands out a per-test DB cloned from that template. We layer our own
# SQLAlchemy engine + rolled-back transaction on top for test isolation.
postgresql_proc = factories.postgresql_proc(load=[_migrate_template_db])
postgresql = factories.postgresql("postgresql_proc")
def _sqlalchemy_url(pg_conn_info) -> str:
return (
"postgresql+psycopg://"
f"{pg_conn_info.user}:{pg_conn_info.password or ''}"
f"@{pg_conn_info.host}:{pg_conn_info.port}/{pg_conn_info.dbname}"
)
@pytest.fixture(scope="session")
def _alembic_ini_path() -> Path:
return Path(__file__).resolve().parent.parent / "application" / "alembic.ini"
@pytest.fixture()
def pg_engine(postgresql, monkeypatch):
"""Per-test SQLAlchemy engine against a fresh ephemeral Postgres DB.
The database is a clone of the session's already-migrated template
(see ``_migrate_template_db``), so the full schema is present without
running alembic here. ``POSTGRES_URI`` is patched in the environment
for the duration of the test so any code that reads it via
``application.core.settings`` sees the ephemeral DB.
"""
url = _sqlalchemy_url(postgresql.info)
monkeypatch.setenv("POSTGRES_URI", url)
# Reset the settings cache so the new POSTGRES_URI is picked up if the
# settings module is already imported.
from application.core import settings as settings_module
monkeypatch.setattr(settings_module.settings, "POSTGRES_URI", url, raising=False)
engine = create_engine(url)
yield engine
engine.dispose()
@pytest.fixture()
def pg_conn(pg_engine):
"""Per-test connection wrapped in a transaction that always rolls back."""
conn = pg_engine.connect()
txn = conn.begin()
yield conn
txn.rollback()
conn.close()
# ---------------------------------------------------------------------------
# Generic unit-test fixtures (no DB, no Mongo)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _no_real_redis(monkeypatch):
"""Force the Redis-absent baseline CI has.
CI runs without a Redis service, so ``get_redis_instance()`` /
``get_pubsub_redis_instance()`` return None there. A dev machine with
a live localhost Redis diverges: code under test silently reads and
writes real keys, leaking state between tests and between runs (seen
with the ``openai_inline_file:*`` upload cache). Flipping the
creation-failed flags makes the real accessors return None everywhere
regardless of import style — consumers that did ``from
application.cache import get_redis_instance`` still hit these module
globals at call time. Tests that want Redis behavior keep injecting
fakes by patching the accessor at the consumer module, which bypasses
this guard. A test targeting the accessor's own construction path
must reset the two flags first.
"""
monkeypatch.setattr("application.cache._redis_instance", None)
monkeypatch.setattr("application.cache._redis_creation_failed", True)
@pytest.fixture(autouse=True)
def _no_worker_delegation(monkeypatch):
"""Embed in-process during tests, the way CI has no worker to embed on.
``EMBEDDINGS_DELEGATE_TO_WORKER`` ships on, so an unmocked embed would
publish to a broker nobody is consuming and block for
``EMBEDDINGS_DELEGATE_TIMEOUT`` before failing -- a minute per call, and a
pass/fail that depends on whether the developer happens to have a worker
running. Tests covering delegation patch the setting back on themselves.
"""
from application.core.settings import settings
monkeypatch.setattr(settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False, raising=False)
monkeypatch.setattr("application.cache._pubsub_redis_instance", None)
monkeypatch.setattr("application.cache._pubsub_redis_creation_failed", True)
@pytest.fixture
def mock_llm():
llm = Mock()
llm.gen_stream = Mock()
llm._supports_tools = True
llm._supports_structured_output = Mock(return_value=False)
llm.__class__.__name__ = "MockLLM"
# Mirror BaseLLM.__init__: real LLMCreator stores the resolved
# upstream model name on self.model_id. Tests that build agents via
# ``mock_llm_creator`` rely on the agent's ``upstream_model_id``
# falling through to this attribute.
llm.model_id = "gpt-4"
return llm
@pytest.fixture
def mock_llm_handler():
handler = Mock()
handler.process_message_flow = Mock()
return handler
@pytest.fixture
def mock_retriever():
retriever = Mock()
retriever.search = Mock(
return_value=[
{"text": "Test document 1", "filename": "doc1.txt", "source": "test"},
{"text": "Test document 2", "title": "doc2.txt", "source": "test"},
]
)
return retriever
@pytest.fixture
def sample_chat_history():
return [
{"prompt": "What is Python?", "response": "Python is a programming language."},
{"prompt": "Tell me more.", "response": "Python is known for simplicity."},
]
@pytest.fixture
def sample_tool_call():
return {
"tool_name": "test_tool",
"call_id": "123",
"action_name": "test_action",
"arguments": {"arg1": "value1"},
"result": "Tool executed successfully",
}
@pytest.fixture
def decoded_token():
return {"sub": "test_user", "email": "test@example.com"}
@pytest.fixture
def log_context():
from application.logging import LogContext
context = LogContext(
endpoint="test_endpoint",
activity_id="test_activity",
user="test_user",
api_key="test_key",
query="test query",
)
return context
@pytest.fixture
def mock_llm_creator(mock_llm, monkeypatch):
monkeypatch.setattr(
"application.llm.llm_creator.LLMCreator.create_llm", Mock(return_value=mock_llm)
)
return mock_llm
@pytest.fixture
def mock_llm_handler_creator(mock_llm_handler, monkeypatch):
monkeypatch.setattr(
"application.llm.handlers.handler_creator.LLMHandlerCreator.create_handler",
Mock(return_value=mock_llm_handler),
)
return mock_llm_handler
@pytest.fixture
def agent_base_params(decoded_token):
return {
"endpoint": "https://api.example.com",
"llm_name": "openai",
"model_id": "gpt-4",
"api_key": "test_api_key",
"user_api_key": None,
"prompt": "You are a helpful assistant.",
"chat_history": [],
"decoded_token": decoded_token,
"attachments": [],
"json_schema": None,
}
@pytest.fixture
def mock_tool():
tool = Mock()
tool.execute_action = Mock(return_value="Tool result")
# Skip artifact-id capture in default mock so the recorded JSONB matches
# what tests expect; per-tool tests can override.
tool.get_artifact_id = None
tool.get_actions_metadata = Mock(
return_value=[
{
"name": "test_action",
"description": "A test action",
"parameters": {
"type": "object",
"properties": {
"param1": {"type": "string", "description": "Test parameter"}
},
"required": ["param1"],
},
}
]
)
return tool
@pytest.fixture
def mock_tool_manager(mock_tool, monkeypatch):
manager = Mock()
manager.load_tool = Mock(return_value=mock_tool)
monkeypatch.setattr(
"application.agents.tool_executor.ToolManager", Mock(return_value=manager)
)
return manager
@pytest.fixture
def flask_app():
from flask import Flask
app = Flask(__name__)
return app