|
|
"""定时调度器服务模块 - 基于 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
|