Files

165 lines
7.1 KiB
Python

import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.module.cleaning.repository import CleaningTaskRepository
from app.module.cleaning.runtime_client import RuntimeClient
logger = get_logger(__name__)
# 模块级单例:进程内所有请求共享同一个调度器
_scheduler: "CleaningTaskScheduler | None" = None
def get_scheduler() -> "CleaningTaskScheduler":
"""获取全局调度器单例,不存在则创建"""
global _scheduler
if _scheduler is None:
_scheduler = CleaningTaskScheduler(
CleaningTaskRepository(None),
RuntimeClient()
)
return _scheduler
class CleaningTaskScheduler:
"""Scheduler for executing cleaning tasks"""
def __init__(self, task_repo: CleaningTaskRepository, runtime_client: RuntimeClient):
self.task_repo = task_repo
self.runtime_client = runtime_client
self._polling_task_ids: set[str] = set() # 待轮询的任务 ID 集合
self._polling_started: bool = False
self._poll_failure_count: dict[str, int] = {} # runtime 不可达连续失败计数
self._MAX_POLL_FAILURES = 1 # 一次超时(60s) → 标记 FAILED
async def execute_task(self, db: AsyncSession, task_id: str, retry_count: int) -> bool:
"""Execute cleaning task"""
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
from datetime import datetime
task = CleaningTaskDto()
task.id = task_id
task.status = CleaningTaskStatus.RUNNING
task.started_at = datetime.now()
task.retry_count = retry_count
submitted = await self.runtime_client.submit_task(task_id, retry_count)
if submitted:
await self.task_repo.update_task(db, task)
self._polling_task_ids.add(task_id)
return submitted
task.status = CleaningTaskStatus.FAILED
task.finished_at = datetime.now()
await self.task_repo.update_task(db, task)
return submitted
async def stop_task(self, db: AsyncSession, task_id: str) -> bool:
"""Stop cleaning task"""
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
self._polling_task_ids.discard(task_id)
self._poll_failure_count.pop(task_id, None)
await self.runtime_client.stop_task(task_id)
task = CleaningTaskDto()
task.id = task_id
task.status = CleaningTaskStatus.STOPPED
await self.task_repo.update_task(db, task)
return True
async def startup(self):
"""进程启动时调用:恢复未完成任务的轮询 + 启动全局轮询协程"""
from app.module.cleaning.schema import CleaningTaskStatus
from app.db.session import AsyncSessionLocal
# 从数据库恢复所有 RUNNING 状态的任务
try:
async with AsyncSessionLocal() as db:
tasks = await self.task_repo.find_tasks(db, status=CleaningTaskStatus.RUNNING)
for task in tasks:
logger.info(f"[Polling] Recovered RUNNING task from DB: {task.id}")
self._polling_task_ids.add(task.id)
except Exception as e:
logger.error(f"[Polling] Failed to recover tasks from DB: {e}")
# 启动全局轮询协程
if not self._polling_started:
self._polling_started = True
asyncio.create_task(self._poll_all_tasks())
logger.info("[Polling] Global polling loop started")
async def _poll_all_tasks(self):
"""全局轮询协程:每 2 秒轮询所有 RUNNING 任务的 runtime 状态"""
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
from app.db.session import AsyncSessionLocal
from datetime import datetime
logger.info("[Polling] Global status polling loop started")
terminal_statuses = {"completed", "failed", "cancelled", "stopped", "not_found"}
while True:
task_ids = list(self._polling_task_ids)
if not task_ids:
# 没有待轮询任务,休眠后继续等
await asyncio.sleep(2)
continue
for task_id in task_ids:
try:
status_data = await self.runtime_client.get_task_status(task_id)
if status_data is None:
# runtime 不可达,累计失败计数
count = self._poll_failure_count.get(task_id, 0) + 1
self._poll_failure_count[task_id] = count
logger.warning(
f"[Polling] Task {task_id} unreachable ({count}/{self._MAX_POLL_FAILURES})"
)
if count >= self._MAX_POLL_FAILURES:
logger.error(
f"[Polling] Task {task_id} marked FAILED: "
f"runtime unreachable after {count} attempts"
)
async with AsyncSessionLocal() as db:
task_dto = CleaningTaskDto()
task_dto.id = task_id
task_dto.status = CleaningTaskStatus.FAILED
task_dto.finished_at = datetime.now()
await self.task_repo.update_task(db, task_dto)
await db.commit()
self._polling_task_ids.discard(task_id)
self._poll_failure_count.pop(task_id, None)
continue
# runtime 可达,重置失败计数
self._poll_failure_count.pop(task_id, None)
current_status = (status_data.get("status", "") or "").lower()
logger.debug(f"[Polling] Task {task_id} status: {current_status}")
if current_status in terminal_statuses:
async with AsyncSessionLocal() as db:
task = CleaningTaskDto()
task.id = task_id
if current_status == "completed":
task.status = CleaningTaskStatus.COMPLETED
else:
task.status = CleaningTaskStatus.FAILED
task.finished_at = datetime.now()
await self.task_repo.update_task(db, task)
await db.commit()
logger.info(
f"[Polling] Task {task_id} finished: {current_status}"
)
self._polling_task_ids.discard(task_id)
self._poll_failure_count.pop(task_id, None)
except Exception as e:
logger.error(f"[Polling] Error polling task {task_id}: {e}")
await asyncio.sleep(2)