AgentEvalTool/backend/agenteval/storage/repository/run.py
sinohqb ca208232c7 perf(db): WAL 模式 + 性能索引 + N+1 查询消除
- SQLite 启用 WAL,允许读写并发
- 新增 6 个索引(eval_runs.status/campaign_id、eval_results.run_id、
  turns.run_id、intelligent_evals.status、task_queue.assigned_at)
- 幂等 Alembic 迁移(列/索引存在性检查)
- domain.py 计数改 func.count 聚合,get_attention_reason 单次加载 sessions
- scenario list_all 批量加载 bindings(1+N → 2 查询)
- mark_orphans_failed 批量加载 campaigns(N → 1 IN 查询)
2026-08-24 23:17:51 +08:00

300 lines
12 KiB
Python

"""Repository for evaluation runs."""
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from sqlalchemy import insert as sql_insert
from sqlalchemy import literal
from sqlalchemy.exc import IntegrityError
from sqlmodel import select
from agenteval.models import CampaignStatus, EvalResult, EvalRun, RunStatus, RunTrigger
from agenteval.storage.db import (
CampaignDB,
EvalResultDB,
EvalRunDB,
TurnDB,
new_uuid,
utc_now,
)
from agenteval.storage.repository.base import BaseRepository
from agenteval.storage.repository.result import result_from_db
class CampaignRunClaimStatus(str, Enum):
"""Outcome of a durable Campaign child-Run claim."""
CLAIMED = "claimed"
EXISTING = "existing"
NOT_FOUND = "not_found"
CONFLICT = "conflict"
@dataclass(frozen=True)
class CampaignRunClaimResult:
"""Typed result for the idempotent child-Run claim seam."""
status: CampaignRunClaimStatus
run: Optional[EvalRun] = None
@property
def accepted(self) -> bool:
return self.status in (CampaignRunClaimStatus.CLAIMED, CampaignRunClaimStatus.EXISTING)
@property
def created(self) -> bool:
return self.status is CampaignRunClaimStatus.CLAIMED
class RunRepository(BaseRepository[EvalRun, EvalRunDB]):
"""Repository for evaluation runs."""
_table = EvalRunDB
_order_by = "started_at"
def claim_campaign_run(
self,
*,
campaign_id: str,
scenario_id: str,
scenario_version: int,
plan_index: int,
occurrence_index: int,
) -> "CampaignRunClaimResult":
"""Claim one durable child Run occurrence while its Campaign is running.
The conditional ``INSERT ... SELECT`` makes the Campaign status check
part of the write. The composite unique index turns retries or a
competing scheduler into an ``EXISTING`` result instead of a second
pending Run. No external messages are sent by this operation.
"""
if plan_index < 0 or occurrence_index < 0:
raise ValueError("plan_index and occurrence_index must be non-negative")
run_id = new_uuid()
now = utc_now()
statement = sql_insert(EvalRunDB).from_select(
[
"id",
"target_id",
"scenario_id",
"scenario_version",
"campaign_id",
"campaign_plan_index",
"campaign_occurrence_index",
"status",
"triggered_by",
"started_at",
],
select(
literal(run_id),
CampaignDB.target_id,
literal(scenario_id),
literal(scenario_version),
CampaignDB.id,
literal(plan_index),
literal(occurrence_index),
literal(RunStatus.PENDING.value),
literal(RunTrigger.CAMPAIGN.value),
literal(now),
).where(
CampaignDB.id == campaign_id,
CampaignDB.status == CampaignStatus.RUNNING.value,
CampaignDB.target_id.is_not(None),
),
)
try:
result = self.session.exec(statement)
if result.rowcount == 1:
self.session.commit()
db = self.session.get(EvalRunDB, run_id)
return CampaignRunClaimResult(CampaignRunClaimStatus.CLAIMED, self._from_db(db))
self.session.rollback()
except IntegrityError:
self.session.rollback()
except Exception:
self.session.rollback()
raise
existing = self._get_campaign_identity(campaign_id, plan_index, occurrence_index)
campaign = self.session.get(CampaignDB, campaign_id)
if campaign is None:
return CampaignRunClaimResult(CampaignRunClaimStatus.NOT_FOUND)
if campaign.status != CampaignStatus.RUNNING.value:
return CampaignRunClaimResult(CampaignRunClaimStatus.CONFLICT)
if existing is not None:
return CampaignRunClaimResult(CampaignRunClaimStatus.EXISTING, self._from_db(existing))
return CampaignRunClaimResult(CampaignRunClaimStatus.CONFLICT)
def _get_campaign_identity(self, campaign_id: str, plan_index: int, occurrence_index: int) -> Optional[EvalRunDB]:
statement = select(EvalRunDB).where(
EvalRunDB.campaign_id == campaign_id,
EvalRunDB.campaign_plan_index == plan_index,
EvalRunDB.campaign_occurrence_index == occurrence_index,
)
return self.session.exec(statement).first()
def _to_db(self, run: EvalRun) -> EvalRunDB:
db = EvalRunDB(
id=run.id,
target_id=run.target_id,
scenario_id=run.scenario_id,
scenario_version=run.scenario_version,
campaign_id=run.campaign_id,
campaign_plan_index=run.campaign_plan_index,
campaign_occurrence_index=run.campaign_occurrence_index,
status=run.status.value,
triggered_by=run.triggered_by.value,
started_at=run.started_at,
completed_at=run.completed_at,
)
if run.summary is not None:
db.set_summary(run.summary.model_dump(mode="json"))
return db
def _from_db(self, db: EvalRunDB) -> EvalRun:
return EvalRun(
id=db.id,
target_id=db.target_id,
scenario_id=db.scenario_id,
scenario_version=db.scenario_version or 1,
campaign_id=db.campaign_id,
campaign_plan_index=db.campaign_plan_index,
campaign_occurrence_index=db.campaign_occurrence_index,
status=db.status,
triggered_by=db.triggered_by or "manual",
started_at=db.started_at,
completed_at=db.completed_at,
summary=db.get_summary(),
)
def list_by_campaign(self, campaign_id: str) -> list[EvalRun]:
statement = select(EvalRunDB).where(EvalRunDB.campaign_id == campaign_id).order_by(EvalRunDB.started_at)
return [self._from_db(r) for r in self.session.exec(statement).all()]
def list_by_campaigns(self, campaign_ids: list[str]) -> dict[str, list[EvalRun]]:
"""Load child Runs for multiple Campaigns in one query."""
grouped = {campaign_id: [] for campaign_id in campaign_ids}
if not campaign_ids:
return grouped
statement = (
select(EvalRunDB)
.where(EvalRunDB.campaign_id.in_(campaign_ids)) # type: ignore[union-attr]
.order_by(EvalRunDB.started_at)
)
for row in self.session.exec(statement).all():
if row.campaign_id in grouped:
grouped[row.campaign_id].append(self._from_db(row))
return grouped
def mark_orphans_failed(self) -> int:
"""Fail process-orphaned Runs while preserving recoverable child claims.
Every RUNNING Run may already have sent messages and must not replay.
A PENDING Run is preserved only when it has a complete Campaign plan
identity and its parent Campaign is still running; the Campaign loop
can safely resume those claims before any external send occurred.
"""
statement = select(EvalRunDB).where(EvalRunDB.status.in_(["running", "pending"])) # type: ignore[attr-defined]
candidates = self.session.exec(statement).all()
# Batch load all campaigns for pending runs to avoid N+1 queries
campaign_ids = {db.campaign_id for db in candidates if db.campaign_id and db.status == RunStatus.PENDING.value}
campaigns = {}
if campaign_ids:
campaign_stmt = select(CampaignDB).where(CampaignDB.id.in_(campaign_ids)) # type: ignore[attr-defined]
campaigns = {c.id: c for c in self.session.exec(campaign_stmt).all()}
failed: list[EvalRunDB] = []
for db in candidates:
if db.status == RunStatus.PENDING.value and self._is_recoverable_campaign_pending(db, campaigns):
continue
self._set_interrupted(db)
failed.append(db)
if failed:
self.session.commit()
return len(failed)
def _is_recoverable_campaign_pending(self, db: EvalRunDB, campaigns: dict[str, CampaignDB]) -> bool:
if db.campaign_id is None or db.campaign_plan_index is None or db.campaign_occurrence_index is None:
return False
campaign = campaigns.get(db.campaign_id)
return campaign is not None and campaign.status == CampaignStatus.RUNNING.value
def _set_interrupted(self, db: EvalRunDB) -> None:
db.status = RunStatus.FAILED.value
db.completed_at = db.completed_at or utc_now()
summary = db.get_summary() or {}
summary["error"] = {"code": "interrupted", "message": "服务重启导致评测中断"}
db.set_summary(summary)
self.session.add(db)
def list_pending_campaign_children(self, campaign_id: str) -> list[EvalRun]:
"""Return durable pending claims in deterministic plan order."""
statement = (
select(EvalRunDB)
.where(
EvalRunDB.campaign_id == campaign_id,
EvalRunDB.campaign_plan_index.is_not(None),
EvalRunDB.campaign_occurrence_index.is_not(None),
EvalRunDB.status == RunStatus.PENDING.value,
)
.order_by(EvalRunDB.campaign_plan_index, EvalRunDB.campaign_occurrence_index)
)
return [self._from_db(db) for db in self.session.exec(statement).all()]
def mark_campaign_running_interrupted(self, campaign_id: str) -> list[str]:
"""Fail running child Runs from a previous process without replaying."""
statement = select(EvalRunDB).where(
EvalRunDB.campaign_id == campaign_id,
EvalRunDB.campaign_plan_index.is_not(None),
EvalRunDB.campaign_occurrence_index.is_not(None),
EvalRunDB.status == RunStatus.RUNNING.value,
)
rows = list(self.session.exec(statement).all())
for db in rows:
self._set_interrupted(db)
if rows:
self.session.commit()
return [db.id or "" for db in rows]
def mark_pending_campaign_child_interrupted(self, run_id: str) -> Optional[EvalRun]:
"""Fail one still-pending child claim that cannot be safely recovered."""
db = self.session.get(EvalRunDB, run_id)
if db is None or db.status != RunStatus.PENDING.value:
return self._from_db(db) if db is not None else None
self._set_interrupted(db)
self.session.commit()
self.session.refresh(db)
return self._from_db(db)
def update(self, run: EvalRun) -> Optional[EvalRun]:
existing = self.session.get(EvalRunDB, run.id)
if not existing:
return None
existing.target_id = run.target_id
existing.scenario_id = run.scenario_id
existing.scenario_version = run.scenario_version
existing.campaign_id = run.campaign_id
existing.campaign_plan_index = run.campaign_plan_index
existing.campaign_occurrence_index = run.campaign_occurrence_index
existing.status = run.status.value
existing.triggered_by = run.triggered_by.value
existing.completed_at = run.completed_at
if run.summary is not None:
existing.set_summary(run.summary.model_dump(mode="json"))
self.session.add(existing)
self.session.commit()
self.session.refresh(existing)
return self._from_db(existing)
def get_turns(self, run_id: str) -> list[TurnDB]:
statement = select(TurnDB).where(TurnDB.run_id == run_id).order_by(TurnDB.sent_at)
return list(self.session.exec(statement).all())
def get_results(self, run_id: str) -> list[EvalResult]:
statement = select(EvalResultDB).where(EvalResultDB.run_id == run_id)
return [result_from_db(r) for r in self.session.exec(statement).all()]