Files
ai-video-fullstack/backend/services/test_runs/orchestrator.py
2026-08-10 13:49:24 +08:00

411 lines
16 KiB
Python

"""In-process MVP scheduler for persisted batch test runs."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime
from loguru import logger
from sqlalchemy import select
from db.models import ModelResource, TestCase, TestRun, TestRunCase
from db.session import SessionLocal
from models import RuntimeModelResource
from services.config_resolver import resolve_runtime_config
from services.test_runs.errors import TestExecutionError
from services.test_runs.evaluator import EvaluationEngine
from services.test_runs.text_runner import TextPipelineRunner
from test_schemas import TestCaseDefinition
@dataclass
class _RunControl:
stop_event: asyncio.Event = field(default_factory=asyncio.Event)
reason: str | None = None
case_tasks: set[asyncio.Task[None]] = field(default_factory=set)
class TestRunOrchestrator:
"""Run cases concurrently while keeping PostgreSQL as the source of truth."""
def __init__(self) -> None:
self._run_tasks: dict[str, asyncio.Task[None]] = {}
self._controls: dict[str, _RunControl] = {}
def start(self, run_id: str) -> None:
current = self._run_tasks.get(run_id)
if current is not None and not current.done():
return
control = _RunControl()
self._controls[run_id] = control
task = asyncio.create_task(self._execute_run(run_id, control), name=f"test-run-{run_id}")
self._run_tasks[run_id] = task
task.add_done_callback(lambda _task: self._forget(run_id))
async def cancel(self, run_id: str) -> None:
control = self._controls.get(run_id)
if control is None:
return
control.reason = "manual"
control.stop_event.set()
for task in tuple(control.case_tasks):
task.cancel()
async def shutdown(self) -> None:
tasks = [task for task in self._run_tasks.values() if not task.done()]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._run_tasks.clear()
self._controls.clear()
def _forget(self, run_id: str) -> None:
self._run_tasks.pop(run_id, None)
self._controls.pop(run_id, None)
async def _execute_run(self, run_id: str, control: _RunControl) -> None:
try:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
run.status = "running"
run.started_at = run.started_at or datetime.now(UTC)
await session.commit()
try:
cfg = await resolve_runtime_config(session, str(run.assistant_id or ""))
except Exception as exc:
await self._fail_run_setup(run_id, str(exc))
return
config = dict(run.config or {})
evaluator_resource_id = str(
config.get("evaluatorModelResourceId") or ""
)
evaluator_row = await session.get(
ModelResource,
evaluator_resource_id,
)
if (
evaluator_row is None
or evaluator_row.capability != "LLM"
or not evaluator_row.enabled
):
await self._fail_run_setup(
run_id,
"评估模型不存在、已停用或不是 LLM 资源",
)
return
evaluator_resource = RuntimeModelResource(
id=evaluator_row.id,
name=evaluator_row.name,
capability=evaluator_row.capability,
interface_type=evaluator_row.interface_type,
values=evaluator_row.values or {},
secrets=evaluator_row.secrets or {},
support_image_input=bool(evaluator_row.support_image_input),
)
rows = (
await session.execute(
select(TestRunCase)
.where(TestRunCase.run_id == run_id)
.order_by(TestRunCase.position)
)
).scalars().all()
semaphore = asyncio.Semaphore(int(config.get("concurrency") or 3))
tasks = {
asyncio.create_task(
self._execute_case(
run_id,
row.id,
cfg,
evaluator_resource,
config,
semaphore,
control,
),
name=f"test-run-case-{row.id}",
)
for row in rows
}
control.case_tasks = tasks
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
await self._finish_run(run_id, control)
except asyncio.CancelledError:
await self._interrupt_run(run_id)
raise
except Exception as exc: # noqa: BLE001 - background task must persist failure
logger.exception(f"批量测试运行失败: run_id={run_id}: {exc}")
await self._fail_run_setup(run_id, str(exc))
async def _execute_case(
self,
run_id: str,
run_case_id: str,
cfg,
evaluator_resource: RuntimeModelResource,
config: dict,
semaphore: asyncio.Semaphore,
control: _RunControl,
) -> None:
async with semaphore:
if control.stop_event.is_set():
await self._mark_skipped(run_case_id)
return
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
snapshot = dict(row.case_snapshot or {})
definition = TestCaseDefinition.model_validate(snapshot)
max_attempts = row.max_attempts
for attempt in range(1, max_attempts + 1):
if control.stop_event.is_set():
await self._mark_skipped(run_case_id)
return
await self._mark_running(run_case_id, attempt)
try:
timeout_seconds = int(
config.get("timeoutSecs")
or config.get("timeout_secs")
or 60
)
async with asyncio.timeout(timeout_seconds):
execution = await TextPipelineRunner().run(
cfg.model_copy(deep=True),
definition,
timeout_seconds=timeout_seconds,
)
evaluated = await EvaluationEngine(evaluator_resource).evaluate(
definition,
execution,
)
except asyncio.CancelledError:
await self._mark_skipped(run_case_id)
raise
except TimeoutError:
error = TestExecutionError(
code="CASE_TIMEOUT",
message=f"测试用例执行与评估超过 {timeout_seconds}",
stage="pipeline",
retryable=True,
)
if attempt < max_attempts and not control.stop_event.is_set():
continue
await self._mark_error(run_case_id, error)
if str(
config.get("errorStrategy")
or config.get("error_strategy")
) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
except TestExecutionError as exc:
if exc.retryable and attempt < max_attempts and not control.stop_event.is_set():
continue
await self._mark_error(run_case_id, exc)
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
except Exception as exc: # noqa: BLE001 - normalize unknown runtime errors
error = TestExecutionError(
code="UNEXPECTED_EXECUTION_ERROR",
message=str(exc) or type(exc).__name__,
stage="pipeline",
retryable=False,
)
await self._mark_error(run_case_id, error)
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
status = "pass" if evaluated.passed else "fail"
await self._mark_evaluated(run_case_id, status, evaluated)
if status == "fail" and str(
config.get("failureStrategy") or config.get("failure_strategy")
) == "stop_on_fail":
await self._request_stop(run_id, control, "assertion_failure")
return
async def _request_stop(
self,
run_id: str,
control: _RunControl,
reason: str,
) -> None:
if control.reason is None:
control.reason = reason
control.stop_event.set()
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is not None and run.stop_reason is None:
run.stop_reason = reason
await session.commit()
async def _mark_running(self, run_case_id: str, attempt: int) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = "running"
row.attempt_count = attempt
row.started_at = row.started_at or datetime.now(UTC)
row.finished_at = None
row.execution_error = None
await session.commit()
async def _mark_evaluated(self, run_case_id: str, status: str, evaluated) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = status
row.result = {
"turns": [
item.model_dump(mode="json", by_alias=True)
for item in evaluated.turns
],
"overallCriteria": [
item.model_dump(mode="json", by_alias=True)
for item in evaluated.overall_criteria
],
}
row.execution_error = None
row.finished_at = datetime.now(UTC)
if row.test_case_id:
test_case = await session.get(TestCase, row.test_case_id)
if test_case is not None:
test_case.last_result = status
await session.commit()
async def _mark_error(
self,
run_case_id: str,
error: TestExecutionError,
) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = "error"
row.execution_error = error.as_dict()
row.finished_at = datetime.now(UTC)
if row.test_case_id:
test_case = await session.get(TestCase, row.test_case_id)
if test_case is not None:
test_case.last_result = "fail"
await session.commit()
async def _mark_skipped(self, run_case_id: str) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None or row.status in {"pass", "fail", "error", "skipped"}:
return
row.status = "skipped"
row.finished_at = datetime.now(UTC)
await session.commit()
async def _finish_run(self, run_id: str, control: _RunControl) -> None:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
waiting = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
for row in waiting:
row.status = "skipped"
row.finished_at = datetime.now(UTC)
manual = bool(run.cancel_requested or control.reason == "manual")
run.status = "cancelled" if manual else "completed"
run.stop_reason = "manual" if manual else control.reason or run.stop_reason
run.finished_at = datetime.now(UTC)
await session.commit()
async def _fail_run_setup(self, run_id: str, message: str) -> None:
error = TestExecutionError(
code="RUN_SETUP_FAILED",
message=message or "批量测试初始化失败",
stage="pipeline",
retryable=False,
)
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
rows = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
now = datetime.now(UTC)
for row in rows:
row.status = "error"
row.execution_error = error.as_dict()
row.finished_at = now
run.status = "completed"
run.stop_reason = "execution_error"
run.finished_at = now
await session.commit()
async def _interrupt_run(self, run_id: str) -> None:
await _mark_run_interrupted(run_id, "后端服务停止,运行已中断")
async def _mark_run_interrupted(run_id: str, message: str) -> None:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None or run.status not in {"queued", "running"}:
return
rows = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
now = datetime.now(UTC)
for row in rows:
if row.status == "running":
row.status = "error"
row.execution_error = {
"code": "SERVER_RESTARTED",
"message": message,
"stage": "pipeline",
"retryable": True,
}
else:
row.status = "skipped"
row.finished_at = now
run.status = "completed"
run.stop_reason = "execution_error"
run.finished_at = now
await session.commit()
async def recover_interrupted_test_runs() -> None:
async with SessionLocal() as session:
run_ids = (
await session.execute(
select(TestRun.id).where(TestRun.status.in_(["queued", "running"]))
)
).scalars().all()
for run_id in run_ids:
await _mark_run_interrupted(str(run_id), "后端服务重启,运行已中断")
test_run_orchestrator = TestRunOrchestrator()