Files
MoviePilot/app/application/scheduling.py
T

182 lines
5.8 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 的执行和恢复合同。"""
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,
"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) -> None:
"""注册 Scheduler 类(组合根在导入期调用)。"""
global _scheduler_class
_scheduler_class = scheduler_class
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 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)