205 lines
7.1 KiB
Python
205 lines
7.1 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 轻量异步任务队列(moduleId: core-async-jobs, 可重生 ✅)
|
|||
|
|
# 矩阵 88 行剩余:敏感性 / 蒙特卡洛重算放后台(提交 / 轮询 / 取消 / 结果)
|
|||
|
|
# 线程模型:daemon worker 线程执行;fn 签名 fn(record),可检查
|
|||
|
|
# record.cancel_requested 提前退出(或抛 JobCancelled)实现可取消长任务。
|
|||
|
|
# 与网关解耦:本模块只做队列与状态机,业务闭包由调用方注入。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import itertools
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
import uuid
|
|||
|
|
from collections.abc import Callable
|
|||
|
|
from datetime import UTC, datetime
|
|||
|
|
from typing import Any, Literal
|
|||
|
|
|
|||
|
|
from pydantic import BaseModel, Field
|
|||
|
|
|
|||
|
|
from server.timeutil import fmt_dt
|
|||
|
|
|
|||
|
|
JobStatus = Literal["pending", "running", "done", "failed", "cancelled"]
|
|||
|
|
_TERMINAL = ("done", "failed", "cancelled")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class JobCancelled(Exception):
|
|||
|
|
"""任务主动取消:worker 捕获后状态归为 cancelled。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _now() -> str:
|
|||
|
|
# DTZ005:统一取本地时区的 aware 时间,墙钟与仓库其余模块一致
|
|||
|
|
return fmt_dt(datetime.now(UTC).astimezone())
|
|||
|
|
|
|||
|
|
|
|||
|
|
class JobRecord(BaseModel):
|
|||
|
|
"""任务记录:状态机 pending → running → done/failed/cancelled。"""
|
|||
|
|
|
|||
|
|
job_id: str
|
|||
|
|
kind: str
|
|||
|
|
status: JobStatus = "pending"
|
|||
|
|
progress: int = 0
|
|||
|
|
params: dict[str, Any] = Field(default_factory=dict)
|
|||
|
|
actor: str = "planner"
|
|||
|
|
result: Any = None
|
|||
|
|
error: str | None = None
|
|||
|
|
created_at: str = ""
|
|||
|
|
started_at: str | None = None
|
|||
|
|
finished_at: str | None = None
|
|||
|
|
cancel_requested: bool = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class JobQueue:
|
|||
|
|
"""轻量异步任务队列:提交 / 轮询 / 取消 / 结果 / 统计。"""
|
|||
|
|
|
|||
|
|
def __init__(self, *, max_finished: int = 200) -> None:
|
|||
|
|
self._lock = threading.RLock()
|
|||
|
|
self._jobs: dict[str, JobRecord] = {}
|
|||
|
|
self._seq = itertools.count(1)
|
|||
|
|
self.max_finished = max_finished
|
|||
|
|
|
|||
|
|
# ---------------- 提交 ----------------
|
|||
|
|
def submit(
|
|||
|
|
self,
|
|||
|
|
kind: str,
|
|||
|
|
fn: Callable[[JobRecord], Any],
|
|||
|
|
*,
|
|||
|
|
params: dict[str, Any] | None = None,
|
|||
|
|
actor: str = "planner",
|
|||
|
|
) -> str:
|
|||
|
|
"""提交任务:立即起 daemon 线程执行,返回 job_id。"""
|
|||
|
|
job_id = f"job-{next(self._seq):04d}-{uuid.uuid4().hex[:6]}"
|
|||
|
|
record = JobRecord(
|
|||
|
|
job_id=job_id, kind=kind, params=params or {}, actor=actor, created_at=_now(),
|
|||
|
|
)
|
|||
|
|
with self._lock:
|
|||
|
|
self._jobs[job_id] = record
|
|||
|
|
thread = threading.Thread(
|
|||
|
|
target=self._worker, args=(job_id, fn),
|
|||
|
|
name=f"aps-job-{job_id}", daemon=True,
|
|||
|
|
)
|
|||
|
|
thread.start()
|
|||
|
|
return job_id
|
|||
|
|
|
|||
|
|
def _worker(self, job_id: str, fn: Callable[[JobRecord], Any]) -> None:
|
|||
|
|
with self._lock:
|
|||
|
|
record = self._jobs[job_id]
|
|||
|
|
record.status = "running"
|
|||
|
|
record.started_at = _now()
|
|||
|
|
cancel = record.cancel_requested
|
|||
|
|
if cancel:
|
|||
|
|
self._finish(job_id, "cancelled")
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
result = fn(record)
|
|||
|
|
with self._lock:
|
|||
|
|
cancelled = self._jobs[job_id].cancel_requested
|
|||
|
|
if cancelled:
|
|||
|
|
self._finish(job_id, "cancelled")
|
|||
|
|
else:
|
|||
|
|
with self._lock:
|
|||
|
|
self._jobs[job_id].result = result
|
|||
|
|
self._jobs[job_id].progress = 100
|
|||
|
|
self._finish(job_id, "done")
|
|||
|
|
except JobCancelled:
|
|||
|
|
self._finish(job_id, "cancelled")
|
|||
|
|
except Exception as exc: # noqa: BLE001 - worker 兜底任意业务异常,转 failed 状态
|
|||
|
|
with self._lock:
|
|||
|
|
self._jobs[job_id].error = str(exc)
|
|||
|
|
self._finish(job_id, "failed")
|
|||
|
|
finally:
|
|||
|
|
self._prune()
|
|||
|
|
|
|||
|
|
def _finish(self, job_id: str, status: JobStatus) -> None:
|
|||
|
|
with self._lock:
|
|||
|
|
record = self._jobs[job_id]
|
|||
|
|
record.status = status
|
|||
|
|
record.finished_at = _now()
|
|||
|
|
if status == "cancelled":
|
|||
|
|
record.result = None
|
|||
|
|
record.error = "cancelled"
|
|||
|
|
|
|||
|
|
# ---------------- 轮询 / 结果 ----------------
|
|||
|
|
def poll(self, job_id: str) -> dict[str, Any] | None:
|
|||
|
|
"""查询任务快照(含 result/error);不存在返回 None。"""
|
|||
|
|
with self._lock:
|
|||
|
|
record = self._jobs.get(job_id)
|
|||
|
|
return record.model_dump(mode="json") if record else None
|
|||
|
|
|
|||
|
|
def wait(self, job_id: str, timeout: float = 15.0) -> dict[str, Any]:
|
|||
|
|
"""阻塞轮询至终态(done/failed/cancelled)。"""
|
|||
|
|
deadline = time.time() + timeout
|
|||
|
|
while True:
|
|||
|
|
record = self.poll(job_id)
|
|||
|
|
if record is None:
|
|||
|
|
raise KeyError(job_id)
|
|||
|
|
if record["status"] in _TERMINAL:
|
|||
|
|
return record
|
|||
|
|
if time.time() >= deadline:
|
|||
|
|
raise TimeoutError(f"任务 {job_id} 等待超时({timeout}s)")
|
|||
|
|
time.sleep(0.05)
|
|||
|
|
|
|||
|
|
# ---------------- 取消 ----------------
|
|||
|
|
def cancel(self, job_id: str) -> bool:
|
|||
|
|
"""取消任务:pending 立即取消;running 置取消标记(worker 收尾时落 cancelled);
|
|||
|
|
已终态或不存在返回 False。"""
|
|||
|
|
with self._lock:
|
|||
|
|
record = self._jobs.get(job_id)
|
|||
|
|
if record is None or record.status in _TERMINAL:
|
|||
|
|
return False
|
|||
|
|
record.cancel_requested = True
|
|||
|
|
if record.status == "pending":
|
|||
|
|
record.status = "cancelled"
|
|||
|
|
record.finished_at = _now()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
# ---------------- 列表 / 统计 ----------------
|
|||
|
|
def list(self, status: str | None = None) -> list[dict[str, Any]]:
|
|||
|
|
with self._lock:
|
|||
|
|
items = [r.model_dump(mode="json") for r in self._jobs.values()]
|
|||
|
|
if status:
|
|||
|
|
items = [i for i in items if i["status"] == status]
|
|||
|
|
items.sort(key=lambda i: i["created_at"] or "", reverse=True)
|
|||
|
|
return items
|
|||
|
|
|
|||
|
|
def stats(self) -> dict[str, int]:
|
|||
|
|
with self._lock:
|
|||
|
|
counts: dict[str, int] = {
|
|||
|
|
"total": len(self._jobs), "pending": 0, "running": 0,
|
|||
|
|
"done": 0, "failed": 0, "cancelled": 0,
|
|||
|
|
}
|
|||
|
|
for r in self._jobs.values():
|
|||
|
|
counts[r.status] = counts.get(r.status, 0) + 1
|
|||
|
|
return counts
|
|||
|
|
|
|||
|
|
def _prune(self) -> None:
|
|||
|
|
"""只保留最多 max_finished 条终态任务(按完成时间淘汰最旧)。"""
|
|||
|
|
with self._lock:
|
|||
|
|
finished = sorted(
|
|||
|
|
(r for r in self._jobs.values() if r.status in _TERMINAL),
|
|||
|
|
key=lambda r: r.finished_at or "",
|
|||
|
|
)
|
|||
|
|
overflow = len(finished) - self.max_finished
|
|||
|
|
for r in finished[:overflow]:
|
|||
|
|
self._jobs.pop(r.job_id, None)
|
|||
|
|
|
|||
|
|
def clear(self) -> None:
|
|||
|
|
with self._lock:
|
|||
|
|
self._jobs.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
_queue: JobQueue | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_job_queue() -> JobQueue:
|
|||
|
|
global _queue
|
|||
|
|
if _queue is None:
|
|||
|
|
_queue = JobQueue()
|
|||
|
|
return _queue
|
|||
|
|
|
|||
|
|
|
|||
|
|
def reset_job_queue() -> None:
|
|||
|
|
"""测试用:丢弃单例,下次重建。"""
|
|||
|
|
global _queue
|
|||
|
|
_queue = None
|