428 lines
16 KiB
Python
428 lines
16 KiB
Python
import asyncio
|
|
import uuid
|
|
from collections import Counter
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from yuxi.repositories.task_repository import TaskRepository
|
|
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, utc_isoformat
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
TaskCoroutine = Callable[["TaskContext"], Awaitable[Any]]
|
|
TERMINAL_STATUSES = {"success", "failed", "cancelled"}
|
|
# 纯进度推进时,进度增量小于该阈值则只更新内存、不落库(前端读内存,不受影响)
|
|
PROGRESS_PERSIST_DELTA = 2.0
|
|
# 内存与数据库各保留最近多少条终态任务,超出的自动清理
|
|
MAX_TERMINAL_TASKS = 200
|
|
# 哨兵:区分「未传参」与「显式传入 None」,使 result/error 可被清空
|
|
_UNSET: Any = object()
|
|
|
|
|
|
def _iso_to_utc_naive(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
return coerce_any_to_utc_datetime(value).replace(tzinfo=None)
|
|
|
|
|
|
@dataclass
|
|
class Task:
|
|
id: str
|
|
name: str
|
|
type: str
|
|
status: str = "pending"
|
|
progress: float = 0.0
|
|
message: str = ""
|
|
created_at: str = field(default_factory=utc_isoformat)
|
|
updated_at: str = field(default_factory=utc_isoformat)
|
|
started_at: str | None = None
|
|
completed_at: str | None = None
|
|
payload: dict[str, Any] = field(default_factory=dict)
|
|
result: Any | None = None
|
|
error: str | None = None
|
|
cancel_requested: bool = False
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
def to_summary_dict(self) -> dict[str, Any]:
|
|
data = asdict(self)
|
|
data.pop("payload", None)
|
|
data.pop("result", None)
|
|
return data
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "Task":
|
|
return cls(
|
|
id=data["id"],
|
|
name=data.get("name", "Unnamed Task"),
|
|
type=data.get("type", "general"),
|
|
status=data.get("status", "pending"),
|
|
progress=data.get("progress", 0.0),
|
|
message=data.get("message", ""),
|
|
created_at=data.get("created_at", utc_isoformat()),
|
|
updated_at=data.get("updated_at", utc_isoformat()),
|
|
started_at=data.get("started_at"),
|
|
completed_at=data.get("completed_at"),
|
|
payload=data.get("payload", {}),
|
|
result=data.get("result"),
|
|
error=data.get("error"),
|
|
cancel_requested=data.get("cancel_requested", False),
|
|
)
|
|
|
|
|
|
class TaskContext:
|
|
def __init__(self, tasker: "Tasker", task_id: str, payload: dict[str, Any] | None = None):
|
|
self._tasker = tasker
|
|
self.task_id = task_id
|
|
self.payload = payload or {}
|
|
|
|
async def set_progress(self, progress: float, message: str | None = None) -> None:
|
|
await self._tasker._update_task(
|
|
self.task_id,
|
|
progress=max(0.0, min(progress, 100.0)),
|
|
message=message,
|
|
)
|
|
|
|
async def set_message(self, message: str) -> None:
|
|
await self._tasker._update_task(self.task_id, message=message)
|
|
|
|
async def set_result(self, result: Any) -> None:
|
|
await self._tasker._update_task(self.task_id, result=result)
|
|
|
|
def is_cancel_requested(self) -> bool:
|
|
return self._tasker._is_cancel_requested(self.task_id)
|
|
|
|
async def raise_if_cancelled(self) -> None:
|
|
if self.is_cancel_requested():
|
|
raise asyncio.CancelledError("Task was cancelled")
|
|
|
|
|
|
class Tasker:
|
|
def __init__(self, worker_count: int = 2):
|
|
self.worker_count = max(1, worker_count)
|
|
self._queue: asyncio.Queue[tuple[str, TaskCoroutine]] = asyncio.Queue()
|
|
self._tasks: dict[str, Task] = {}
|
|
self._lock = asyncio.Lock()
|
|
self._workers: list[asyncio.Task[Any]] = []
|
|
self._started = False
|
|
self._repo = TaskRepository()
|
|
# 记录每个任务上次落库时的进度,用于进度节流
|
|
self._last_persisted_progress: dict[str, float] = {}
|
|
|
|
async def start(self) -> None:
|
|
async with self._lock:
|
|
if self._started:
|
|
return
|
|
await self._load_state()
|
|
for _ in range(self.worker_count):
|
|
worker = asyncio.create_task(self._worker_loop(), name="tasker-worker")
|
|
self._workers.append(worker)
|
|
self._started = True
|
|
logger.info("Tasker started with {} workers", self.worker_count)
|
|
|
|
async def shutdown(self) -> None:
|
|
async with self._lock:
|
|
if not self._started:
|
|
return
|
|
for worker in self._workers:
|
|
worker.cancel()
|
|
await asyncio.gather(*self._workers, return_exceptions=True)
|
|
self._workers.clear()
|
|
self._started = False
|
|
logger.info("Tasker shutdown complete")
|
|
|
|
async def enqueue(
|
|
self,
|
|
*,
|
|
name: str,
|
|
task_type: str,
|
|
payload: dict[str, Any] | None = None,
|
|
coroutine: TaskCoroutine,
|
|
) -> Task:
|
|
task_id = uuid.uuid4().hex
|
|
task = Task(id=task_id, name=name, type=task_type, payload=payload or {})
|
|
async with self._lock:
|
|
self._tasks[task_id] = task
|
|
await self._persist_task(task)
|
|
await self._queue.put((task_id, coroutine))
|
|
logger.info("Enqueued task {} ({})", task_id, name)
|
|
return task
|
|
|
|
async def find_task_by_payload(
|
|
self,
|
|
*,
|
|
task_type: str,
|
|
payload_match: dict[str, Any],
|
|
statuses: set[str] | None = None,
|
|
) -> Task | None:
|
|
async with self._lock:
|
|
return self._find_task_by_payload_locked(task_type, payload_match, statuses)
|
|
|
|
async def enqueue_unique_by_payload(
|
|
self,
|
|
*,
|
|
name: str,
|
|
task_type: str,
|
|
payload: dict[str, Any] | None = None,
|
|
coroutine: TaskCoroutine,
|
|
payload_match: dict[str, Any],
|
|
statuses: set[str] | None = None,
|
|
) -> tuple[Task, bool]:
|
|
task_payload = payload or {}
|
|
async with self._lock:
|
|
existing = self._find_task_by_payload_locked(task_type, payload_match, statuses)
|
|
if existing:
|
|
return existing, False
|
|
task_id = uuid.uuid4().hex
|
|
task = Task(id=task_id, name=name, type=task_type, payload=task_payload)
|
|
self._tasks[task_id] = task
|
|
await self._persist_task(task)
|
|
await self._queue.put((task_id, coroutine))
|
|
logger.info("Enqueued task {} ({})", task.id, name)
|
|
return task, True
|
|
|
|
def _find_task_by_payload_locked(
|
|
self,
|
|
task_type: str,
|
|
payload_match: dict[str, Any],
|
|
statuses: set[str] | None,
|
|
) -> Task | None:
|
|
for task in self._tasks.values():
|
|
if task.type != task_type:
|
|
continue
|
|
if statuses is not None and task.status not in statuses:
|
|
continue
|
|
if all(task.payload.get(key) == value for key, value in payload_match.items()):
|
|
return task
|
|
return None
|
|
|
|
async def list_tasks(self, status: str | None = None, limit: int = 100) -> dict[str, Any]:
|
|
async with self._lock:
|
|
all_tasks = list(self._tasks.values())
|
|
|
|
status_counter = Counter(task.status for task in all_tasks)
|
|
type_counter = Counter(task.type for task in all_tasks)
|
|
all_tasks.sort(key=lambda item: item.created_at or utc_isoformat(), reverse=True)
|
|
|
|
tasks = all_tasks
|
|
if status:
|
|
tasks = [task for task in tasks if task.status == status]
|
|
|
|
limited_tasks = tasks[: max(limit, 0)]
|
|
|
|
summary: dict[str, Any] = {
|
|
"total": len(all_tasks),
|
|
"filtered_total": len(tasks),
|
|
"status_counts": dict(status_counter),
|
|
"type_counts": dict(type_counter),
|
|
}
|
|
|
|
return {
|
|
"tasks": [task.to_summary_dict() for task in limited_tasks],
|
|
"summary": summary,
|
|
}
|
|
|
|
async def get_task(self, task_id: str) -> dict[str, Any] | None:
|
|
async with self._lock:
|
|
task = self._tasks.get(task_id)
|
|
return task.to_dict() if task else None
|
|
|
|
async def cancel_task(self, task_id: str) -> bool:
|
|
async with self._lock:
|
|
task = self._tasks.get(task_id)
|
|
if not task:
|
|
return False
|
|
if task.status in TERMINAL_STATUSES:
|
|
return False
|
|
task.cancel_requested = True
|
|
task.updated_at = utc_isoformat()
|
|
await self._persist_task(task)
|
|
logger.info("Cancellation requested for task {}", task_id)
|
|
return True
|
|
|
|
async def delete_task(self, task_id: str) -> bool:
|
|
"""Delete a task by id. Returns True if deleted, False if not found."""
|
|
async with self._lock:
|
|
if task_id not in self._tasks:
|
|
return False
|
|
del self._tasks[task_id]
|
|
self._last_persisted_progress.pop(task_id, None)
|
|
await self._repo.delete(task_id)
|
|
logger.info("Deleted task {}", task_id)
|
|
return True
|
|
|
|
async def _worker_loop(self) -> None:
|
|
while True:
|
|
try:
|
|
task_id, coroutine = await self._queue.get()
|
|
try:
|
|
task = await self._get_task_instance(task_id)
|
|
if not task:
|
|
continue
|
|
if task.cancel_requested:
|
|
await self._mark_cancelled(task_id, "Task was cancelled before execution")
|
|
continue
|
|
await self._update_task(
|
|
task_id, status="running", progress=0.0, message="任务开始执行", started_at=utc_isoformat()
|
|
)
|
|
context = TaskContext(self, task_id, task.payload)
|
|
try:
|
|
result = await coroutine(context)
|
|
if task.cancel_requested:
|
|
await self._mark_cancelled(task_id, "Task cancelled during execution")
|
|
continue
|
|
await self._update_task(
|
|
task_id,
|
|
status="success",
|
|
progress=100.0,
|
|
message="任务已完成",
|
|
result=result,
|
|
completed_at=utc_isoformat(),
|
|
)
|
|
except asyncio.CancelledError:
|
|
await self._mark_cancelled(task_id, "任务被取消")
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("Task {} failed: {}", task_id, exc)
|
|
await self._update_task(
|
|
task_id,
|
|
status="failed",
|
|
progress=100.0,
|
|
message="任务执行失败",
|
|
error=str(exc),
|
|
completed_at=utc_isoformat(),
|
|
)
|
|
finally:
|
|
self._queue.task_done()
|
|
await self._prune_terminal_tasks()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("Tasker worker error: {}", exc)
|
|
|
|
async def _get_task_instance(self, task_id: str) -> Task | None:
|
|
async with self._lock:
|
|
return self._tasks.get(task_id)
|
|
|
|
async def _mark_cancelled(self, task_id: str, message: str) -> None:
|
|
await self._update_task(
|
|
task_id,
|
|
status="cancelled",
|
|
progress=100.0,
|
|
message=message,
|
|
completed_at=utc_isoformat(),
|
|
)
|
|
|
|
async def _update_task(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
status: str | None = None,
|
|
progress: float | None = None,
|
|
message: str | None = None,
|
|
result: Any = _UNSET,
|
|
error: Any = _UNSET,
|
|
started_at: str | None = None,
|
|
completed_at: str | None = None,
|
|
) -> None:
|
|
async with self._lock:
|
|
task = self._tasks.get(task_id)
|
|
if not task:
|
|
return
|
|
if status:
|
|
task.status = status
|
|
if progress is not None:
|
|
task.progress = max(0.0, min(progress, 100.0))
|
|
if message is not None:
|
|
task.message = message
|
|
if result is not _UNSET:
|
|
task.result = result
|
|
if error is not _UNSET:
|
|
task.error = error
|
|
if started_at is not None:
|
|
task.started_at = started_at
|
|
if completed_at is not None:
|
|
task.completed_at = completed_at
|
|
task.updated_at = utc_isoformat()
|
|
|
|
# 仅进度推进的高频更新做节流;状态切换、结果、错误、起止时间一律立即落库
|
|
only_progress = (
|
|
status is None and result is _UNSET and error is _UNSET and started_at is None and completed_at is None
|
|
)
|
|
if only_progress:
|
|
last = self._last_persisted_progress.get(task_id)
|
|
if last is not None and abs(task.progress - last) < PROGRESS_PERSIST_DELTA:
|
|
return
|
|
self._last_persisted_progress[task_id] = task.progress
|
|
await self._persist_task(task)
|
|
|
|
def _is_cancel_requested(self, task_id: str) -> bool:
|
|
task = self._tasks.get(task_id)
|
|
return bool(task and task.cancel_requested)
|
|
|
|
def _collect_stale_terminal_ids(self) -> list[str]:
|
|
"""从内存中剔除超出保留上限的旧终态任务,返回需要从数据库删除的 id(调用方须持锁)。"""
|
|
terminal = [task for task in self._tasks.values() if task.status in TERMINAL_STATUSES]
|
|
if len(terminal) <= MAX_TERMINAL_TASKS:
|
|
return []
|
|
terminal.sort(key=lambda item: item.created_at or "", reverse=True)
|
|
stale = terminal[MAX_TERMINAL_TASKS:]
|
|
for task in stale:
|
|
self._tasks.pop(task.id, None)
|
|
self._last_persisted_progress.pop(task.id, None)
|
|
return [task.id for task in stale]
|
|
|
|
async def _prune_terminal_tasks(self) -> None:
|
|
async with self._lock:
|
|
stale_ids = self._collect_stale_terminal_ids()
|
|
for task_id in stale_ids:
|
|
await self._repo.delete(task_id)
|
|
if stale_ids:
|
|
logger.info("Pruned {} old terminal tasks", len(stale_ids))
|
|
|
|
async def _load_state(self) -> None:
|
|
records = await self._repo.list_all()
|
|
interrupted = 0
|
|
for record in records:
|
|
task = Task.from_dict(record.to_dict())
|
|
if task.status not in TERMINAL_STATUSES:
|
|
# 进程重启后内存队列已丢失,无法续跑,统一标记为失败
|
|
task.message = "服务重启时任务中断" if task.status == "running" else "服务重启时任务未继续执行"
|
|
task.status = "failed"
|
|
task.updated_at = utc_isoformat()
|
|
await self._persist_task(task)
|
|
interrupted += 1
|
|
self._tasks[task.id] = task
|
|
if interrupted:
|
|
logger.info("Marked {} interrupted tasks as failed", interrupted)
|
|
stale_ids = self._collect_stale_terminal_ids()
|
|
for task_id in stale_ids:
|
|
await self._repo.delete(task_id)
|
|
if stale_ids:
|
|
logger.info("Pruned {} old terminal tasks on startup", len(stale_ids))
|
|
|
|
async def _persist_task(self, task: Task) -> None:
|
|
data: dict[str, Any] = {
|
|
"name": task.name,
|
|
"type": task.type,
|
|
"status": task.status,
|
|
"progress": task.progress,
|
|
"message": task.message,
|
|
"payload": task.payload,
|
|
"result": task.result,
|
|
"error": task.error,
|
|
"cancel_requested": 1 if task.cancel_requested else 0,
|
|
"created_at": _iso_to_utc_naive(task.created_at),
|
|
"updated_at": _iso_to_utc_naive(task.updated_at),
|
|
"started_at": _iso_to_utc_naive(task.started_at),
|
|
"completed_at": _iso_to_utc_naive(task.completed_at),
|
|
}
|
|
await self._repo.upsert(task.id, data)
|
|
|
|
|
|
tasker = Tasker()
|
|
|
|
|
|
__all__ = ["tasker", "TaskContext", "Tasker"]
|