Files
2026-06-08 18:14:59 +08:00

555 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Service - APScheduler 4.x 调度服务
基于 APScheduler 4.x 实现的定时任务调度核心服务
"""
import json
import logging
import os
import socket
from datetime import datetime
from typing import Optional, Dict, Any, List
from apscheduler import AsyncScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from app.config import settings
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SchedulerService:
"""
定时任务调度服务 (APScheduler 4.x)
功能特点:
1. 使用 APScheduler 4.x 的 AsyncScheduler
2. 支持多种触发器类型(cron、interval、date
3. 自动从数据库加载任务
4. 通过事件订阅监听任务执行
5. 自动更新任务状态和记录日志
"""
_instance = None
_scheduler: Optional[AsyncScheduler] = None
_running: bool = False
def __new__(cls):
"""单例模式"""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def get_scheduler(self) -> Optional[AsyncScheduler]:
"""获取调度器实例"""
return self._scheduler
def set_scheduler(self, scheduler: AsyncScheduler):
"""设置调度器实例"""
self._scheduler = scheduler
self._running = True
def is_running(self) -> bool:
"""判断调度器是否运行中"""
return self._running and self._scheduler is not None
def set_running(self, running: bool):
"""设置运行状态"""
self._running = running
async def load_jobs_from_db(self):
"""从数据库加载所有启用的任务"""
logger.info("开始从数据库加载任务...")
if not self._scheduler:
logger.warning("调度器未初始化,无法加载任务")
return
try:
from sqlalchemy import select, update
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob, SchedulerLog
async with AsyncSessionLocal() as db:
# 注意:启动时处理 running 日志的逻辑已移除
# 因为可能会因为数据库锁而阻塞启动
# running 状态的日志会在前端显示为"执行中",用户可手动处理
# 获取所有启用的任务
result = await db.execute(
select(SchedulerJob).where(
SchedulerJob.status == 1,
SchedulerJob.is_deleted == False # noqa: E712
)
)
jobs = result.scalars().all()
logger.info(f"查询到 {len(jobs)} 个启用的任务")
for job in jobs:
try:
success = await self.add_job(job)
if success:
logger.info(f"加载任务成功: {job.code}")
else:
logger.warning(f"加载任务返回失败: {job.code}")
except Exception as e:
logger.error(f"加载任务失败 {job.code}: {str(e)}")
# 启动定期清理任务
await self._start_cleanup_job()
logger.info(f"任务加载完成,共加载 {len(jobs)} 个任务")
except Exception as e:
logger.error(f"从数据库加载任务失败: {str(e)}")
async def _start_cleanup_job(self):
"""启动定期清理过期任务的定时任务"""
if not self._scheduler:
return
cleanup_job_id = '_scheduler_cleanup'
# 先注册任务
await self._scheduler.configure_task(cleanup_job_id, func=self._cleanup_expired_jobs_wrapper)
# 每天凌晨3点清理过期任务
await self._scheduler.add_schedule(
func_or_task_id=cleanup_job_id,
trigger=CronTrigger(hour=3, minute=0),
id=cleanup_job_id,
)
logger.info("定期清理任务已启动(每天凌晨3点)")
async def _cleanup_expired_jobs_wrapper(self):
"""清理过期任务的包装函数"""
await self.cleanup_expired_jobs(days=7)
async def add_job(self, job_obj) -> bool:
"""添加任务到调度器"""
if not self._scheduler:
logger.error("调度器未初始化")
return False
try:
# 构建触发器
trigger = self._build_trigger(job_obj)
if not trigger:
logger.error(f"无法为任务 {job_obj.code} 构建触发器")
return False
# 导入任务函数
task_func = self._import_task_func(job_obj.task_func)
if not task_func:
logger.error(f"无法导入任务函数: {job_obj.task_func}")
return False
# 解析任务参数
args = json.loads(job_obj.task_args) if job_obj.task_args else []
kwargs = json.loads(job_obj.task_kwargs) if job_obj.task_kwargs else {}
kwargs['job_code'] = job_obj.code
# 创建带日志记录的包装函数
wrapper_func = self._create_job_wrapper(task_func, job_obj.code, args, kwargs)
# APScheduler 4.x: 需要先使用 configure_task 注册任务
task_id = job_obj.code
await self._scheduler.configure_task(task_id, func=wrapper_func)
# 添加任务调度
await self._scheduler.add_schedule(
func_or_task_id=task_id,
trigger=trigger,
id=job_obj.code,
)
logger.info(f"任务 {job_obj.code} 已添加到调度器")
return True
except Exception as e:
logger.error(f"添加任务失败 {job_obj.code}: {str(e)}")
return False
def _create_job_wrapper(self, task_func, job_code: str, args: list, kwargs: dict):
"""创建带日志记录的任务包装函数"""
async def wrapper():
return await self._execute_job(task_func, job_code, args, kwargs)
return wrapper
async def _execute_job(self, task_func, job_code: str, args: list, kwargs: dict):
"""执行任务并记录日志"""
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob, SchedulerLog
from scheduler.task_log_service import TaskLogService, TaskLogger
from sqlalchemy import select
start_time = datetime.now()
exception_info = None
result = None
log_id = None
job_name = job_code
# 在任务开始执行时创建日志记录(状态为 running)
try:
async with AsyncSessionLocal() as db:
# 获取任务
query_result = await db.execute(
select(SchedulerJob).where(SchedulerJob.code == job_code)
)
job_obj = query_result.scalar_one_or_none()
if job_obj:
job_name = job_obj.name
# 创建执行日志(状态为 running)
log = SchedulerLog(
job_id=job_obj.id,
job_name=job_obj.name,
job_code=job_obj.code,
start_time=start_time,
status='running',
hostname=socket.gethostname(),
process_id=os.getpid(),
)
# 更新任务状态为执行中
job_obj.last_run_status = 'running'
db.add(log)
await db.commit()
await db.refresh(log)
log_id = log.id
# 发布任务开始事件
await TaskLogService.publish_start(log_id, job_name)
except Exception as e:
logger.error(f"创建任务执行日志失败: {str(e)}")
# 创建 TaskLogger 并注入到 kwargs
task_logger = None
if log_id:
task_logger = TaskLogger(str(log_id), job_name)
kwargs['task_logger'] = task_logger
# 执行任务
try:
if args:
result = await task_func(*args, **kwargs)
else:
result = await task_func(**kwargs)
except Exception as e:
exception_info = e
logger.error(f"任务 {job_code} 执行失败: {str(e)}")
end_time = datetime.now()
# 发布完成/错误事件
if log_id:
if exception_info:
await TaskLogService.publish_error(str(log_id), job_name, str(exception_info))
else:
await TaskLogService.publish_complete(str(log_id), job_name, str(result) if result else None)
# 更新日志和任务状态
try:
async with AsyncSessionLocal() as db:
# 获取任务
query_result = await db.execute(
select(SchedulerJob).where(SchedulerJob.code == job_code)
)
job_obj = query_result.scalar_one_or_none()
if job_obj:
# 更新执行日志
if log_id:
log_result = await db.execute(
select(SchedulerLog).where(SchedulerLog.id == log_id)
)
log = log_result.scalar_one_or_none()
if log:
log.end_time = end_time
log.duration = (end_time - start_time).total_seconds()
if exception_info:
log.status = 'failed'
log.exception = str(exception_info)
import traceback
log.traceback = traceback.format_exc()
else:
log.status = 'success'
log.result = str(result) if result else None
if exception_info:
# 执行失败
job_obj.last_run_status = 'failed'
job_obj.last_run_result = str(exception_info)
job_obj.failure_count += 1
else:
# 执行成功
job_obj.last_run_status = 'success'
job_obj.last_run_result = str(result) if result else None
job_obj.success_count += 1
job_obj.total_run_count += 1
job_obj.last_run_time = end_time
# 一次性任务(date 类型)执行后更新状态为禁用,但不删除
if job_obj.trigger_type == 'date':
job_obj.status = 0 # 禁用
job_obj.next_run_time = None
await db.commit()
except Exception as e:
logger.error(f"更新任务执行日志失败: {str(e)}")
if exception_info:
raise exception_info
return result
async def remove_job(self, job_code: str) -> bool:
"""从调度器移除任务"""
if not self._scheduler:
return False
try:
await self._scheduler.remove_schedule(job_code)
logger.info(f"任务 {job_code} 已从调度器移除")
return True
except Exception as e:
logger.error(f"移除任务失败 {job_code}: {str(e)}")
return False
async def pause_job(self, job_code: str) -> bool:
"""暂停任务"""
if not self._scheduler:
return False
try:
await self._scheduler.pause_schedule(job_code)
logger.info(f"任务 {job_code} 已暂停")
return True
except Exception as e:
logger.error(f"暂停任务失败 {job_code}: {str(e)}")
return False
async def resume_job(self, job_code: str) -> bool:
"""恢复任务"""
if not self._scheduler:
return False
try:
await self._scheduler.unpause_schedule(job_code)
logger.info(f"任务 {job_code} 已恢复")
return True
except Exception as e:
logger.error(f"恢复任务失败 {job_code}: {str(e)}")
return False
async def modify_job(self, job_obj) -> bool:
"""修改任务"""
try:
# 先移除旧任务
await self.remove_job(job_obj.code)
# 如果任务是启用状态,重新添加
if job_obj.is_enabled():
return await self.add_job(job_obj)
return True
except Exception as e:
logger.error(f"修改任务失败 {job_obj.code}: {str(e)}")
return False
async def run_job_now(self, job_code: str) -> bool:
"""立即执行任务"""
if not self._scheduler:
return False
try:
# 在 APScheduler 4.x 中,使用 run_job 立即执行
from sqlalchemy import select
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob
async with AsyncSessionLocal() as db:
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.code == job_code)
)
job_obj = result.scalar_one_or_none()
if job_obj:
# 解析任务参数
args = json.loads(job_obj.task_args) if job_obj.task_args else []
kwargs = json.loads(job_obj.task_kwargs) if job_obj.task_kwargs else {}
kwargs['job_code'] = job_obj.code
# 导入并执行任务函数
task_func = self._import_task_func(job_obj.task_func)
if task_func:
await self._execute_job(task_func, job_code, args, kwargs)
logger.info(f"任务 {job_code} 已立即执行")
return True
return False
except Exception as e:
logger.error(f"立即执行任务失败 {job_code}: {str(e)}")
return False
async def get_job_info(self, job_code: str) -> Optional[Dict[str, Any]]:
"""获取任务信息"""
if not self._scheduler:
return None
try:
schedules = await self._scheduler.get_schedules()
for schedule in schedules:
if schedule.id == job_code:
return {
'id': schedule.id,
'next_run_time': schedule.next_fire_time.isoformat() if schedule.next_fire_time else None,
'trigger': str(schedule.trigger),
}
return None
except Exception as e:
logger.error(f"获取任务信息失败 {job_code}: {str(e)}")
return None
async def get_all_jobs(self) -> List[Dict[str, Any]]:
"""获取所有任务"""
if not self._scheduler:
return []
try:
schedules = await self._scheduler.get_schedules()
return [
{
'id': schedule.id,
'next_run_time': schedule.next_fire_time.isoformat() if schedule.next_fire_time else None,
'trigger': str(schedule.trigger),
}
for schedule in schedules
]
except Exception as e:
logger.error(f"获取所有任务失败: {str(e)}")
return []
def _build_trigger(self, job_obj):
"""构建触发器"""
try:
if job_obj.trigger_type == 'cron':
# Cron 触发器
parts = job_obj.cron_expression.split()
if len(parts) != 5:
logger.error(f"Cron 表达式格式错误: {job_obj.cron_expression}")
return None
return CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4],
)
elif job_obj.trigger_type == 'interval':
# 间隔触发器
return IntervalTrigger(seconds=job_obj.interval_seconds)
elif job_obj.trigger_type == 'date':
# 指定时间触发器
return DateTrigger(run_time=job_obj.run_date)
else:
logger.error(f"不支持的触发器类型: {job_obj.trigger_type}")
return None
except Exception as e:
logger.error(f"构建触发器失败: {str(e)}")
return None
def _import_task_func(self, task_path: str):
"""动态导入任务函数"""
try:
module_path, func_name = task_path.rsplit('.', 1)
module = __import__(module_path, fromlist=[func_name])
return getattr(module, func_name)
except Exception as e:
logger.error(f"导入任务函数失败 {task_path}: {str(e)}")
return None
async def _cleanup_one_time_job(self, db, job_obj):
"""清理一次性任务"""
try:
job_code = job_obj.code
# 从调度器移除
try:
await self._scheduler.remove_schedule(job_code)
except Exception:
pass
# 软删除数据库记录
job_obj.is_deleted = True
await db.commit()
logger.debug(f"一次性任务已清理: {job_code}")
except Exception as e:
logger.error(f"清理一次性任务失败 {job_obj.code}: {str(e)}")
async def cleanup_expired_jobs(self, days: int = 7) -> int:
"""
清理过期的一次性任务
Args:
days: 清理多少天前的任务,默认7天
"""
try:
from datetime import timedelta
from sqlalchemy import select
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob
cutoff = datetime.now() - timedelta(days=days)
async with AsyncSessionLocal() as db:
# 查找过期的一次性任务
result = await db.execute(
select(SchedulerJob).where(
SchedulerJob.trigger_type == 'date',
SchedulerJob.run_date < cutoff,
SchedulerJob.is_deleted == False # noqa: E712
)
)
expired_jobs = result.scalars().all()
count = len(expired_jobs)
if count > 0:
for job in expired_jobs:
# 从调度器移除
try:
await self._scheduler.remove_schedule(job.code)
except Exception:
pass
# 软删除
job.is_deleted = True
await db.commit()
logger.info(f"清理了 {count} 个过期的一次性任务")
return count
except Exception as e:
logger.error(f"清理过期任务失败: {str(e)}")
return 0
# 全局调度器服务实例
scheduler_service = SchedulerService()