mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
The OptimalService singleton published the instance before initialize() finished, so a concurrent caller could observe _initialized=False and hit "OptimalService not initialized" during RAG indexing. Build the instance, initialize it, then publish under a lazily-bound asyncio lock so all callers share a fully-initialized singleton. roboco_kb_search forwarded the legacy alias index_types=['docs'], which is not a valid IndexType value (the enum value is 'documentation'), producing a 400 at the route. Normalize the alias in the client before the request is sent and fix the misleading tool docstring. The mentor route let exceptions from mentor.ask escape as a bare 500 that masked the real cause. Catch, log the true upstream error with stack, and surface it in the response detail so failures are diagnosable.
138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
"""Grounding-layer regression tests for the Optimal/RAG stack.
|
|
|
|
Covers three failure modes that surfaced at runtime:
|
|
|
|
1. ``get_optimal_service`` published a not-yet-initialized singleton while
|
|
``initialize()`` was still awaiting, so a concurrent caller hit
|
|
"OptimalService not initialized. Call initialize() first." during indexing.
|
|
2. ``roboco_kb_search`` forwarded the legacy alias ``index_types=['docs']``,
|
|
which is not a valid ``IndexType`` value ('documentation' is), producing a
|
|
400 at the route.
|
|
3. The mentor route let exceptions from ``mentor.ask`` escape as a bare 500
|
|
with no log of the true upstream cause.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from roboco.mcp.optimal_server import normalize_index_types
|
|
from roboco.models.optimal import IndexType
|
|
from roboco.services import optimal as optimal_module
|
|
from roboco.services.optimal import (
|
|
OptimalService,
|
|
close_optimal_service,
|
|
get_optimal_service,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sub-issue 2: kb_search must not forward the invalid 'docs' alias
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_normalize_index_types_maps_docs_alias_to_documentation() -> None:
|
|
"""The legacy 'docs' alias must become the valid 'documentation' value.
|
|
|
|
``IndexType('docs')`` raises ``ValueError`` — the only valid value is
|
|
``IndexType.DOCUMENTATION`` whose string value is 'documentation'.
|
|
"""
|
|
assert normalize_index_types(["docs"]) == ["documentation"]
|
|
# Every normalized value must be a constructible IndexType.
|
|
for value in normalize_index_types(["docs"]):
|
|
IndexType(value)
|
|
|
|
|
|
def test_normalize_index_types_passes_valid_values_through() -> None:
|
|
assert normalize_index_types(["documentation", "decisions"]) == [
|
|
"documentation",
|
|
"decisions",
|
|
]
|
|
|
|
|
|
def test_normalize_index_types_none_returns_none() -> None:
|
|
assert normalize_index_types(None) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sub-issue 1: the init entrypoint must never expose an uninitialized singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _SlowInitService(OptimalService):
|
|
"""OptimalService whose initialize() yields control mid-flight.
|
|
|
|
This reproduces the publish-before-initialize race: while one coroutine
|
|
is awaiting inside ``initialize()``, a second coroutine calls
|
|
``get_optimal_service()``. With the old code the second caller received
|
|
the instance with ``_initialized == False``.
|
|
"""
|
|
|
|
init_calls = 0
|
|
|
|
async def initialize(self) -> None:
|
|
type(self).init_calls += 1
|
|
# Cooperatively yield so a concurrent get_optimal_service() can run
|
|
# during the window the old code left the instance unpublished/uninit.
|
|
await asyncio.sleep(0)
|
|
self._initialized = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_optimal_service_never_returns_uninitialized(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Concurrent callers must all receive a fully-initialized singleton.
|
|
|
|
The indexing entrypoint calls ``_get_plugin`` which raises
|
|
"OptimalService not initialized" when ``_initialized`` is False. This test
|
|
asserts no concurrent caller can observe that state.
|
|
"""
|
|
await close_optimal_service()
|
|
_SlowInitService.init_calls = 0
|
|
monkeypatch.setattr(optimal_module, "OptimalService", _SlowInitService)
|
|
|
|
try:
|
|
results: list[OptimalService] = await asyncio.gather(
|
|
get_optimal_service(),
|
|
get_optimal_service(),
|
|
get_optimal_service(),
|
|
)
|
|
for svc in results:
|
|
assert svc._initialized is True
|
|
# All callers share the one singleton, initialized exactly once.
|
|
assert len({id(s) for s in results}) == 1
|
|
assert _SlowInitService.init_calls == 1
|
|
finally:
|
|
await close_optimal_service()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_indexing_entrypoint_does_not_raise_not_initialized(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A caller obtaining the service mid-init must not hit _get_plugin's guard."""
|
|
await close_optimal_service()
|
|
_SlowInitService.init_calls = 0
|
|
monkeypatch.setattr(optimal_module, "OptimalService", _SlowInitService)
|
|
|
|
async def _use_service() -> None:
|
|
svc = await get_optimal_service()
|
|
# Mirror what index_documentation does first: resolve the plugin,
|
|
# which raises RuntimeError("OptimalService not initialized...") if
|
|
# the singleton was published before initialize() completed.
|
|
svc._plugins[IndexType.DOCUMENTATION] = _FakePlugin()
|
|
svc._get_plugin(IndexType.DOCUMENTATION)
|
|
|
|
try:
|
|
await asyncio.gather(_use_service(), _use_service())
|
|
finally:
|
|
await close_optimal_service()
|
|
|
|
|
|
class _FakePlugin:
|
|
"""Minimal stand-in so _get_plugin returns without a real plugin."""
|
|
|
|
async def close(self) -> None: # pragma: no cover - never awaited here
|
|
return None
|