"""定时调度器服务模块 - 基于 APScheduler BackgroundScheduler 管理定时采集任务""" import asyncio import contextlib import logging import re from datetime import datetime, timedelta from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.interval import IntervalTrigger from app.config import settings from app.database import async_session_factory logger = logging.getLogger(__name__) scheduler = BackgroundScheduler( executors={"default": ThreadPoolExecutor(max_workers=settings.max_workers)}, job_defaults={ "max_instances": settings.scheduler_max_instances, "misfire_grace_time": 60, }, ) async def execute_task(task_id: int) -> None: """执行指定任务的数据采集。 每个任务独立创建数据库会话,避免跨线程问题。 """ from app.services.cache import get_task, save_market_data, update_task_status from app.services.collector import fetch_symbol_data async with async_session_factory() as db: try: task = await get_task(db, task_id) if not task or not task.enabled: logger.warning("任务 %s 不存在或已禁用,停止执行", task_id) return periods = task.periods.split(",") if task.periods else [] logger.info("[定时任务] 开始采集 %s (periods=%s)", task.symbol, periods) result = await fetch_symbol_data( symbol=task.symbol, data_type=task.data_type, periods=periods, ) if result and result.get("timeframes"): await save_market_data(db, task.symbol, result) await update_task_status(db, task_id, "success") logger.info("[定时任务] %s 采集成功", task.symbol) # 一次性任务:标记已完成并从调度器移除 if task.task_type == "once": task.is_finished = True task.enabled = False await db.commit() await remove_job(task_id) logger.info("[定时任务] %s 一次性任务已完成", task.symbol) else: await update_task_status(db, task_id, "failed") logger.error("[定时任务] %s 采集失败: %s", task.symbol, (result or {}).get("error")) except Exception as e: logger.error("[定时任务] 执行异常 task_id=%s: %s", task_id, e) with contextlib.suppress(Exception): await update_task_status(db, task_id, "failed") def _run_task_in_thread(task_id: int) -> None: """在线程池中执行异步任务(供 APScheduler 调用)。""" asyncio.run(execute_task(task_id)) async def start_scheduler() -> None: """启动定时调度器。""" if not scheduler.running: scheduler.start() logger.info("调度器已启动") async def stop_scheduler() -> None: """停止定时调度器。""" if scheduler.running: scheduler.shutdown(wait=False) logger.info("调度器已停止") async def add_job( task_id: int, interval_seconds: int, task_type: str = "interval", run_time: str | None = None, ) -> str: """向调度器添加定时任务。 Args: task_id: 任务 ID。 interval_seconds: 执行间隔(秒)。 task_type: 任务类型 (interval / daily / once)。 run_time: 执行时间,格式 HH:MM(用于 daily 和 once 类型)。 Returns: job_id 字符串。 """ job_id = f"task_{task_id}" # 如果已存在,先移除 if scheduler.get_job(job_id): scheduler.remove_job(job_id) # 根据任务类型选择 trigger try: if task_type == "daily" and run_time: if not re.match(r"^\d{2}:\d{2}$", run_time): raise ValueError(f"run_time格式错误: {run_time}, 应为 HH:MM") hour, minute = map(int, run_time.split(":")) if not (0 <= hour <= 23 and 0 <= minute <= 59): raise ValueError(f"run_time值无效: {run_time}") trigger = CronTrigger(hour=hour, minute=minute, timezone="Asia/Shanghai") elif task_type == "once" and run_time: if not re.match(r"^\d{2}:\d{2}$", run_time): raise ValueError(f"run_time格式错误: {run_time}, 应为 HH:MM") hour, minute = map(int, run_time.split(":")) if not (0 <= hour <= 23 and 0 <= minute <= 59): raise ValueError(f"run_time值无效: {run_time}") now = datetime.now() run_dt = now.replace(hour=hour, minute=minute, second=0, microsecond=0) if run_dt <= now: run_dt = run_dt + timedelta(days=1) trigger = DateTrigger(run_date=run_dt, timezone="Asia/Shanghai") else: trigger = IntervalTrigger(seconds=interval_seconds) except ValueError as e: logger.error("任务 %s 时间配置错误: %s, 使用默认间隔触发器", task_id, e) trigger = IntervalTrigger(seconds=interval_seconds) scheduler.add_job( func=_run_task_in_thread, trigger=trigger, args=[task_id], id=job_id, name=f"auto_collect_{task_id}", replace_existing=True, ) logger.info("已添加定时任务: job_id=%s, type=%s, trigger=%s", job_id, task_type, trigger) return job_id async def is_job_running(task_id: int) -> bool: """判断指定任务的定时作业是否正在运行。""" job_id = f"task_{task_id}" return scheduler.get_job(job_id) is not None async def get_all_jobs() -> dict[str, dict]: """获取所有定时作业的状态信息。""" jobs = scheduler.get_jobs() result: dict[str, dict] = {} for job in jobs: next_run = job.next_run_time result[job.id] = { "next_run_time": next_run.isoformat() if next_run else None, } return result async def remove_job(task_id: int) -> bool: """从调度器移除定时任务。 Args: task_id: 任务 ID。 Returns: 是否成功移除。 """ job_id = f"task_{task_id}" job = scheduler.get_job(job_id) if job: scheduler.remove_job(job_id) logger.info("已移除定时任务: %s", job_id) return True return False