scheduler.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """APScheduler 装配。
  2. 用 ``AsyncIOScheduler`` 与主事件循环共用同一个 loop——非 async 的调度器
  3. 会在自己的线程里跑任务,那样 ``agent.astream`` / aiosqlite 的 loop 绑定
  4. 全部失效。这是 1.5 明确记录过的约束。
  5. 启动时把 DB 里 enabled 的任务全量装载进调度器:DB 是任务定义的**事实源**,
  6. 调度器内存里的 job 只是它的一份投影。所以改任务必须「先写 DB,再同步
  7. job」,反之会在重启后丢失。
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. from typing import Any
  12. from apscheduler.schedulers.asyncio import AsyncIOScheduler
  13. from apscheduler.triggers.cron import CronTrigger
  14. from db import schedule_store
  15. from obs import get_logger
  16. from scheduler.runner import run_schedule
  17. logger = get_logger(__name__)
  18. _scheduler: AsyncIOScheduler | None = None
  19. _active: set[asyncio.Task] = set()
  20. def _job_id(schedule_id: str) -> str:
  21. return f"schedule:{schedule_id}"
  22. def _parse_cron(expr: str) -> CronTrigger:
  23. """解析 5 段 cron。非法表达式抛 ValueError,由调用方转成 400。"""
  24. return CronTrigger.from_crontab((expr or "").strip(), timezone="Asia/Shanghai")
  25. async def _invoke(schedule_id: str) -> None:
  26. """job 回调。吞掉异常——见 scheduler/runner.run_schedule 的说明。"""
  27. task = asyncio.current_task()
  28. _active.add(task)
  29. try:
  30. await run_schedule(schedule_id, trigger="cron")
  31. except Exception: # runner 已记录带 schedule_id / trace_id 的错误,避免重复堆栈
  32. pass
  33. finally:
  34. _active.discard(task)
  35. def get_scheduler() -> AsyncIOScheduler | None:
  36. """取调度器实例;未启动返回 None。"""
  37. return _scheduler
  38. def is_running() -> bool:
  39. return _scheduler is not None and _scheduler.running
  40. async def start() -> int:
  41. """启动调度器并装载已启用的任务。返回装载条数。"""
  42. global _scheduler
  43. if _scheduler is not None and _scheduler.running:
  44. return len(_scheduler.get_jobs())
  45. _scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
  46. _scheduler.start()
  47. schedules = await asyncio.to_thread(schedule_store.list_schedules, None, True, 500)
  48. loaded = 0
  49. for schedule in schedules:
  50. try:
  51. add_job(schedule)
  52. loaded += 1
  53. except Exception as exc:
  54. # 单条 cron 非法不该让整个调度器起不来,但必须留下痕迹
  55. logger.error(
  56. "装载定时任务失败 id=%s cron=%r:%s: %s",
  57. schedule.get("id"), schedule.get("cron"), type(exc).__name__, exc,
  58. )
  59. logger.info("调度器已启动,装载 %d 个任务(扫描到 %d 条)", loaded, len(schedules))
  60. return loaded
  61. async def shutdown() -> None:
  62. """停止调度器。"""
  63. global _scheduler
  64. if _scheduler is None:
  65. return
  66. try:
  67. _scheduler.pause()
  68. active = list(_active)
  69. for task in active:
  70. task.cancel()
  71. if active:
  72. await asyncio.gather(*active, return_exceptions=True)
  73. _scheduler.shutdown(wait=False)
  74. except Exception as exc: # pragma: no cover
  75. logger.warning("关闭调度器异常:%s: %s", type(exc).__name__, exc)
  76. _scheduler = None
  77. logger.info("调度器已停止")
  78. def add_job(schedule: dict[str, Any]) -> str:
  79. """把一个任务定义投影成调度器 job。"""
  80. if _scheduler is None:
  81. raise RuntimeError("调度器未启动")
  82. schedule_id = schedule["id"]
  83. trigger = _parse_cron(schedule["cron"])
  84. _scheduler.add_job(
  85. _invoke,
  86. trigger=trigger,
  87. args=[schedule_id],
  88. id=_job_id(schedule_id),
  89. name=schedule.get("title") or schedule_id,
  90. replace_existing=True,
  91. max_instances=1,
  92. # 错过触发时间(服务重启/长时间 GC)时不要补跑:定时任务多是
  93. # 「每日汇报」这类语义,补跑历史时点没有意义,还会突然烧一批额度
  94. misfire_grace_time=60,
  95. coalesce=True,
  96. )
  97. logger.info("已装载定时任务 id=%s cron=%s", schedule_id, schedule["cron"])
  98. return _job_id(schedule_id)
  99. def remove_job(schedule_id: str) -> bool:
  100. """摘掉 job。任务定义被删或停用时调用。"""
  101. if _scheduler is None:
  102. return False
  103. job = _scheduler.get_job(_job_id(schedule_id))
  104. if job is None:
  105. return False
  106. _scheduler.remove_job(_job_id(schedule_id))
  107. logger.info("已摘除定时任务 id=%s", schedule_id)
  108. return True
  109. def sync_job(schedule: dict[str, Any]) -> None:
  110. """按任务定义的当前状态同步 job:enabled 则装载,否则摘除。"""
  111. if schedule.get("enabled"):
  112. add_job(schedule)
  113. else:
  114. remove_job(schedule["id"])
  115. def jobs() -> list[dict[str, Any]]:
  116. """当前调度器里的 job 快照,供 /health 展示。"""
  117. if _scheduler is None:
  118. return []
  119. return [
  120. {
  121. "id": job.id,
  122. "name": job.name,
  123. "next_run_time": job.next_run_time.isoformat() if job.next_run_time else None,
  124. "trigger": str(job.trigger),
  125. }
  126. for job in _scheduler.get_jobs()
  127. ]