Files
MoviePilot/app/application/scheduling.py
JontyLee f2918b9285 fix(scheduler): 让声明为不限时的协程任务预算真正生效
JobSpec.timeout_seconds 此前无法表达“显式不限时”:所有未声明预算的协程任务
一律补齐 SCHEDULER_JOB_TIMEOUT,订阅搜索队列因此被 3600 秒总时长判死,而它的
托管协程消费者无法靠取消收尾,只会把正常长轮次记成失败。现在收敛为三态语义
(正数为上限、0 为显式不限时、缺省继承默认),并修正 asyncio 超时异常 str()
为空导致的日志、系统通知与 SystemError 事件文本缺失。
2026-10-26 10:34:42 +08:00

288 lines
8.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
调度器工具服务门面。
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)