Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

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"]