Skip to content

Commit 8319b05

Browse files
committed
feat(evaluation): the EvalRunner Ray actor and its dispatcher
`EvalRunner` executes one run: upload and time each corpus file, shell out to promptfoo twice, fold the outputs into metrics, persist, drop the throwaway partition. `RayEvaluationRunner` binds it to the `EvaluationRunner` port and keeps every Ray concern — actor lookup, `.remote()`, timeouts, cancellation — on the worker side of the boundary. The runner drives OpenRAG through its own HTTP API rather than in-process calls, for two reasons: it is the path a real user's documents take, so the indexing timings mean something, and it is the same surface promptfoo talks to, so an eval can never pass against a code path the API does not expose. Notes: - The actor handle is resolved on first use, not in `__init__`. `EvalRunner` is detached, so merely building the adapter must not be what spawns it — listing datasets should not start a worker process. - `run()` never raises. The caller dispatched it fire-and-forget and has nobody to catch for, so every outcome is written to the run row. - One bad corpus file is recorded as a failed sample rather than voiding the run; every file failing is an error worth surfacing. - promptfoo exits non-zero when assertions fail, which is a result, not an error — the presence of the output file is what decides. Both streams are captured, since promptfoo reports config errors on stdout. - Each run gets its own `PROMPTFOO_CONFIG_DIR`. promptfoo keeps a SQLite history under it, defaulting to `$HOME/.promptfoo`, which is not guaranteed writable in a container. - `max_concurrency=4` so `cancel()` and `is_busy()` still land while `run()` holds a slot.
1 parent f15c899 commit 8319b05

5 files changed

Lines changed: 479 additions & 0 deletions

File tree

openrag/di/container.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from core.vector_stores import VectorStore
6161
from services.orchestrators.auth_service import AuthService
6262
from services.orchestrators.conversion_service import ConversionService
63+
from services.orchestrators.evaluation_service import EvaluationService
6364
from services.orchestrators.indexing_service import IndexingService
6465
from services.orchestrators.job_service import JobService
6566
from services.orchestrators.mcp_service import MCPService
@@ -117,6 +118,7 @@ def __init__(self, settings: Settings | None = None) -> None:
117118
self._partition_service: PartitionService | None = None
118119
self._model_endpoint_service: ModelEndpointService | None = None
119120
self._preset_service: PresetService | None = None
121+
self._evaluation_service: EvaluationService | None = None
120122
self._workspace_service: WorkspaceService | None = None
121123
self._retrieval_service: RetrievalService | None = None
122124
self._query_service: QueryService | None = None
@@ -464,6 +466,25 @@ def preset_service(self) -> PresetService:
464466
)
465467
return self._preset_service
466468

469+
@property
470+
def evaluation_service(self) -> EvaluationService:
471+
"""EvaluationService — dataset storage and run dispatch."""
472+
if self._evaluation_service is None:
473+
from services.orchestrators.evaluation_service import EvaluationService
474+
from services.workers.eval_dispatcher import from_ray_namespace
475+
476+
self._evaluation_service = EvaluationService(
477+
repo=self.evaluation_repo,
478+
# The adapter resolves its detached actor on first use, so
479+
# building the service here does not spawn a worker.
480+
runner=from_ray_namespace(),
481+
user_service=self.user_service,
482+
user_repo=self.user_repo,
483+
partition_service=self.partition_service,
484+
config=self._require_settings(),
485+
)
486+
return self._evaluation_service
487+
467488
@property
468489
def workspace_service(self) -> WorkspaceService:
469490
"""WorkspaceService — lazily built, cached for the container's lifetime."""

openrag/di/providers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ def get_preset_service(request: Request = None) -> Any:
149149
return _get_optional_service(_require_initialized(request), "preset_service")
150150

151151

