fix: refactors and optimisations

This commit is contained in:
Alex committed 2026-08-20 13:56:37 +01:00
1 parent e6fb46560c
commit 82ac2dcee9
14 files changed
+497 -144

No files matched your search

+39 -20
View File
@@ -15,8 +15,6 @@ from __future__ import annotations
import logging
import signal
import threading
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FutureTimeoutError
from typing import Any, Callable, Dict, List, Optional
from celery import current_task
@@ -318,14 +316,22 @@ class ReadDocumentTool(Tool):
@staticmethod
def _run_with_sigalrm(fn: Callable[[], Any], timeout: float) -> Any:
"""Run ``fn`` with a SIGALRM deadline, always restoring the handler and cancelling the timer."""
completed = False
def _on_alarm(signum: int, frame: Any) -> None:
# A straggler racing the disarm below: ``fn`` already returned, and
# raising here would escape the ``finally`` and replace its value
# with a timeout for a document that parsed fine.
if completed:
return
raise _InlineParseTimeout()
previous = signal.signal(signal.SIGALRM, _on_alarm)
signal.setitimer(signal.ITIMER_REAL, timeout)
try:
return fn()
result = fn()
completed = True
return result
finally:
# Nested so the handler is restored even if the alarm lands in this very
# window (fn returned just as the timer expired).
@@ -336,24 +342,37 @@ class ReadDocumentTool(Tool):
@staticmethod
def _run_in_thread(fn: Callable[[], Any], timeout: float) -> Any:
"""Run ``fn`` in a helper thread and stop waiting at ``timeout`` (the parse is NOT killed)."""
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="read-document-inline")
try:
future = executor.submit(fn)
"""Run ``fn`` in a daemon helper thread, giving up at ``timeout`` (the parse is NOT killed)."""
# A raw daemon thread rather than a ThreadPoolExecutor: executor threads
# are non-daemon and registered with ``concurrent.futures``' atexit hook,
# which joins them -- so an abandoned parse would hold up worker
# shutdown for the rest of its (size-scaled) window. Same reasoning as
# application/guardrails/engine.py. ``shutdown(cancel_futures=True)``
# is not an alternative: it only drops queued work items, never the one
# already running.
slot: Dict[str, Any] = {}
def _fill() -> None:
try:
return future.result(timeout=timeout)
except FutureTimeoutError:
if future.done(): # ``fn`` itself raised a TimeoutError; surface it as a failure
raise
logger.warning(
"read_document: inline parse exceeded %.0fs off the main thread; the parse thread "
"keeps running until the parser finishes (it cannot be interrupted)",
timeout,
)
raise _InlineParseTimeout() from None
finally:
# Never block the agent run on the orphaned parse.
executor.shutdown(wait=False)
slot["value"] = fn()
except BaseException as exc: # noqa: BLE001 - re-raised on the caller's thread
slot["error"] = exc
thread = threading.Thread(target=_fill, daemon=True, name="read-document-inline")
thread.start()
thread.join(timeout)
if thread.is_alive():
logger.warning(
"read_document: inline parse exceeded %.0fs off the main thread; the parse thread "
"keeps running until the parser finishes (it cannot be interrupted)",
timeout,
)
raise _InlineParseTimeout()
# ``fn`` raising TimeoutError is a parse failure, not our deadline; with
# join()+is_alive() the two are distinguishable without a done() probe.
if "error" in slot:
raise slot["error"]
return slot.get("value")
# ------------------------------------------------------------------
# Input resolution (run-scoped gate, before enqueue)
+13 -5
View File
@@ -77,12 +77,20 @@ def _bypasses_cache(extra: dict | None) -> bool:
return any(extra.get(key) for key in _CACHE_BYPASS_KWARGS)
# ``gen_cache`` and ``stream_cache`` hash the same (messages, model, kwargs)
# tuple, so they would collide on any call served both ways: whichever wrote
# last replaced the other's value. Namespacing the key is what keeps the two
# payload shapes apart -- sniffing the shape on read cannot, because the write
# side has already destroyed the other entry by then.
_GEN_KEY_PREFIX = "gen:"
_STREAM_KEY_PREFIX = "stream:"
def _is_stream_payload(raw: str) -> bool:
"""Whether a cached value is a ``stream_cache`` chunk envelope.
``gen_cache`` and ``stream_cache`` share one key space, so a
non-streaming read can land on a stream entry; without this guard the
serialized chunk list would be handed back as the model's answer.
Belt-and-braces behind the key prefixes above: a stream envelope must
never be handed back as a non-streaming answer, whatever put it there.
"""
if not raw.startswith("{"):
return False
@@ -220,7 +228,7 @@ def gen_cache(func):
return func(self, model, messages, stream, tools, *args, **kwargs)
try:
cache_key = gen_cache_key(messages, model, tools, extra=kwargs)
cache_key = f"{_GEN_KEY_PREFIX}{gen_cache_key(messages, model, tools, extra=kwargs)}"
except ValueError as e:
logger.error(f"Cache key generation failed: {e}")
return func(self, model, messages, stream, tools, *args, **kwargs)
@@ -255,7 +263,7 @@ def stream_cache(func):
return
try:
cache_key = gen_cache_key(messages, model, tools, extra=kwargs)
cache_key = f"{_STREAM_KEY_PREFIX}{gen_cache_key(messages, model, tools, extra=kwargs)}"
except ValueError as e:
logger.error(f"Cache key generation failed: {e}")
yield from func(self, model, messages, stream, tools, *args, **kwargs)
+6 -12
View File
@@ -24,10 +24,12 @@ from application.vectorstore import pgconn
DEFAULT_NAME_EMBEDDING_DIM = 768
# Mirrors ``PGVectorStore.DEFAULT_POOL_MAX_SIZE``; duplicated rather than
# imported so the graph store never pulls the vector store (and its embeddings
# stack) in at import time. 0 disables pooling.
DEFAULT_POOL_MAX_SIZE = 8
# Bound here (same objects, not copies) from the shared pool module, which both
# stores already import. Reaching through ``pgvector`` instead would drag the
# embeddings stack in at import time; ``pgconn`` imports nothing heavier than
# ``logging`` and ``threading``. 0 disables pooling.
DEFAULT_POOL_MAX_SIZE = pgconn.DEFAULT_POOL_MAX_SIZE
_resolve_pool_max_size = pgconn.resolve_pool_max_size
MAX_SUBGRAPH_NODES = 500
MAX_SUBGRAPH_EDGES = 2000
@@ -38,14 +40,6 @@ GRAPH_OVERVIEW_MAX_LIMIT = 250
PGVECTOR_SOURCE_COLUMN = "source_id"
def _resolve_pool_max_size() -> int:
"""Pool size from settings, defensively — 0 means one direct connection."""
value = getattr(settings, "PGVECTOR_POOL_MAX_SIZE", DEFAULT_POOL_MAX_SIZE)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return value
return DEFAULT_POOL_MAX_SIZE
def _safe_identifier(name: str) -> str:
"""Return ``name`` if it is a bare SQL identifier, else raise.
+5 -2
View File
@@ -4,6 +4,7 @@ import io
import json
import logging
import os.path
from typing import Any, Callable
from openai import BadRequestError, OpenAI
@@ -638,7 +639,7 @@ class OpenAILLM(BaseLLM):
return normalized
return ""
def _note_tools_rejected(self, model, error) -> None:
def _note_tools_rejected(self, model: str, error: Exception) -> None:
"""Record that this endpoint refuses tool calling, warning once."""
if not self._tools_rejected:
logging.warning(
@@ -661,7 +662,9 @@ class OpenAILLM(BaseLLM):
if key not in _TOOL_REQUEST_KEYS
}
def _create_with_tool_fallback(self, create, params: dict, model):
def _create_with_tool_fallback(
self, create: Callable[..., Any], params: dict, model: str
) -> Any:
"""Send a request, retrying once without tools on a tools-unsupported 400.
Args:
+12 -56
View File
@@ -4,14 +4,7 @@ from typing import Any, Dict, List, Optional, Tuple
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.retriever.base import BaseRetriever
from application.retriever.fanout import ( # noqa: F401 (re-exported)
DEFAULT_MAX_PARALLEL_SOURCES,
EMBEDDING_ATTRS as _EMBEDDING_ATTRS,
embed_questions,
max_parallel_sources,
run_source_jobs,
store_embeddings,
)
from application.retriever.fanout import fetch_per_source, max_parallel_sources
from application.retriever.labels import labels_from_metadata
from application.utils import num_tokens_from_string
from application.vectorstore.vector_creator import VectorCreator
@@ -260,17 +253,6 @@ class ClassicRAG(BaseRetriever):
)
return plans
@staticmethod
def _store_embeddings(docsearch):
"""Return the embeddings object a vector store searches with, if any."""
return store_embeddings(docsearch)
def _embed_questions(
self, docsearch, questions: List[str]
) -> Dict[str, List[float]]:
"""Embed each distinct query once for the whole retrieval."""
return embed_questions(docsearch, questions)
def _search_source(
self,
plan: Dict[str, Any],
@@ -309,44 +291,18 @@ class ClassicRAG(BaseRetriever):
) -> List[Optional[Tuple[Any, Optional[str]]]]:
"""Fetch every source's candidates, one embedding and one fan-out.
The first store is built on the calling thread because its embeddings
object supplies the shared query vector — and priming the embeddings
singleton there keeps the workers off a concurrent model load. The
searches then run on a bounded pool, and results come back in the
original source order so the merge stays deterministic.
Shares :func:`~application.retriever.fanout.fetch_per_source` with the
search service so both paths order, embed and degrade identically.
"""
first_store = None
try:
first_store = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, plans[0]["id"], settings.EMBEDDINGS_KEY
)
except Exception as e:
logger.error(
f"Error searching vectorstore {plans[0]['id']}: {e}", exc_info=True
)
if first_store is None:
# The first source is already a logged failure; the rest still run,
# each embedding its own query (there is no store to borrow one from).
return [None] + self._run_jobs([(plan, None, None) for plan in plans[1:]])
questions = list(dict.fromkeys(plan["question"] for plan in plans))
vectors = self._embed_questions(first_store, questions)
return self._run_jobs(
[
(plan, first_store if idx == 0 else None, vectors.get(plan["question"]))
for idx, plan in enumerate(plans)
]
)
def _run_jobs(self, jobs: List[Tuple]) -> List[Optional[Tuple[Any, Optional[str]]]]:
"""Run the per-source search jobs, concurrently when there are several."""
if not jobs:
return []
return run_source_jobs(
lambda job: self._search_source(*job),
jobs,
workers=_max_parallel_sources(len(jobs)),
return fetch_per_source(
plans,
lambda plan: VectorCreator.create_vectorstore(
settings.VECTOR_STORE, plan["id"], settings.EMBEDDINGS_KEY
),
self._search_source,
lambda plan: plan["question"],
label_of=lambda plan: plan["id"],
workers_for=_max_parallel_sources,
)
def _get_data(self):
+63
View File
@@ -125,3 +125,66 @@ def run_source_jobs(
max_workers=workers, thread_name_prefix="rag-source"
) as pool:
return list(pool.map(fn, jobs))
def fetch_per_source(
items: List[Job],
build_store: Callable[[Job], Any],
search: Callable[[Job, Optional[Any], Optional[List[float]]], Result],
question_of: Callable[[Job], str],
label_of: Callable[[Job], Any] = str,
workers_for: Optional[Callable[[int], int]] = None,
) -> List[Optional[Result]]:
"""Search every source: one store built up front, one embedding, one fan-out.
The first store is built on the calling thread because its embeddings
object supplies the shared query vector — and priming the embeddings
singleton there keeps the workers off a concurrent model load. The searches
then run on a bounded pool, and results come back in the original source
order so the caller's merge stays deterministic.
Args:
items: One entry per source, in the order results must come back.
build_store: Builds the vector store for an item; may raise.
search: ``(item, store_or_None, query_vector_or_None) -> result``. It
must swallow its own errors and report failure as ``None``, since a
raise here aborts the whole fan-out.
question_of: The query for an item, used to look up its shared vector.
label_of: Item identifier for the store-construction error log.
workers_for: Pool size from the job count; defaults to
:func:`max_parallel_sources`.
Returns:
list: One entry per item, in ``items`` order. ``None`` marks a source
whose store could not be built or whose search reported failure.
"""
items = list(items)
if not items:
return []
def _dispatch(jobs: List[Any]) -> List[Optional[Result]]:
if not jobs:
return []
workers = workers_for(len(jobs)) if workers_for is not None else None
return run_source_jobs(lambda job: search(*job), jobs, workers=workers)
first_store = None
try:
first_store = build_store(items[0])
except Exception as e:
logger.error(
"Error searching vectorstore %s: %s", label_of(items[0]), e, exc_info=True
)
if first_store is None:
# The first source is already a logged failure; the rest still run,
# each embedding its own query (there is no store to borrow one from).
return [None] + _dispatch([(item, None, None) for item in items[1:]])
vectors = embed_questions(first_store, [question_of(item) for item in items])
return _dispatch(
[
(item, first_store if idx == 0 else None, vectors.get(question_of(item)))
for idx, item in enumerate(items)
]
)
+10 -3
View File
@@ -8,6 +8,8 @@ support (``keyword_search`` returns ``[]``) reduce to exact vector-only
behaviour.
"""
from typing import Any, List, Optional
from application.retriever.classic_rag import ClassicRAG
RRF_K = 60
@@ -49,15 +51,20 @@ def fuse_with_scores(vector_hits, keyword_hits, k=RRF_K):
class HybridRetriever(ClassicRAG):
"""ClassicRAG variant that fuses vector + keyword search with RRF."""
def _score_kind(self, docsearch):
def _score_kind(self, docsearch: Any) -> str:
"""RRF scores rank hits against each other, not against a similarity
cutoff — they are not comparable to the store's cosine scores, so they
carry their own label."""
return "rrf"
def _fetch_candidates(
self, docsearch, question, src_k, score_threshold, query_vector=None
):
self,
docsearch: Any,
question: str,
src_k: int,
score_threshold: Optional[float],
query_vector: Optional[List[float]] = None,
) -> List[Any]:
"""Return RRF-fused vector+keyword hits for one vector store.
Inherits the per-source resolution and budgeting from
+13 -35
View File
@@ -11,7 +11,7 @@ import logging
from typing import Any, Dict, List, Optional
from application.core.settings import settings
from application.retriever.fanout import embed_questions, run_source_jobs
from application.retriever.fanout import fetch_per_source
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.session import db_readonly
from application.vectorstore.vector_creator import VectorCreator
@@ -125,41 +125,19 @@ def _fetch_sources(
) -> List[Optional[List[Any]]]:
"""Fetch every source's hits: one query embedding, one bounded fan-out.
The first store is built on the calling thread because its embeddings
object supplies the shared query vector — and priming the embeddings
singleton there keeps the workers off a concurrent model load. Results come
back in the original source order so the merge stays deterministic.
Shares :func:`~application.retriever.fanout.fetch_per_source` with
``ClassicRAG`` so the search route and the answer path order, embed and
degrade identically.
"""
def _job(job) -> Optional[List[Any]]:
source_id, docsearch, query_vector = job
return _search_one(source_id, docsearch, query, k, query_vector)
first_store = None
try:
first_store = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_ids[0], settings.EMBEDDINGS_KEY
)
except Exception as e:
logger.error(
f"Error searching vectorstore {source_ids[0]}: {e}",
exc_info=True,
)
if first_store is None:
# The first source is already a logged failure; the rest still run,
# each embedding its own query (there is no store to borrow one from).
return [None] + run_source_jobs(
_job, [(sid, None, None) for sid in source_ids[1:]]
)
vector = embed_questions(first_store, [query]).get(query)
return run_source_jobs(
_job,
[
(sid, first_store if idx == 0 else None, vector)
for idx, sid in enumerate(source_ids)
],
return fetch_per_source(
source_ids,
lambda source_id: VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_id, settings.EMBEDDINGS_KEY
),
lambda source_id, docsearch, query_vector: _search_one(
source_id, docsearch, query, k, query_vector
),
lambda _source_id: query,
)
+23
View File
@@ -21,6 +21,12 @@ from typing import Any, Dict
# Seconds a caller waits for a free pooled connection before giving up.
POOL_TIMEOUT_SECONDS = 30.0
# Physical connections one process may hold per DSN. Lives here, not in either
# store: ``pool_for`` keys its registry by DSN and applies ``max_size`` only on
# creation, so two stores disagreeing about the default would make the
# effective pool size depend on which of them touched the DSN first.
DEFAULT_POOL_MAX_SIZE = 8
# dsn -> psycopg_pool.ConnectionPool, one per process.
_POOLS: Dict[str, Any] = {}
_POOLS_LOCK = threading.Lock()
@@ -42,6 +48,23 @@ def configure_pooled_connection(conn) -> None:
logging.debug("pgvector types not registered yet: %s", e)
def resolve_pool_max_size() -> int:
"""Pool size from settings, defensively — 0 means one direct connection.
Returns:
int: ``PGVECTOR_POOL_MAX_SIZE`` when it is a non-negative, non-bool
int, else :data:`DEFAULT_POOL_MAX_SIZE`. The unit suite replaces
``settings`` with a MagicMock, whose attributes must never become
a pool size.
"""
from application.core.settings import settings
value = getattr(settings, "PGVECTOR_POOL_MAX_SIZE", DEFAULT_POOL_MAX_SIZE)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return value
return DEFAULT_POOL_MAX_SIZE
def pool_for(dsn: str, max_size: int):
"""Return this process's connection pool for ``dsn``, creating it lazily.
+8 -9
View File
@@ -14,7 +14,6 @@ from application.vectorstore.document_class import Document
_IVFFLAT_LISTS_CACHE: Dict[str, Optional[int]] = {}
DEFAULT_EMBEDDING_DIM = 768
DEFAULT_POOL_MAX_SIZE = 8
# Advisory-lock key shared with the boot hook so concurrent workers serialize DDL.
SCHEMA_LOCK_KEY = "docsgpt:vectors:ddl"
@@ -23,6 +22,7 @@ SCHEMA_LOCK_KEY = "docsgpt:vectors:ddl"
# here — same objects, not copies — because callers and tests reach for
# ``pgvector._POOLS`` / patch ``pgvector._pool_for``.
POOL_TIMEOUT_SECONDS = pgconn.POOL_TIMEOUT_SECONDS
DEFAULT_POOL_MAX_SIZE = pgconn.DEFAULT_POOL_MAX_SIZE
_POOLS = pgconn._POOLS
_POOLS_LOCK = pgconn._POOLS_LOCK
_configure_pooled_connection = pgconn.configure_pooled_connection
@@ -87,13 +87,8 @@ class PGVectorStore(BaseVectorStore):
# a safety net for processes that never ran the boot hook.
self._schema_ensured = False
@staticmethod
def _resolve_pool_max_size() -> int:
"""Pool size from settings, defensively — 0 means one direct connection."""
value = getattr(settings, "PGVECTOR_POOL_MAX_SIZE", DEFAULT_POOL_MAX_SIZE)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return value
return DEFAULT_POOL_MAX_SIZE
# Shared with ``GraphStore`` via pgconn so the two can never disagree.
_resolve_pool_max_size = staticmethod(pgconn.resolve_pool_max_size)
def _get_connection(self):
"""Get or create this store's connection, pooled unless pooling is off."""
@@ -117,7 +112,11 @@ class PGVectorStore(BaseVectorStore):
return self._connection
def _register_pgvector_types(self, conn) -> None:
"""Register pgvector's adapters, tolerating a not-yet-created extension."""
"""Register pgvector's adapters, tolerating a not-yet-created extension.
Kept as a method rather than calling :func:`pgconn.configure_pooled_connection`
directly because ``self._register_vector`` is the seam the tests patch.
"""
try:
self._register_vector(conn)
except Exception as e:
@@ -481,6 +481,79 @@ def test_inline_parse_times_out_on_the_signal_path(monkeypatch, caplog):
assert any("timed out" in rec.getMessage() for rec in caplog.records if rec.levelname == "WARNING")
@pytest.mark.unit
def test_a_straggler_alarm_does_not_discard_a_finished_parse(monkeypatch):
"""The alarm can land between ``fn()`` returning and the disarm call.
``_InlineParseTimeout`` is a BaseException, so nothing catches it locally:
raised from the ``finally`` it replaces the return value, and the caller
reports a timeout for a document that parsed correctly.
"""
real_setitimer = signal.setitimer
def _fire_on_disarm(which, seconds, *rest):
# Reproduce the race deterministically: deliver SIGALRM at the exact
# instant the timer is being cancelled.
if seconds == 0:
handler = signal.getsignal(signal.SIGALRM)
if callable(handler):
handler(signal.SIGALRM, None)
return real_setitimer(which, seconds, *rest)
monkeypatch.setattr(signal, "setitimer", _fire_on_disarm)
parsed = {"status": "ok", "content": "the document really did parse"}
assert rd.ReadDocumentTool._run_with_sigalrm(lambda: parsed, 30.0) == parsed
@pytest.mark.unit
def test_a_genuine_sigalrm_timeout_still_raises(monkeypatch):
"""The completion flag must not disarm the guard itself."""
with pytest.raises(rd._InlineParseTimeout):
rd.ReadDocumentTool._run_with_sigalrm(lambda: time.sleep(1.0), 0.15)
assert signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0)
@pytest.mark.unit
def test_the_inline_helper_thread_is_a_daemon():
"""A timed-out parse is abandoned, so its thread must not outlive the process.
``concurrent.futures`` registers its (non-daemon) workers with an atexit
hook that joins them, which is exactly why application/guardrails/engine.py
uses raw daemon threads for the same abandon-on-timeout shape.
"""
seen: Dict[str, Any] = {}
def _capture():
seen["thread"] = threading.current_thread()
return {"status": "ok"}
assert rd.ReadDocumentTool._run_in_thread(_capture, 5.0) == {"status": "ok"}
assert seen["thread"].name.startswith("read-document-inline")
assert seen["thread"].daemon is True
@pytest.mark.unit
def test_the_thread_path_propagates_a_parse_failure():
"""An error raised by ``fn`` surfaces as itself, not as a timeout."""
def _boom():
raise RuntimeError("parser exploded")
with pytest.raises(RuntimeError, match="parser exploded"):
rd.ReadDocumentTool._run_in_thread(_boom, 5.0)
@pytest.mark.unit
def test_the_thread_path_does_not_mistake_a_parse_timeout_for_its_own():
"""``fn`` raising TimeoutError is a failure, not the deadline elapsing."""
def _raises_timeout():
raise TimeoutError("the parser's own network timeout")
with pytest.raises(TimeoutError, match="own network timeout"):
rd.ReadDocumentTool._run_in_thread(_raises_timeout, 5.0)
@pytest.mark.unit
def test_inline_parse_times_out_on_the_thread_path(monkeypatch, caplog):
"""Off the main thread (gevent/threads pools, Windows) the timer is unusable."""
+127
View File
@@ -15,6 +15,7 @@ import pytest
from application.retriever.fanout import (
DEFAULT_MAX_PARALLEL_SOURCES,
embed_questions,
fetch_per_source,
max_parallel_sources,
run_source_jobs,
store_embeddings,
@@ -174,3 +175,129 @@ class TestEmbedQuestions:
embedder.embed_query = Mock(side_effect=RuntimeError("model down"))
store = SimpleNamespace(_embedding=embedder)
assert embed_questions(store, ["q"]) == {}
def _fanout_store(vector=(9.0,)):
"""A store whose embedder returns a fixed, recognisable vector."""
embedder = Mock()
embedder.embed_query = Mock(return_value=list(vector))
return SimpleNamespace(_embedding=embedder)
@pytest.mark.unit
class TestFetchPerSource:
"""The whole per-source fan-out both ClassicRAG and the search service use."""
def test_only_the_first_store_is_built_on_the_calling_thread(self):
built = []
store = _fanout_store()
def _build(item):
built.append(item)
return store
seen = []
results = fetch_per_source(
["a", "b", "c"],
_build,
lambda item, docsearch, vector: seen.append((item, docsearch, vector))
or f"hit-{item}",
lambda item: "q",
)
assert results == ["hit-a", "hit-b", "hit-c"]
# One construction up front; the workers build their own.
assert built == ["a"]
assert seen[0][1] is store
assert [s[1] for s in seen[1:]] == [None, None]
def test_the_query_is_embedded_once_for_every_source(self):
store = _fanout_store(vector=(7.0,))
vectors = []
fetch_per_source(
["a", "b", "c"],
lambda item: store,
lambda item, docsearch, vector: vectors.append(vector),
lambda item: "same question",
)
assert vectors == [[7.0], [7.0], [7.0]]
assert store._embedding.embed_query.call_count == 1
def test_per_item_questions_get_their_own_vectors(self):
embedder = Mock()
embedder.embed_query = Mock(side_effect=lambda q: [len(q)])
store = SimpleNamespace(_embedding=embedder)
vectors = []
fetch_per_source(
["ab", "cde"],
lambda item: store,
lambda item, docsearch, vector: vectors.append(vector),
lambda item: item,
)
assert vectors == [[2], [3]]
def test_results_keep_source_order_regardless_of_finish_order(self):
store = _fanout_store()
started = threading.Barrier(3, timeout=5)
def _search(item, docsearch, vector):
started.wait() # force genuine interleaving
return item
results = fetch_per_source(
["a", "b", "c"], lambda item: store, _search, lambda item: "q",
)
assert results == ["a", "b", "c"]
def test_a_failed_first_store_still_runs_the_rest(self):
def _build(item):
raise RuntimeError("index is gone")
results = fetch_per_source(
["a", "b", "c"],
_build,
lambda item, docsearch, vector: (item, docsearch, vector),
lambda item: "q",
)
# The first slot is a logged failure; the rest run store-less and
# vector-less, so each embeds its own query exactly as before.
assert results[0] is None
assert results[1:] == [("b", None, None), ("c", None, None)]
def test_no_items_is_not_a_fan_out(self):
build = Mock()
assert fetch_per_source([], build, Mock(), Mock()) == []
build.assert_not_called()
def test_worker_cap_is_delegated_to_the_caller(self):
store = _fanout_store()
widths = []
fetch_per_source(
["a", "b", "c"],
lambda item: store,
lambda item, docsearch, vector: item,
lambda item: "q",
workers_for=lambda n: widths.append(n) or 1,
)
assert widths == [3]
@pytest.mark.unit
class TestBothCallersShareTheFanOut:
"""The duplication this helper replaced must not creep back in."""
def test_classic_rag_and_the_search_service_both_delegate(self):
import application.retriever.classic_rag as classic_rag
import application.services.search_service as search_service
assert classic_rag.fetch_per_source is fetch_per_source
assert search_service.fetch_per_source is fetch_per_source
+68 -2
View File
@@ -895,12 +895,12 @@ def test_stream_cache_bypassed_for_previous_response_id(mock_make_redis):
@pytest.mark.unit
@patch("application.cache.get_redis_instance")
def test_gen_cache_ignores_stream_payload_stored_under_same_key(mock_make_redis):
"""``gen_cache`` and ``stream_cache`` share a key space; a stream
"""Belt-and-braces: even planted directly under the gen key, a stream
envelope must never be handed back as a non-streaming answer."""
fake = _FakeRedis()
mock_make_redis.return_value = fake
messages = [{"role": "user", "content": "test"}]
key = gen_cache_key(messages, "m", None)
key = f"gen:{gen_cache_key(messages, 'm', None)}"
fake.set(key, json.dumps({"version": 1, "chunks": ["a", "b"]}))
@gen_cache
@@ -909,3 +909,69 @@ def test_gen_cache_ignores_stream_payload_stored_under_same_key(mock_make_redis)
result = mock_function(None, "m", messages, stream=False, tools=None)
assert result == "real answer"
@pytest.mark.unit
@patch("application.cache.get_redis_instance")
def test_gen_and_stream_caches_do_not_share_a_key_space(mock_make_redis):
"""A gen write must not overwrite the stream envelope for the same call.
Both wrappers hash the same (messages, model, kwargs) tuple, so before the
namespacing they collided: the gen write replaced the envelope with a bare
string and the next stream read failed to decode it.
"""
fake = _FakeRedis()
mock_make_redis.return_value = fake
messages = [{"role": "user", "content": "test"}]
@stream_cache
def streamer(self, model, messages, stream, tools, **kwargs):
yield "alpha"
yield "beta"
@gen_cache
def generator(self, model, messages, stream, tools, **kwargs):
return "a plain answer"
assert list(streamer(None, "m", messages, stream=True, tools=None)) == [
"alpha", "beta",
]
assert generator(None, "m", messages, stream=False, tools=None) == "a plain answer"
upstream_calls = []
@stream_cache
def streamer_again(self, model, messages, stream, tools, **kwargs):
upstream_calls.append(1)
yield "SHOULD NOT REACH UPSTREAM"
replayed = list(streamer_again(None, "m", messages, stream=True, tools=None))
assert replayed == ["alpha", "beta"]
assert not upstream_calls
@pytest.mark.unit
@patch("application.cache.get_redis_instance")
def test_a_json_array_answer_is_never_replayed_as_stream_chunks(mock_make_redis):
"""A gen answer that happens to be a JSON array is not a chunk list.
The stream reader's pre-v1 compatibility branch accepts a bare JSON array,
so a colliding gen entry used to be replayed verbatim as the streamed
answer -- silent corruption rather than a cache miss.
"""
fake = _FakeRedis()
mock_make_redis.return_value = fake
messages = [{"role": "user", "content": "test"}]
@gen_cache
def generator(self, model, messages, stream, tools, **kwargs):
return '["alpha", "beta"]'
generator(None, "m", messages, stream=False, tools=None)
@stream_cache
def streamer(self, model, messages, stream, tools, **kwargs):
yield "fresh"
assert list(streamer(None, "m", messages, stream=True, tools=None)) == ["fresh"]
@@ -99,6 +99,43 @@ class TestConstructionTouchesNothing:
assert store._pool_max_size == 8
@pytest.mark.unit
class TestPoolSizingHasOneHome:
"""``pgconn`` owns the pool defaults; both stores bind to it, not copies.
``pool_for`` keys its registry by DSN and honours ``max_size`` only when it
first builds the pool, so if the vector store and the graph store carried
their own defaults the effective size would depend on which one touched
the DSN first.
"""
def test_both_stores_share_one_implementation(self):
from application.graphrag import store as store_module
from application.vectorstore import pgconn
assert pgvector_module.DEFAULT_POOL_MAX_SIZE is pgconn.DEFAULT_POOL_MAX_SIZE
assert store_module.DEFAULT_POOL_MAX_SIZE is pgconn.DEFAULT_POOL_MAX_SIZE
assert store_module._resolve_pool_max_size is pgconn.resolve_pool_max_size
assert (
pgvector_module.PGVectorStore._resolve_pool_max_size
is pgconn.resolve_pool_max_size
)
@pytest.mark.parametrize(
"value,expected",
[(0, 0), (2, 2), (None, 8), ("4", 8), (True, 8), (-1, 8)],
)
def test_pool_size_is_resolved_defensively(self, monkeypatch, value, expected):
from application.core import settings as settings_module
from application.vectorstore import pgconn
monkeypatch.setattr(
settings_module.settings, "PGVECTOR_POOL_MAX_SIZE", value, raising=False
)
assert pgconn.resolve_pool_max_size() == expected
@pytest.mark.unit
class TestWritePathEnsuresSchemaOnce:
def test_add_texts_ensures_once_across_two_calls(self):