Files
projectAIpopular/gateway/jobs.py
T

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""异步后台任务注册表(T1:后端异步化核心)。
设计原则(对齐 v3 方案 D1-D6):
- asyncio 原生,无 Celery/Redis/外部队列
- 每个 request_id -> TaskInfo(状态/开始时间/结果或错误)
- 任务写 runs/{id}/workspace.jsonSSE 生成器只读该文件(D3:不改 pipeline
- 定期清理已完成任务(防内存泄漏)
"""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, Optional
# runs/ 目录(与 pipeline.py 默认一致)
RUNS_DIR = Path("runs")
# 任务状态
STATE_PENDING = "pending"
STATE_RUNNING = "running"
STATE_DONE = "done"
STATE_FAILED = "failed"
@dataclass
class TaskInfo:
"""一个后台任务的状态快照。"""
request_id: str
state: str = STATE_PENDING # pending | running | done | failed
started_at: float = 0.0 # time.time()
finished_at: float = 0.0 # time.time()done/failed 时)
error: Optional[str] = None # failed 时错误信息
# PipelineResult 字段(done 时填充)
response: Optional[str] = None
status: Optional[str] = None # done | fast_path | escalated | failed
fast_path: bool = False
rounds_used: int = 0
api_input_tokens: int = 0
api_output_tokens: int = 0
cost_est: float = 0.0
model_used: Optional[str] = None
latency_ms: float = 0.0
workspace_path: Optional[str] = None
error_detail: Optional[str] = None
route: list = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"state": self.state,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
"response": self.response,
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"workspace_path": self.workspace_path,
"route": self.route,
"error_detail": self.error_detail,
}
def to_result_event(self) -> Dict[str, Any]:
"""终态 SSE result 事件 payload。"""
return {
"type": "result",
"request_id": self.request_id,
"response": self.response or "",
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"route": self.route,
"error": self.error,
}
class JobStore:
"""asyncio 后台任务注册表。
线程安全(asyncio 事件循环单线程,不需要额外锁)。
任务由 register() 注册、由 _task_done() 填充结果。
SSE 生成器调用 watch_file() 轮询 workspace.json 变化。
"""
def __init__(self, max_age_seconds: float = 3600.0, max_running: int = 50):
"""
Args:
max_age_seconds: 已完成任务在内存中保留时间(秒),超时后自动清理
max_running: 最大同时运行任务数,超出后拒绝新任务
"""
self._tasks: Dict[str, TaskInfo] = {}
self._asyncio_tasks: Dict[str, "asyncio.Task[None]"] = {} # request_id -> asyncio.Task
self._cancel_events: Dict[str, asyncio.Event] = {} # request_id -> cancel Event
self.max_age_seconds = max_age_seconds
self.max_running = max_running
self._poll_interval = 0.3 # workspace.json 轮询间隔(秒)
# ─────────────────────────────────────────────────────────────────
# 公共 API
# ─────────────────────────────────────────────────────────────────
def register(self, request_id: str) -> tuple[bool, str]:
"""注册一个 pending 任务。返回 (True, "") 成功,(False, reason) 容量满。"""
running = [t for t in self._tasks.values() if t.state == STATE_RUNNING]
if len(running) >= self.max_running:
return False, f"同时运行任务已达上限 {self.max_running},请稍后重试"
if request_id in self._tasks:
return False, f"任务 {request_id} 已存在"
self._tasks[request_id] = TaskInfo(
request_id=request_id,
state=STATE_PENDING,
started_at=time.time(),
)
return True, ""
def get(self, request_id: str) -> Optional[TaskInfo]:
"""查询任务状态。"""
return self._tasks.get(request_id)
def list_all(self) -> Dict[str, TaskInfo]:
"""列出所有任务(含已完成)。"""
return dict(self._tasks)
def list_running(self) -> Dict[str, TaskInfo]:
"""只列出运行中任务。"""
return {k: v for k, v in self._tasks.items() if v.state == STATE_RUNNING}
def submit(
self,
request_id: str,
coro, # type: asyncio.coroutine
) -> "asyncio.Task[None]":
"""提交协程到后台运行;内部注册 asyncio.Task。"""
task = asyncio.create_task(self._run_wrapper(request_id, coro))
self._asyncio_tasks[request_id] = task
return task
def new_cancel(self, request_id: str) -> asyncio.Event:
"""为 SSE 连接创建一个取消事件。"""
evt = asyncio.Event()
self._cancel_events[request_id] = evt
return evt
def get_cancel(self, request_id: str) -> Optional[asyncio.Event]:
"""获取已有取消事件。"""
return self._cancel_events.get(request_id)
# ─────────────────────────────────────────────────────────────────
# 内部:任务运行包装
# ─────────────────────────────────────────────────────────────────
async def _run_wrapper(self, request_id: str, coro):
"""把用户协程包装成可追踪的任务:更新状态、捕获异常、清理。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_RUNNING
try:
await coro
except Exception as exc: # pragma: no cover
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = f"unhandled:{type(exc).__name__}:{exc}"
finally:
# 清理 asyncio task 引用
self._asyncio_tasks.pop(request_id, None)
# 定期 GC 已完成任务
self._cleanup_aged()
def _task_done(
self,
request_id: str,
result: "PipelineResult", # from pipeline.PipelineResult
) -> None:
"""任务正常完成时由调用方调用,写入结果。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_DONE
info.finished_at = time.time()
info.response = result.response
info.status = result.status
info.fast_path = result.fast_path
info.rounds_used = result.rounds_used
info.api_input_tokens = result.api_input_tokens
info.api_output_tokens = result.api_output_tokens
info.cost_est = result.cost_est
info.model_used = result.model_used
info.latency_ms = result.latency_ms
info.workspace_path = result.workspace_path
info.route = list(result.route) if result.route else []
if result.error:
info.error_detail = result.error
def _task_failed(
self,
request_id: str,
error: str,
detail: Optional[str] = None,
) -> None:
"""任务异常结束时调用。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = error
info.error_detail = detail
# ─────────────────────────────────────────────────────────────────
# SSE 专用:workspace.json 文件轮询
# ─────────────────────────────────────────────────────────────────
async def watch_workspace(
self,
request_id: str,
cancel_event: asyncio.Event,
) -> AsyncGenerator[Dict[str, Any], None]:
"""监视 runs/{id}/workspace.jsonyield 单条事件。
事件类型:
- {"type": "status", "value": <state>, "request_id": <id>}
- {"type": "workspace", "version": <n>, "workspace": <dict>}
- {"type": "error", "detail": <str>}
当任务进入 done/failed 状态或 cancel_event 被 set 时停止。
"""
ws_path = RUNS_DIR / request_id / "workspace.json"
seen_mtime: float = 0.0
seen_size: int = 0
while not cancel_event.is_set():
info = self.get(request_id)
# 检查终态
if info and info.state in (STATE_DONE, STATE_FAILED):
if info.state == STATE_FAILED:
yield {"type": "error", "detail": info.error or "任务失败"}
# result 由 /runs/{id}/status 提供,此处只推送终态 status
yield {"type": "status", "value": info.state, "request_id": request_id}
break
# 读文件变化(mtime + size 双检)
if ws_path.exists():
try:
stat = ws_path.stat()
if stat.st_mtime != seen_mtime or stat.st_size != seen_size:
raw = ws_path.read_text(encoding="utf-8")
data = json.loads(raw)
version = int(data.get("meta", {}).get("round", 0))
seen_mtime = stat.st_mtime
seen_size = stat.st_size
yield {
"type": "workspace",
"version": version,
"state": info.state if info else STATE_RUNNING,
"request_id": request_id,
"workspace": data,
}
except (json.JSONDecodeError, OSError):
pass # 文件正在写入,忽略
await asyncio.sleep(self._poll_interval)
# 连接关闭前最后推一次终态
info = self.get(request_id)
if info:
yield {"type": "status", "value": info.state, "request_id": request_id}
# ─────────────────────────────────────────────────────────────────
# 内存清理
# ─────────────────────────────────────────────────────────────────
def _cleanup_aged(self) -> None:
"""删除超过 max_age_seconds 的已完成任务引用。"""
now = time.time()
to_remove = [
rid for rid, info in self._tasks.items()
if info.state in (STATE_DONE, STATE_FAILED)
and (now - info.finished_at) > self.max_age_seconds
]
for rid in to_remove:
self._tasks.pop(rid, None)
def cancel(self, request_id: str) -> bool:
"""取消运行中的任务。返回 True 找到并取消,False 未找到。"""
task = self._asyncio_tasks.get(request_id)
if task is None:
return False
task.cancel()
info = self.get(request_id)
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = "cancelled_by_user"
return True
# ─────────────────────────────────────────────────────────────────────────────
# 全局单例(gateway 进程内共享)
# ─────────────────────────────────────────────────────────────────────────────
_store: Optional[JobStore] = None
def get_job_store() -> JobStore:
global _store
if _store is None:
_store = JobStore(max_age_seconds=3600.0, max_running=50)
return _store
def reset_job_store() -> None:
"""测试用:重置全局注册表。"""
global _store
_store = None