152+
def get_evaluation_service(request: Request = None) -> Any:
153+
"""Resolve the evaluation orchestrator from the active container."""
154+
return _get_optional_service(_require_initialized(request), "evaluation_service")
155+
156+
152157
def get_config(request: Request = None):
153158
"""Resolve application configuration from the active container."""
154159
return _require_initialized(request).config
@@ -159,6 +164,7 @@ def get_config(request: Request = None):
159164
"get_config",
160165
"get_container",
161166
"get_conversion_service",
167+
"get_evaluation_service",
162168
"get_indexing_service",
163169
"get_job_service",
164170
"get_mcp_service",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Ray adapter for :class:`~core.evaluation.runner.EvaluationRunner`.
2+
3+
Binds the port to the ``EvalRunner`` actor and keeps every Ray concern —
4+
actor lookup, ``.remote()`` calls, timeout and cancellation handling — on this
5+
side of the boundary, so ``EvaluationService`` never imports Ray.
6+
7+
The actor handle is resolved on first use rather than in ``__init__``:
8+
``EvalRunner`` is a *detached* actor, so merely building this adapter must not
9+
be what spawns it. Listing datasets should not start a worker process.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import TYPE_CHECKING, Any
15+
16+
from core.evaluation.runner import EvaluationRunner
17+
from services.workers.ray_utils import call_ray_actor_with_timeout
18+
19+
if TYPE_CHECKING:
20+
from collections.abc import Mapping, Sequence
21+
22+
#: Bound on the calls that are awaited (liveness probe, cancellation).
23+
#: ``dispatch`` is fire-and-forget and so has nothing to time out.
24+
DEFAULT_TIMEOUT = 60.0
25+
26+
27+
class RayEvaluationRunner(EvaluationRunner):
28+
"""``EvaluationRunner`` backed by the ``EvalRunner`` Ray actor."""
29+
30+
def __init__(self, namespace: str = "openrag", timeout: float = DEFAULT_TIMEOUT) -> None:
31+
self._namespace = namespace
32+
self._timeout = timeout
33+
self._actor: Any = None
34+
35+
def _handle(self) -> Any:
36+
"""Get-or-create the detached actor, memoised for the process."""
37+
if self._actor is None:
38+
from services.workers.eval_runner import build_eval_runner
39+
40+
self._actor = build_eval_runner(namespace=self._namespace)
41+
return self._actor
42+
43+
async def is_busy(self) -> bool:
44+
return await call_ray_actor_with_timeout(
45+
future=self._handle().is_busy.remote(),
46+
timeout=self._timeout,
47+
task_description="reaching the evaluation runner",
48+
)
49+
50+
async def dispatch(
51+
self,
52+
*,
53+
run_id: str,
54+
partition: str,
55+
token: str,
56+
api_base_url: str,
57+
corpus_dir: str,
58+
cases: Sequence[Mapping[str, Any]],
59+
) -> None:
60+
# Deliberately not awaited: the worker owns the run from here and
61+
# records its own outcome, so the ObjectRef is dropped.
62+
self._handle().run.remote(
63+
run_id=run_id,
64+
partition=partition,
65+
token=token,
66+
api_base_url=api_base_url,
67+
corpus_dir=corpus_dir,
68+
cases=[dict(case) for case in cases],
69+
)
70+
71+
async def cancel(self, run_id: str) -> bool:
72+
return await call_ray_actor_with_timeout(
73+
future=self._handle().cancel.remote(run_id),
74+
timeout=self._timeout,
75+
task_description=f"cancelling evaluation run {run_id}",
76+
)
77+
78+
79+
def from_ray_namespace(namespace: str = "openrag", timeout: float = DEFAULT_TIMEOUT) -> RayEvaluationRunner:
80+
"""Build the adapter bound to the detached ``EvalRunner`` actor."""
81+
return RayEvaluationRunner(namespace=namespace, timeout=timeout)
82+
83+
84+
__all__ = ["DEFAULT_TIMEOUT", "RayEvaluationRunner", "from_ray_namespace"]

0 commit comments

Comments
 (0)