561 lines
20 KiB
Python
561 lines
20 KiB
Python
"""
|
|
APScheduler 集成模块
|
|
提供持久化任务调度服务
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from contextlib import suppress
|
|
from typing import Dict, List, Optional, Callable, AsyncGenerator
|
|
from datetime import datetime
|
|
|
|
from fastapi import Request
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
|
from apscheduler.executors.asyncio import AsyncIOExecutor
|
|
from apscheduler.job import Job
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from apscheduler.triggers.date import DateTrigger
|
|
|
|
|
|
def acquire_scheduler_lock():
|
|
"""
|
|
获取调度器分布式锁,确保只有一个进程实例运行调度器
|
|
|
|
通过在临时目录创建锁文件并使用文件系统锁机制,防止多个进程同时启动调度器。
|
|
支持 Windows 和 Unix/Linux 系统的不同锁实现。
|
|
|
|
Returns:
|
|
tuple: 包含两个元素的元组
|
|
- lock_file (file object or None): 锁文件对象,如果获取锁失败则为 None
|
|
- bool: 是否成功获取锁,成功为 True,失败为 False
|
|
"""
|
|
lock_path = os.path.join(tempfile.gettempdir(), "chongming_scheduler.lock")
|
|
lock_file = open(lock_path, "w")
|
|
try:
|
|
if sys.platform == "win32":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_NB | fcntl.LOCK_EX)
|
|
return lock_file, True
|
|
except (IOError, OSError):
|
|
lock_file.close()
|
|
return None, False
|
|
|
|
|
|
class TaskService:
|
|
"""
|
|
任务调度服务类
|
|
|
|
基于 APScheduler 实现的异步任务调度服务,支持多种触发器类型(间隔、Cron、一次性)。
|
|
使用 SQLite 数据库持久化存储任务信息,确保服务重启后任务不丢失。
|
|
通过文件锁机制保证多进程环境下只有一个实例执行调度任务。
|
|
"""
|
|
|
|
def __init__(self, config, logger, async_session_maker=None):
|
|
"""
|
|
初始化任务调度服务
|
|
|
|
Args:
|
|
config (dict): 配置字典,包含调度器相关配置项
|
|
- scheduler.job_store_path (str): 任务存储数据库路径,默认为 "scheduler_jobs.db"
|
|
logger (logging.Logger): 日志记录器实例,用于记录调度器运行日志
|
|
async_session_maker (callable, optional): 异步会话工厂函数,用于数据库操作,默认为 None
|
|
|
|
Attributes:
|
|
async_session_maker: 异步会话工厂函数
|
|
job_store_db_path: 任务存储数据库文件路径
|
|
scheduler: AsyncIOScheduler 调度器实例
|
|
_started (bool): 调度器是否已启动的标志
|
|
lock_file: 锁文件对象
|
|
is_scheduler_worker (bool): 当前进程是否为调度器工作进程
|
|
logger: 日志记录器实例
|
|
"""
|
|
self.async_session_maker = async_session_maker
|
|
# 使用独立的 SQLite 数据库存储任务(与业务数据库分开)
|
|
self.job_store_db_path = config.get("scheduler", {}).get(
|
|
"job_store_path", "scheduler_jobs.db"
|
|
)
|
|
job_store_url = f"sqlite:///{self.job_store_db_path}"
|
|
|
|
# 配置 job stores 和 executors
|
|
jobstores = {"default": SQLAlchemyJobStore(url=job_store_url)}
|
|
executors = {"default": AsyncIOExecutor()}
|
|
job_defaults = {
|
|
"coalesce": True, # 合并错过的任务
|
|
"max_instances": 3, # 最大并发实例数
|
|
"misfire_grace_time": 30, # 错过任务的宽限时间(秒)
|
|
}
|
|
|
|
self.scheduler = AsyncIOScheduler(
|
|
jobstores=jobstores,
|
|
executors=executors,
|
|
job_defaults=job_defaults,
|
|
timezone="Asia/Shanghai",
|
|
)
|
|
self._started = False
|
|
self.lock_file, self.is_scheduler_worker = acquire_scheduler_lock()
|
|
self.logger = logger
|
|
|
|
async def start(self, init_tasks_callback: Optional[Callable] = None):
|
|
"""
|
|
启动任务调度器
|
|
|
|
仅在当前进程成功获取调度器锁时启动调度器,避免多进程重复启动。
|
|
启动后可选择执行初始化任务回调函数。
|
|
|
|
Args:
|
|
init_tasks_callback (callable, optional): 初始化任务回调函数,接收 TaskService 实例作为参数。
|
|
用于在调度器启动后注册初始任务。默认为 None。
|
|
|
|
Note:
|
|
- 如果调度器已经启动,不会重复启动
|
|
- 只有成功获取锁的进程才会启动调度器
|
|
"""
|
|
if self.is_scheduler_worker:
|
|
if not self._started:
|
|
self.scheduler.start()
|
|
self._started = True
|
|
self.logger.info("任务调度器已启动")
|
|
if init_tasks_callback:
|
|
await init_tasks_callback(self)
|
|
|
|
async def shutdown(self, wait: bool = True):
|
|
"""
|
|
关闭任务调度器
|
|
|
|
优雅地关闭调度器,包括暂停所有运行中的任务、关闭调度器、释放文件锁。
|
|
|
|
Args:
|
|
wait (bool): 是否等待正在执行的任务完成后再关闭。
|
|
True 表示等待任务完成,False 表示立即关闭。默认为 True。
|
|
|
|
Note:
|
|
- 先暂停所有任务,再关闭调度器
|
|
- 释放文件锁以允许其他进程获取锁
|
|
- 使用 suppress 捕获所有异常,确保关闭过程不会抛出异常
|
|
"""
|
|
if self.lock_file:
|
|
with suppress(Exception):
|
|
for job in await self.get_jobs():
|
|
await self.pause_job(job.get("id", ""))
|
|
|
|
if self._started:
|
|
self.scheduler.shutdown(wait=wait)
|
|
self._started = False
|
|
self.logger.info("任务调度器已关闭")
|
|
if sys.platform == "win32":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)
|
|
self.lock_file.close()
|
|
|
|
async def add_interval_job(
|
|
self,
|
|
func: Callable,
|
|
seconds: int = 0,
|
|
minutes: int = 0,
|
|
hours: int = 0,
|
|
days: int = 0,
|
|
job_id: Optional[str] = None,
|
|
args: Optional[List] = None,
|
|
kwargs: Optional[Dict] = None,
|
|
replace_existing: bool = True,
|
|
) -> Job:
|
|
"""
|
|
添加间隔执行任务
|
|
|
|
创建按照固定时间间隔重复执行的任务。
|
|
|
|
Args:
|
|
func (callable): 要执行的任务函数
|
|
seconds (int): 间隔秒数,默认为 0
|
|
minutes (int): 间隔分钟数,默认为 0
|
|
hours (int): 间隔小时数,默认为 0
|
|
days (int): 间隔天数,默认为 0
|
|
job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
|
|
args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
|
|
kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
|
|
replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
|
|
|
|
Returns:
|
|
Job: 创建的任务对象
|
|
|
|
Example:
|
|
>>> await service.add_interval_job(my_func, minutes=5, job_id="cleanup_task")
|
|
"""
|
|
trigger = IntervalTrigger(
|
|
seconds=seconds, minutes=minutes, hours=hours, days=days
|
|
)
|
|
job = self.scheduler.add_job(
|
|
func,
|
|
trigger=trigger,
|
|
id=job_id,
|
|
args=args or [],
|
|
kwargs=kwargs or {},
|
|
replace_existing=replace_existing,
|
|
)
|
|
self.logger.info(f"添加间隔任务: {job.id}")
|
|
return job
|
|
|
|
async def add_cron_job(
|
|
self,
|
|
func: Callable,
|
|
job_id: Optional[str] = None,
|
|
year: Optional[int] = None,
|
|
month: Optional[int] = None,
|
|
day: Optional[int] = None,
|
|
week: Optional[int] = None,
|
|
day_of_week: Optional[int] = None,
|
|
hour: Optional[int] = None,
|
|
minute: Optional[int] = None,
|
|
second: Optional[int] = None,
|
|
args: Optional[List] = None,
|
|
kwargs: Optional[Dict] = None,
|
|
replace_existing: bool = True,
|
|
) -> Job:
|
|
"""
|
|
添加 Cron 表达式任务
|
|
|
|
创建按照 Cron 表达式规则执行的任务,支持精确的时间调度。
|
|
|
|
Args:
|
|
func (callable): 要执行的任务函数
|
|
job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
|
|
year (int, optional): 年份,支持通配符和范围
|
|
month (int, optional): 月份 (1-12)
|
|
day (int, optional): 日期 (1-31)
|
|
week (int, optional): 周数 (1-53)
|
|
day_of_week (int, optional): 星期几 (0-6, 0 表示周一)
|
|
hour (int, optional): 小时 (0-23)
|
|
minute (int, optional): 分钟 (0-59)
|
|
second (int, optional): 秒 (0-59)
|
|
args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
|
|
kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
|
|
replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
|
|
|
|
Returns:
|
|
Job: 创建的任务对象
|
|
|
|
Example:
|
|
>>> # 每天凌晨 2 点执行
|
|
>>> await service.add_cron_job(my_func, hour=2, minute=0, job_id="daily_task")
|
|
"""
|
|
trigger = CronTrigger(
|
|
year=year,
|
|
month=month,
|
|
day=day,
|
|
week=week,
|
|
day_of_week=day_of_week,
|
|
hour=hour,
|
|
minute=minute,
|
|
second=second,
|
|
)
|
|
job = self.scheduler.add_job(
|
|
func,
|
|
trigger=trigger,
|
|
id=job_id,
|
|
args=args or [],
|
|
kwargs=kwargs or {},
|
|
replace_existing=replace_existing,
|
|
)
|
|
self.logger.info(f"添加 cron 任务: {job.id}")
|
|
return job
|
|
|
|
async def add_date_job(
|
|
self,
|
|
func: Callable,
|
|
run_date: Optional[datetime] = None,
|
|
job_id: Optional[str] = None,
|
|
args: Optional[List] = None,
|
|
kwargs: Optional[Dict] = None,
|
|
replace_existing: bool = True,
|
|
) -> Job:
|
|
"""
|
|
添加一次性任务
|
|
|
|
创建在指定时间点执行一次的任务,执行后自动移除。
|
|
|
|
Args:
|
|
func (callable): 要执行的任务函数
|
|
run_date (datetime, optional): 任务执行的具体时间,如果未提供则立即执行
|
|
job_id (str, optional): 任务唯一标识符,如果未提供则自动生成
|
|
args (list, optional): 传递给任务函数的位置参数列表,默认为空列表
|
|
kwargs (dict, optional): 传递给任务函数的关键字参数字典,默认为空字典
|
|
replace_existing (bool): 如果存在相同 ID 的任务,是否替换它。默认为 True
|
|
|
|
Returns:
|
|
Job: 创建的任务对象
|
|
|
|
Example:
|
|
>>> from datetime import datetime
|
|
>>> run_time = datetime(2024, 12, 25, 10, 0, 0)
|
|
>>> await service.add_date_job(my_func, run_date=run_time, job_id="christmas_task")
|
|
"""
|
|
trigger = DateTrigger(run_date=run_date)
|
|
job = self.scheduler.add_job(
|
|
func,
|
|
trigger=trigger,
|
|
id=job_id,
|
|
args=args or [],
|
|
kwargs=kwargs or {},
|
|
replace_existing=replace_existing,
|
|
)
|
|
self.logger.info(f"添加一次性任务: {job.id}")
|
|
return job
|
|
|
|
async def remove_job(self, job_id: str) -> bool:
|
|
"""
|
|
删除任务
|
|
|
|
从调度器中移除指定任务,任务将不再执行。
|
|
|
|
Args:
|
|
job_id (str): 要删除的任务的唯一标识符
|
|
|
|
Returns:
|
|
bool: 删除是否成功,成功返回 True,失败返回 False
|
|
|
|
Note:
|
|
- 如果任务不存在或删除过程中发生异常,返回 False
|
|
- 会记录删除操作的日志
|
|
"""
|
|
try:
|
|
self.scheduler.remove_job(job_id)
|
|
self.logger.info(f"删除任务: {job_id}")
|
|
return True
|
|
except Exception as e:
|
|
self.logger.error(f"删除任务失败 {job_id}: {e}")
|
|
return False
|
|
|
|
async def pause_job(self, job_id: str) -> bool:
|
|
"""
|
|
暂停任务
|
|
|
|
暂停指定任务的执行,任务保留在调度器中但不会触发。
|
|
|
|
Args:
|
|
job_id (str): 要暂停的任务的唯一标识符
|
|
|
|
Returns:
|
|
bool: 暂停是否成功,成功返回 True,失败返回 False
|
|
|
|
Note:
|
|
- 暂停的任务可以通过 resume_job 恢复
|
|
- 如果任务不存在或暂停过程中发生异常,返回 False
|
|
"""
|
|
try:
|
|
self.scheduler.pause_job(job_id)
|
|
self.logger.info(f"暂停任务: {job_id}")
|
|
return True
|
|
except Exception as e:
|
|
self.logger.error(f"暂停任务失败 {job_id}: {e}")
|
|
return False
|
|
|
|
async def resume_job(self, job_id: str) -> bool:
|
|
"""
|
|
恢复任务
|
|
|
|
恢复之前被暂停的任务,使其继续按照原定计划执行。
|
|
|
|
Args:
|
|
job_id (str): 要恢复的任务的唯一标识符
|
|
|
|
Returns:
|
|
bool: 恢复是否成功,成功返回 True,失败返回 False
|
|
|
|
Note:
|
|
- 只能恢复已被暂停的任务
|
|
- 如果任务不存在或恢复过程中发生异常,返回 False
|
|
"""
|
|
try:
|
|
self.scheduler.resume_job(job_id)
|
|
self.logger.info(f"恢复任务: {job_id}")
|
|
return True
|
|
except Exception as e:
|
|
self.logger.error(f"恢复任务失败 {job_id}: {e}")
|
|
return False
|
|
|
|
async def get_job(self, job_id: str) -> Optional[Dict]:
|
|
"""
|
|
获取单个任务信息
|
|
|
|
查询指定任务的详细信息,包括下次执行时间、触发器等。
|
|
|
|
Args:
|
|
job_id (str): 要查询的任务的唯一标识符
|
|
|
|
Returns:
|
|
dict or None: 任务信息字典,如果任务不存在则返回 None。
|
|
字典包含以下字段:
|
|
- id (str): 任务 ID
|
|
- name (str): 任务名称
|
|
- next_run_time (str or None): 下次执行时间的 ISO 格式字符串
|
|
- trigger (str): 触发器描述
|
|
- pending (bool): 任务是否处于待处理状态
|
|
- args (list): 任务函数的位置参数
|
|
- kwargs (dict): 任务函数的关键字参数
|
|
"""
|
|
job = self.scheduler.get_job(job_id)
|
|
if job:
|
|
return self._job_to_dict(job)
|
|
return None
|
|
|
|
async def get_jobs(self) -> List[Dict]:
|
|
"""
|
|
获取所有任务信息
|
|
|
|
查询调度器中所有任务的详细信息列表。
|
|
|
|
Returns:
|
|
list[dict]: 任务信息字典列表,每个字典的结构与 get_job 返回的相同。
|
|
如果没有任务,返回空列表。
|
|
"""
|
|
jobs = self.scheduler.get_jobs()
|
|
return [self._job_to_dict(job) for job in jobs]
|
|
|
|
async def reschedule_job(
|
|
self, job_id: str, trigger_type: str, **trigger_args
|
|
) -> Optional[Job]:
|
|
"""
|
|
重新调度任务(修改触发时间)
|
|
|
|
修改现有任务的触发器配置,改变任务的执行时间规则。
|
|
|
|
Args:
|
|
job_id (str): 要重新调度的任务的唯一标识符
|
|
trigger_type (str): 触发器类型,可选值:
|
|
- "interval": 间隔触发器
|
|
- "cron": Cron 表达式触发器
|
|
- "date": 一次性触发器
|
|
**trigger_args: 触发器参数,根据 trigger_type 不同而不同:
|
|
- interval: seconds, minutes, hours, days
|
|
- cron: year, month, day, week, day_of_week, hour, minute, second
|
|
- date: run_date
|
|
|
|
Returns:
|
|
Job or None: 重新调度后的任务对象,如果任务不存在或重新调度失败则返回 None
|
|
|
|
Raises:
|
|
ValueError: 当传入不支持的 trigger_type 时抛出
|
|
|
|
Example:
|
|
>>> # 将任务改为每 10 分钟执行一次
|
|
>>> await service.reschedule_job("task_id", "interval", minutes=10)
|
|
"""
|
|
try:
|
|
job = self.scheduler.get_job(job_id)
|
|
if not job:
|
|
return None
|
|
|
|
if trigger_type == "interval":
|
|
trigger = IntervalTrigger(**trigger_args)
|
|
elif trigger_type == "cron":
|
|
trigger = CronTrigger(**trigger_args)
|
|
elif trigger_type == "date":
|
|
trigger = DateTrigger(**trigger_args)
|
|
else:
|
|
raise ValueError(f"不支持的 trigger 类型: {trigger_type}")
|
|
|
|
job.reschedule(trigger=trigger)
|
|
self.logger.info(f"重新调度任务: {job_id}")
|
|
return job
|
|
except Exception as e:
|
|
self.logger.error(f"重新调度任务失败 {job_id}: {e}")
|
|
return None
|
|
|
|
def _job_to_dict(self, job: Job) -> Dict:
|
|
"""
|
|
将 Job 对象转换为字典
|
|
|
|
内部辅助方法,将 APScheduler 的 Job 对象序列化为字典格式,
|
|
便于 API 返回和数据传输。
|
|
|
|
Args:
|
|
job (Job): APScheduler 的 Job 对象
|
|
|
|
Returns:
|
|
dict: 包含任务信息的字典,字段包括:
|
|
- id (str): 任务 ID
|
|
- name (str): 任务名称
|
|
- next_run_time (str or None): 下次执行时间的 ISO 8601 格式字符串
|
|
- trigger (str): 触发器的字符串描述
|
|
- pending (bool): 任务是否处于待处理状态
|
|
- args (list): 任务函数的位置参数列表
|
|
- kwargs (dict): 任务函数的关键字参数字典
|
|
"""
|
|
return {
|
|
"id": job.id,
|
|
"name": job.name,
|
|
"next_run_time": (
|
|
job.next_run_time.isoformat() if job.next_run_time else None
|
|
),
|
|
"trigger": str(job.trigger),
|
|
"pending": job.pending,
|
|
"args": job.args,
|
|
"kwargs": job.kwargs,
|
|
}
|
|
|
|
|
|
_task_service: Optional[TaskService] = None
|
|
|
|
|
|
def get_task_service_instance(config=None, logger=None, async_session_maker=None):
|
|
"""
|
|
获取任务调度服务单例实例
|
|
|
|
使用全局变量实现单例模式,确保整个应用中只有一个 TaskService 实例。
|
|
首次调用时创建实例,后续调用直接返回已创建的实例。
|
|
|
|
Args:
|
|
config (dict, optional): 配置字典,仅在首次创建实例时需要
|
|
logger (logging.Logger, optional): 日志记录器,仅在首次创建实例时需要
|
|
async_session_maker (callable, optional): 异步会话工厂函数,仅在首次创建实例时需要
|
|
|
|
Returns:
|
|
TaskService: 任务调度服务单例实例
|
|
|
|
Raises:
|
|
ValueError: 如果首次调用时未提供 config 或 logger 参数
|
|
|
|
Note:
|
|
- 这是一个单例模式实现,确保全局只有一个调度器实例
|
|
- 首次调用必须提供 config 和 logger 参数
|
|
- 后续调用可以不提供参数,直接返回已创建的实例
|
|
"""
|
|
global _task_service
|
|
if _task_service is None:
|
|
if config is None or logger is None:
|
|
raise ValueError("请传入 config 和 logger")
|
|
_task_service = TaskService(config, logger, async_session_maker)
|
|
return _task_service
|
|
|
|
|
|
async def get_task_service(request: Request) -> AsyncGenerator:
|
|
"""
|
|
FastAPI 依赖注入函数,获取任务调度服务实例
|
|
|
|
用于 FastAPI 路由函数的依赖注入,从应用状态中获取已初始化的任务调度服务。
|
|
|
|
Args:
|
|
request (Request): FastAPI 请求对象,从中获取应用实例
|
|
|
|
Returns:
|
|
TaskService: 从 request.app.state.task_service 获取的任务调度服务实例
|
|
|
|
Note:
|
|
- 此函数应在应用启动时通过 startup 事件初始化 task_service
|
|
- 适用于 FastAPI 的 Depends 依赖注入机制
|
|
"""
|
|
return request.app.state.task_service
|