"""APScheduler 调度器:FastAPI lifespan 中启动/关闭。""" import asyncio import logging from contextlib import asynccontextmanager from datetime import datetime from typing import Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from app.config import get_settings from app.database import SessionLocal from app.models.job import Job from app.models.run import Run from app.utils.logging import get_logger log = get_logger(__name__) settings = get_settings() _scheduler: Optional[AsyncIOScheduler] = None async def _execute_scheduled_job(job_id: int) -> None: """调度器回调:创建 run 记录并启动执行。""" db = SessionLocal() try: job = db.get(Job, job_id) if not job or not job.enabled: return run = Run( job_id=job.id, status="pending", trigger="scheduled", started_at=datetime.utcnow(), ) db.add(run) db.commit() db.refresh(run) run_id = run.id finally: db.close() from app.core.executor import run_backup asyncio.create_task(run_backup(run_id, trigger="scheduled")) def _register_job(job: Job) -> Optional[str]: """注册一个 job 的 cron trigger,返回 APScheduler job id。""" assert _scheduler is not None if not job.enabled or not job.cron_expression: return None try: trigger = CronTrigger.from_crontab(job.cron_expression, timezone=settings.TZ) except Exception as e: log.error("Invalid cron for job %s: %s", job.name, e) return None sched_job = _scheduler.add_job( _execute_scheduled_job, trigger=trigger, args=[job.id], id=f"job-{job.id}", replace_existing=True, misfire_grace_time=600, coalesce=True, ) log.info("Registered scheduled job: id=%s name=%s cron=%s", job.id, job.name, job.cron_expression) return sched_job.id def _reload_all_jobs() -> None: """从 DB 重新加载所有 enabled job。""" assert _scheduler is not None # 移除现有 for sj in _scheduler.get_jobs(): sj.remove() db = SessionLocal() try: jobs = db.query(Job).filter(Job.enabled == True, Job.cron_expression.isnot(None)).all() # noqa: E712 for j in jobs: _register_job(j) finally: db.close() @asynccontextmanager async def scheduler_lifespan(): global _scheduler _scheduler = AsyncIOScheduler(timezone=settings.TZ) _scheduler.start() log.info("Scheduler started") try: _reload_all_jobs() yield finally: _scheduler.shutdown(wait=False) log.info("Scheduler stopped") _scheduler = None def reschedule_job(job_id: int) -> None: """job 更新后调用此函数重新注册。""" if _scheduler is None: return sched_id = f"job-{job_id}" # 移除旧的 try: existing = _scheduler.get_job(sched_id) if existing: existing.remove() except Exception: pass # 重新加载 db = SessionLocal() try: job = db.get(Job, job_id) if job: _register_job(job) finally: db.close() def remove_job(job_id: int) -> None: if _scheduler is None: return try: existing = _scheduler.get_job(f"job-{job_id}") if existing: existing.remove() except Exception: pass