mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
JobSpec.timeout_seconds 此前无法表达“显式不限时”:所有未声明预算的协程任务 一律补齐 SCHEDULER_JOB_TIMEOUT,订阅搜索队列因此被 3600 秒总时长判死,而它的 托管协程消费者无法靠取消收尾,只会把正常长轮次记成失败。现在收敛为三态语义 (正数为上限、0 为显式不限时、缺省继承默认),并修正 asyncio 超时异常 str() 为空导致的日志、系统通知与 SystemError 事件文本缺失。
288 lines
8.2 KiB
Python
288 lines
8.2 KiB
Python
"""
|
||
调度器工具服务门面。
|
||
|
||
Agent 工具与 API 端点对运行时调度器的操作统一经本模块调用,
|
||
Scheduler 实现由 startup 组合根在生命周期装配阶段注册,避免 application 层
|
||
静态依赖顶层 scheduler 模块(scheduler 反向依赖 chain,会成环)。
|
||
|
||
依赖方向:
|
||
|
||
agent.tools / api.endpoints -> application.scheduling <- startup(注册 Scheduler 类)
|
||
"""
|
||
|
||
import asyncio
|
||
from dataclasses import dataclass, field
|
||
from enum import StrEnum
|
||
from typing import Any, Awaitable, Callable, List, Optional, cast
|
||
|
||
# Agent 自主定时任务在运行时调度器中的任务 ID 前缀。
|
||
AGENT_TASK_JOB_PREFIX = "agent-task"
|
||
|
||
# Scheduler 类:由 startup/initializers/scheduler.py 在显式启动阶段注册。
|
||
_scheduler_class: Any = None
|
||
|
||
|
||
class JobOverlapPolicy(StrEnum):
|
||
"""
|
||
描述同一 job 已运行时的新触发处理策略。
|
||
"""
|
||
|
||
# 前一次尚未结束时跳过本次触发。
|
||
SKIP = "skip"
|
||
|
||
|
||
class JobRecoveryPolicy(StrEnum):
|
||
"""
|
||
描述进程重启后是否以及如何重建执行意图。
|
||
"""
|
||
|
||
# 不补跑中断执行,等待下一次计划触发。
|
||
NEXT_SCHEDULE = "next_schedule"
|
||
# 依靠持久队列恢复未完成的执行意图。
|
||
DURABLE_QUEUE = "durable_queue"
|
||
# 重启后仅允许显式手动触发。
|
||
MANUAL_ONLY = "manual_only"
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class JobSpec:
|
||
"""
|
||
数据化声明一个业务 job 的执行和恢复合同。
|
||
|
||
:param job_id: 调度任务的唯一稳定标识
|
||
:param name: 向日志和管理界面展示的任务名称
|
||
:param func: 调度触发时调用的业务函数
|
||
:param owner: 任务生命周期所属的业务组件标识
|
||
:param overlap: 前一次仍在执行时的重入策略,默认跳过新触发
|
||
:param timeout_seconds: 执行超时预算:正数为总时长上限,0 表示显式不限时,
|
||
None 表示未声明并由全局默认补齐
|
||
:param manual: 是否允许通过管理界面手动触发
|
||
:param recovery: 进程重启后的恢复策略,默认等待下一次计划触发
|
||
:param kwargs: 每次调用业务函数时使用的关键字参数
|
||
"""
|
||
|
||
job_id: str
|
||
name: str
|
||
func: Callable[..., Any]
|
||
owner: str
|
||
overlap: JobOverlapPolicy = JobOverlapPolicy.SKIP
|
||
timeout_seconds: int | None = None
|
||
manual: bool = False
|
||
recovery: JobRecoveryPolicy = JobRecoveryPolicy.NEXT_SCHEDULE
|
||
kwargs: dict[str, Any] = field(default_factory=dict)
|
||
|
||
def to_runtime_state(self) -> dict[str, Any]:
|
||
"""
|
||
生成兼容 Scheduler Facade 的可变执行状态。
|
||
"""
|
||
return {
|
||
"name": self.name,
|
||
"func": self.func,
|
||
"owner": self.owner,
|
||
"execution_group": "agent" if self.owner == "agent" else "default",
|
||
"overlap": self.overlap.value,
|
||
"timeout_seconds": self.timeout_seconds,
|
||
"manual": self.manual,
|
||
"recovery": self.recovery.value,
|
||
"kwargs": dict(self.kwargs),
|
||
"running": False,
|
||
}
|
||
|
||
|
||
class JobCatalog:
|
||
"""
|
||
保存唯一 job ID 到声明的映射。
|
||
"""
|
||
|
||
def __init__(self, specs: list[JobSpec]) -> None:
|
||
"""
|
||
拒绝重复 ID,并冻结供 Scheduler 初始化的声明集合。
|
||
"""
|
||
self._specs = {spec.job_id: spec for spec in specs}
|
||
if len(self._specs) != len(specs):
|
||
raise ValueError("JobSpec job_id 不得重复")
|
||
|
||
def runtime_states(self) -> dict[str, dict[str, Any]]:
|
||
"""
|
||
返回兼容旧 Scheduler `_jobs` 的全新状态字典。
|
||
"""
|
||
return {
|
||
job_id: spec.to_runtime_state()
|
||
for job_id, spec in self._specs.items()
|
||
}
|
||
|
||
|
||
class JobExecutionState:
|
||
"""
|
||
集中维护兼容 job 字典的 overlap 与终态字段。
|
||
"""
|
||
|
||
@staticmethod
|
||
def begin(job: dict[str, Any], started_at: str) -> bool:
|
||
"""
|
||
按 overlap policy 尝试进入 running,已运行时返回 False。
|
||
"""
|
||
if job.get("running") and job.get(
|
||
"overlap", JobOverlapPolicy.SKIP.value
|
||
) == JobOverlapPolicy.SKIP.value:
|
||
return False
|
||
job.update(
|
||
running=True,
|
||
last_started_at=started_at,
|
||
last_finished_at=None,
|
||
last_error=None,
|
||
)
|
||
return True
|
||
|
||
@staticmethod
|
||
def finish(job: dict[str, Any], finished_at: str, error: str | None) -> None:
|
||
"""
|
||
写入成功或失败终态并释放 running 标记。
|
||
"""
|
||
job.update(
|
||
running=False,
|
||
last_finished_at=finished_at,
|
||
last_error=error,
|
||
)
|
||
|
||
@staticmethod
|
||
async def await_result(
|
||
awaitable: Awaitable[Any], timeout_seconds: int | None
|
||
) -> Any:
|
||
"""
|
||
等待协程任务;声明了超时时由 asyncio 负责取消底层任务。
|
||
"""
|
||
if timeout_seconds is None:
|
||
return await awaitable
|
||
return await asyncio.wait_for(awaitable, timeout=timeout_seconds)
|
||
|
||
|
||
def register_scheduler_class(scheduler_class: Any) -> Any:
|
||
"""
|
||
注册 Scheduler 类,并返回先前实现供失败回滚。
|
||
"""
|
||
global _scheduler_class
|
||
previous = _scheduler_class
|
||
_scheduler_class = scheduler_class
|
||
return previous
|
||
|
||
|
||
def reset_scheduler_class() -> None:
|
||
"""
|
||
清除 Scheduler 实现,避免跨 lifespan 保留具体运行时。
|
||
"""
|
||
register_scheduler_class(None)
|
||
|
||
|
||
def get_scheduler() -> Any:
|
||
"""
|
||
返回调度器实例。
|
||
"""
|
||
if _scheduler_class is None:
|
||
raise RuntimeError(
|
||
"调度器服务未初始化:请先通过 register_scheduler_class 注册 Scheduler 类"
|
||
)
|
||
return _scheduler_class()
|
||
|
||
|
||
class Scheduler:
|
||
"""
|
||
应用层调度器兼容门面,不直接导入顶层 Scheduler 实现。
|
||
"""
|
||
|
||
def __new__(cls) -> Any:
|
||
"""
|
||
返回组合根注册的调度器实例。
|
||
"""
|
||
return get_scheduler()
|
||
|
||
|
||
def list_scheduler_jobs() -> List[Any]:
|
||
"""
|
||
列出运行时调度器的全部任务。
|
||
"""
|
||
return cast(List[Any], get_scheduler().list())
|
||
|
||
|
||
def start_scheduler_job(job_id: str, **kwargs: Any) -> None:
|
||
"""
|
||
立即运行指定的运行时定时任务。
|
||
"""
|
||
get_scheduler().start(job_id, **kwargs)
|
||
|
||
|
||
def update_plugin_job(plugin_id: str) -> None:
|
||
"""
|
||
更新插件的定时任务。
|
||
"""
|
||
get_scheduler().update_plugin_job(plugin_id)
|
||
|
||
|
||
def remove_plugin_job(plugin_id: str) -> None:
|
||
"""
|
||
移除插件的定时任务。
|
||
"""
|
||
get_scheduler().remove_plugin_job(plugin_id)
|
||
|
||
|
||
def add_plugin_once_job(
|
||
plugin_id: str,
|
||
job_id: str,
|
||
func: Callable[..., Any],
|
||
name: str,
|
||
delay_seconds: float = 0,
|
||
func_kwargs: Optional[dict[str, Any]] = None,
|
||
) -> bool:
|
||
"""
|
||
在宿主调度器追加插件一次性任务,替代插件自建 BackgroundScheduler。
|
||
|
||
不重建该插件 get_service() 声明的周期服务;同 ID 重复追加时替换未执行的旧任务。
|
||
:return: 调度器运行中且注册成功时返回 True
|
||
"""
|
||
return cast(
|
||
bool,
|
||
get_scheduler().add_plugin_once_job(
|
||
plugin_id,
|
||
job_id,
|
||
func,
|
||
name,
|
||
delay_seconds=delay_seconds,
|
||
func_kwargs=func_kwargs,
|
||
),
|
||
)
|
||
|
||
|
||
def remove_plugin_once_job(plugin_id: str, job_id: str) -> None:
|
||
"""
|
||
取消插件尚未执行的一次性任务。
|
||
"""
|
||
get_scheduler().remove_plugin_once_job(plugin_id, job_id)
|
||
|
||
|
||
def start_agent_task(task_id: int) -> bool:
|
||
"""
|
||
立即执行 Agent 自主定时任务。
|
||
"""
|
||
return cast(bool, get_scheduler().start_agent_task(task_id))
|
||
|
||
|
||
def get_agent_task_next_run(task_id: int) -> Optional[Any]:
|
||
"""
|
||
查询 Agent 自主定时任务的下一次运行时间。
|
||
"""
|
||
return get_scheduler().get_agent_task_next_run(task_id)
|
||
|
||
|
||
def update_agent_task_job(task_id: int) -> Optional[Any]:
|
||
"""
|
||
更新 Agent 自主定时任务的注册信息,返回下一次运行时间。
|
||
"""
|
||
return get_scheduler().update_agent_task_job(task_id)
|
||
|
||
|
||
def remove_agent_task_job(task_id: int) -> None:
|
||
"""
|
||
移除 Agent 自主定时任务的注册信息。
|
||
"""
|
||
get_scheduler().remove_agent_task_job(task_id)
|