2026-08-11 00:54:05 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 轻量异步任务队列(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。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
class JobQueueFullError(RuntimeError):
|
|
|
|
|
|
"""在途任务达到上限,拒绝继续创建线程。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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
|
2026-08-26 00:25:46 +08:00
|
|
|
|
tenant_uuid: str = "platform"
|
|
|
|
|
|
project_id: str = "default"
|
|
|
|
|
|
|
|
|
|
|
|
def raise_if_cancelled(self) -> None:
|
|
|
|
|
|
if self.cancel_requested:
|
|
|
|
|
|
raise JobCancelled()
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class JobQueue:
|
|
|
|
|
|
"""轻量异步任务队列:提交 / 轮询 / 取消 / 结果 / 统计。"""
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def __init__(self, *, max_finished: int = 200, max_concurrent: int = 2,
|
|
|
|
|
|
max_inflight: int = 8) -> None:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
self._lock = threading.RLock()
|
|
|
|
|
|
self._jobs: dict[str, JobRecord] = {}
|
|
|
|
|
|
self._seq = itertools.count(1)
|
|
|
|
|
|
self.max_finished = max_finished
|
2026-08-26 00:25:46 +08:00
|
|
|
|
self.max_concurrent = max(1, int(max_concurrent))
|
|
|
|
|
|
self.max_inflight = max(self.max_concurrent, int(max_inflight))
|
|
|
|
|
|
self._slots = threading.BoundedSemaphore(self.max_concurrent)
|
|
|
|
|
|
self._inflight_threads = 0
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------------- 提交 ----------------
|
|
|
|
|
|
def submit(
|
|
|
|
|
|
self,
|
|
|
|
|
|
kind: str,
|
|
|
|
|
|
fn: Callable[[JobRecord], Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
params: dict[str, Any] | None = None,
|
|
|
|
|
|
actor: str = "planner",
|
2026-08-26 00:25:46 +08:00
|
|
|
|
tenant_uuid: str = "platform",
|
|
|
|
|
|
project_id: str = "default",
|
2026-08-11 00:54:05 +08:00
|
|
|
|
) -> str:
|
|
|
|
|
|
"""提交任务:立即起 daemon 线程执行,返回 job_id。"""
|
|
|
|
|
|
job_id = f"job-{next(self._seq):04d}-{uuid.uuid4().hex[:6]}"
|
|
|
|
|
|
with self._lock:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if self._inflight_threads >= self.max_inflight:
|
|
|
|
|
|
raise JobQueueFullError(f"异步任务在途已达上限 {self.max_inflight}")
|
|
|
|
|
|
record = JobRecord(
|
|
|
|
|
|
job_id=job_id, kind=kind, params=params or {}, actor=actor,
|
|
|
|
|
|
tenant_uuid=tenant_uuid, project_id=project_id, created_at=_now(),
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
self._jobs[job_id] = record
|
2026-08-26 00:25:46 +08:00
|
|
|
|
self._inflight_threads += 1
|
2026-08-11 00:54:05 +08:00
|
|
|
|
thread = threading.Thread(
|
|
|
|
|
|
target=self._worker, args=(job_id, fn),
|
|
|
|
|
|
name=f"aps-job-{job_id}", daemon=True,
|
|
|
|
|
|
)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
try:
|
|
|
|
|
|
thread.start()
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
self._jobs.pop(job_id, None)
|
|
|
|
|
|
self._inflight_threads = max(0, self._inflight_threads - 1)
|
|
|
|
|
|
raise
|
2026-08-11 00:54:05 +08:00
|
|
|
|
return job_id
|
|
|
|
|
|
|
|
|
|
|
|
def _worker(self, job_id: str, fn: Callable[[JobRecord], Any]) -> None:
|
|
|
|
|
|
try:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
with self._slots:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
with self._lock:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
record = self._jobs[job_id]
|
|
|
|
|
|
if record.status == "cancelled" or record.cancel_requested:
|
|
|
|
|
|
self._finish(job_id, "cancelled")
|
|
|
|
|
|
return
|
|
|
|
|
|
record.status = "running"; record.started_at = _now()
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = fn(record)
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
current = self._jobs[job_id]
|
|
|
|
|
|
if current.cancel_requested:
|
|
|
|
|
|
self._finish(job_id, "cancelled")
|
|
|
|
|
|
else:
|
|
|
|
|
|
current.result = result; current.progress = 100
|
|
|
|
|
|
self._finish(job_id, "done")
|
|
|
|
|
|
except JobCancelled:
|
|
|
|
|
|
self._finish(job_id, "cancelled")
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
current = self._jobs[job_id]
|
|
|
|
|
|
if current.cancel_requested:
|
|
|
|
|
|
self._finish(job_id, "cancelled")
|
|
|
|
|
|
else:
|
|
|
|
|
|
current.error = str(exc)
|
|
|
|
|
|
self._finish(job_id, "failed")
|
2026-08-11 00:54:05 +08:00
|
|
|
|
finally:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
with self._lock:
|
|
|
|
|
|
self._inflight_threads = max(0, self._inflight_threads - 1)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 轮询 / 结果 ----------------
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def poll(self, job_id: str, *, tenant_uuid: str | None = None,
|
|
|
|
|
|
project_id: str | None = None,
|
|
|
|
|
|
actor: str | None = None) -> dict[str, Any] | None:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""查询任务快照(含 result/error);不存在返回 None。"""
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
record = self._jobs.get(job_id)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if record and tenant_uuid is not None and record.tenant_uuid != tenant_uuid: return None
|
|
|
|
|
|
if record and project_id is not None and record.project_id != project_id: return None
|
|
|
|
|
|
if record and actor is not None and record.actor != actor: return None
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 取消 ----------------
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def cancel(self, job_id: str, *, tenant_uuid: str | None = None,
|
|
|
|
|
|
project_id: str | None = None,
|
|
|
|
|
|
actor: str | None = None) -> bool:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""取消任务: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
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if tenant_uuid is not None and record.tenant_uuid != tenant_uuid: return False
|
|
|
|
|
|
if project_id is not None and record.project_id != project_id: return False
|
|
|
|
|
|
if actor is not None and record.actor != actor: return False
|
2026-08-11 00:54:05 +08:00
|
|
|
|
record.cancel_requested = True
|
|
|
|
|
|
if record.status == "pending":
|
|
|
|
|
|
record.status = "cancelled"
|
|
|
|
|
|
record.finished_at = _now()
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 列表 / 统计 ----------------
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def list(self, status: str | None = None, *, tenant_uuid: str | None = None,
|
|
|
|
|
|
project_id: str | None = None,
|
|
|
|
|
|
actor: str | None = None) -> list[dict[str, Any]]:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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]
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if tenant_uuid is not None: items = [i for i in items if i["tenant_uuid"] == tenant_uuid]
|
|
|
|
|
|
if project_id is not None: items = [i for i in items if i["project_id"] == project_id]
|
|
|
|
|
|
if actor is not None: items = [i for i in items if i["actor"] == actor]
|
2026-08-11 00:54:05 +08:00
|
|
|
|
items.sort(key=lambda i: i["created_at"] or "", reverse=True)
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def stats(self, *, tenant_uuid: str | None = None,
|
|
|
|
|
|
project_id: str | None = None,
|
|
|
|
|
|
actor: str | None = None) -> dict[str, int]:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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():
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if tenant_uuid is not None and r.tenant_uuid != tenant_uuid: continue
|
|
|
|
|
|
if project_id is not None and r.project_id != project_id: continue
|
|
|
|
|
|
if actor is not None and r.actor != actor: continue
|
2026-08-11 00:54:05 +08:00
|
|
|
|
counts[r.status] = counts.get(r.status, 0) + 1
|
2026-08-26 00:25:46 +08:00
|
|
|
|
counts["total"] = sum(counts[s] for s in ("pending", "running", "done", "failed", "cancelled"))
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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
|