refactor: dataize scheduler job contracts

This commit is contained in:
jxxghp
2026-08-21 21:59:11 +08:00
parent dce7372b81
commit c755c074b9
4 changed files with 288 additions and 154 deletions
+101 -1
View File
@@ -9,7 +9,10 @@ Scheduler 实现由 startup 组合根在导入期注册,避免 application 层
agent.tools / api.endpoints -> application.scheduling <- startup(注册 Scheduler 类)
"""
from typing import Any, List, Optional
import asyncio
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, Awaitable, Callable, List, Optional
# Agent 自主定时任务在运行时调度器中的任务 ID 前缀。
AGENT_TASK_JOB_PREFIX = "agent-task"
@@ -18,6 +21,103 @@ AGENT_TASK_JOB_PREFIX = "agent-task"
_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
+87 -153
View File
@@ -49,7 +49,13 @@ from app.runtime.scheduling import TimerUtils
lock = threading.Lock()
SCHEDULER_PROGRESS_PREFIX = "scheduler"
# Agent 自主定时任务前缀下沉到 application 门面,此处保留兼容导出。
from app.application.scheduling import AGENT_TASK_JOB_PREFIX # noqa: E402
from app.application.scheduling import ( # noqa: E402
AGENT_TASK_JOB_PREFIX,
JobCatalog,
JobExecutionState,
JobRecoveryPolicy,
JobSpec,
)
class SchedulerChain(ChainBase):
@@ -206,11 +212,13 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
return
job_id = "database_backup"
self._jobs[job_id] = {
"name": "数据库备份",
"func": self.database_backup,
"running": False,
}
self._jobs[job_id] = JobSpec(
job_id,
"数据库备份",
self.database_backup,
"database",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
).to_runtime_state()
self._scheduler.add_job(
self.start,
trigger=TimerUtils.build_schedule_trigger(
@@ -242,112 +250,28 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
with lock:
# 各服务的运行状态
mediaserver_chain = MediaServerChain()
self._jobs = {
"cookiecloud": {
"name": "同步CookieCloud站点",
"func": SiteChain().sync_cookies,
"running": False,
},
"mediaserver_sync": {
"name": "同步媒体服务器",
"func": mediaserver_chain.sync,
"running": False,
},
"subscribe_tmdb": {
"name": "订阅元数据更新",
"func": SubscribeChain().check,
"running": False,
},
"subscribe_search": {
"name": "订阅搜索补全",
"func": SubscribeChain().search,
"running": False,
"kwargs": {"state": "R"},
},
"new_subscribe_search": {
"name": "新增订阅搜索",
"func": SubscribeChain().search,
"running": False,
"kwargs": {"state": "N"},
},
"subscribe_refresh": {
"name": "订阅刷新",
"func": SubscribeChain().refresh,
"running": False,
},
"subscribe_follow": {
"name": "关注的订阅分享",
"func": SubscribeChain().follow,
"running": False,
},
"transfer": {
"name": "下载文件整理",
"func": TransferChain().process,
"running": False,
},
"clear_cache": {
"name": "缓存清理",
"func": self.clear_cache,
"running": False,
"manual": True,
},
"data_cleanup": {
"name": "数据表清理",
"func": SchedulerChain().cleanup,
"running": False,
},
"user_auth": {
"name": "用户认证检查",
"func": self.user_auth,
"running": False,
},
"scheduler_job": {
"name": "公共定时服务",
"func": SchedulerChain().scheduler_job,
"running": False,
},
"random_wallpager": {
"name": "壁纸缓存",
"func": WallpaperHelper().get_wallpapers,
"running": False,
},
"sitedata_refresh": {
"name": "站点数据刷新",
"func": SiteChain().refresh_userdatas,
"running": False,
},
"recommend_refresh": {
"name": "推荐缓存",
"func": RecommendChain().refresh_recommend,
"running": False,
},
"plugin_market_refresh": {
"name": "插件市场缓存",
"func": PluginManager().async_get_online_plugins,
"running": False,
"kwargs": {"force": True},
},
"subscribe_calendar_cache": {
"name": "订阅日历缓存",
"func": SubscribeChain().cache_calendar,
"running": False,
},
"full_gc": {
"name": "主动内存回收",
"func": self.full_gc,
"running": False,
},
"agent_heartbeat": {
"name": "智能体定时任务",
"func": self.agent_heartbeat,
"running": False,
},
"usage_report": {
"name": "安装版本统计上报",
"func": MoviePilotServerHelper.report_usage,
"running": False,
},
}
self._jobs = JobCatalog([
JobSpec("cookiecloud", "同步CookieCloud站点", SiteChain().sync_cookies, "site"),
JobSpec("mediaserver_sync", "同步媒体服务器", mediaserver_chain.sync, "mediaserver"),
JobSpec("subscribe_tmdb", "订阅元数据更新", SubscribeChain().check, "subscription"),
JobSpec("subscribe_search", "订阅搜索补全", SubscribeChain().search, "subscription", kwargs={"state": "R"}),
JobSpec("new_subscribe_search", "新增订阅搜索", SubscribeChain().search, "subscription", kwargs={"state": "N"}),
JobSpec("subscribe_refresh", "订阅刷新", SubscribeChain().refresh, "subscription"),
JobSpec("subscribe_follow", "关注的订阅分享", SubscribeChain().follow, "subscription"),
JobSpec("transfer", "下载文件整理", TransferChain().process, "transfer", recovery=JobRecoveryPolicy.DURABLE_QUEUE),
JobSpec("clear_cache", "缓存清理", self.clear_cache, "runtime", manual=True, recovery=JobRecoveryPolicy.MANUAL_ONLY),
JobSpec("data_cleanup", "数据表清理", SchedulerChain().cleanup, "database"),
JobSpec("user_auth", "用户认证检查", self.user_auth, "security"),
JobSpec("scheduler_job", "公共定时服务", SchedulerChain().scheduler_job, "module"),
JobSpec("random_wallpager", "壁纸缓存", WallpaperHelper().get_wallpapers, "image"),
JobSpec("sitedata_refresh", "站点数据刷新", SiteChain().refresh_userdatas, "site"),
JobSpec("recommend_refresh", "推荐缓存", RecommendChain().refresh_recommend, "recommend"),
JobSpec("plugin_market_refresh", "插件市场缓存", PluginManager().async_get_online_plugins, "plugin", kwargs={"force": True}),
JobSpec("subscribe_calendar_cache", "订阅日历缓存", SubscribeChain().cache_calendar, "subscription"),
JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"),
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
]).runtime_states()
self._scheduler = BackgroundScheduler(
timezone=settings.TZ,
@@ -355,11 +279,13 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
)
self._register_database_backup_job()
self._jobs["outbox_dispatch"] = {
"name": "恢复待投递副作用",
"func": dispatch_pending_outbox,
"running": False,
}
self._jobs["outbox_dispatch"] = JobSpec(
"outbox_dispatch",
"恢复待投递副作用",
dispatch_pending_outbox,
"outbox",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
).to_runtime_state()
self._scheduler.add_job(
self.start,
"interval",
@@ -393,12 +319,13 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
)
for mediaserver_schedule in mediaserver_schedules:
job_id = mediaserver_schedule["id"]
self._jobs[job_id] = {
"name": mediaserver_schedule["name"],
"func": mediaserver_chain.sync,
"running": False,
"kwargs": {"server": mediaserver_schedule["server"]},
}
self._jobs[job_id] = JobSpec(
job_id,
mediaserver_schedule["name"],
mediaserver_chain.sync,
"mediaserver",
kwargs={"server": mediaserver_schedule["server"]},
).to_runtime_state()
self._scheduler.add_job(
self.start,
"interval",
@@ -632,13 +559,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
job = self._jobs.get(job_id)
if not job:
return None
if job.get("running"):
if not JobExecutionState.begin(job, started_at):
logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...")
return None
self._jobs[job_id]["running"] = True
self._jobs[job_id]["last_started_at"] = started_at
self._jobs[job_id]["last_finished_at"] = None
self._jobs[job_id]["last_error"] = None
progress = ProgressHelper(self._get_progress_key(job_id))
progress.start()
progress.update(
@@ -671,9 +594,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
with self._lock:
job = self._jobs.get(job_id)
if job:
job["running"] = False
job["last_finished_at"] = finished_at
job["last_error"] = error
JobExecutionState.finish(job, finished_at, error)
job_name = job.get("name") if job else job_id
# 收尾可能发生在事件循环上(__run_coro_job),使用异步进度后端避免阻塞
progress = AsyncProgressHelper(self._get_progress_key(job_id))
@@ -862,9 +783,16 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
success = True
error = None
try:
result = await coro
result = await JobExecutionState.await_result(
coro,
timeout_seconds=job.get("timeout_seconds"),
)
error = self.__get_result_error(result)
success = error is None
except asyncio.TimeoutError as err:
success = False
error = f"任务执行超时({job.get('timeout_seconds')} 秒)"
self.__handle_job_error(job_id=job_id, job=job, error=err)
except asyncio.CancelledError:
success = False
error = "任务已取消"
@@ -1050,13 +978,15 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
self._jobs[job_id] = {
"name": task.name,
"provider_name": "[Agent]",
"func": self.execute_agent_task,
"running": False,
"kwargs": {"task_id": task_id},
}
self._jobs[job_id] = JobSpec(
job_id,
task.name,
self.execute_agent_task,
"agent",
recovery=JobRecoveryPolicy.NEXT_SCHEDULE,
kwargs={"task_id": task_id},
).to_runtime_state()
self._jobs[job_id]["provider_name"] = "[Agent]"
# 已开始的一次任务在重启后结果未知,只保留显式执行入口,不能按
# 过期触发时间自动重放可能已经发生的外部副作用。
if manual_only:
@@ -1272,12 +1202,13 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
with self._lock:
try:
job_id = f"workflow-{workflow.id}"
self._jobs[job_id] = {
"func": WorkflowChain().process,
"name": workflow.name,
"provider_name": "工作流",
"running": False,
}
self._jobs[job_id] = JobSpec(
job_id,
workflow.name,
WorkflowChain().process,
"workflow",
).to_runtime_state()
self._jobs[job_id]["provider_name"] = "工作流"
self._scheduler.add_job(
self.start,
trigger=CronTrigger.from_crontab(workflow.timer),
@@ -1321,14 +1252,17 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
sid = f"{pid}_{service['id']}"
job_id = sid.split("|")[0]
self.remove_plugin_job(pid, job_id)
self._jobs[job_id] = {
"func": service["func"],
"name": service["name"],
"pid": pid,
"provider_name": plugin_name,
"kwargs": service.get("func_kwargs") or {},
"running": False,
}
self._jobs[job_id] = JobSpec(
job_id,
service["name"],
service["func"],
f"plugin:{pid}",
kwargs=service.get("func_kwargs") or {},
).to_runtime_state()
self._jobs[job_id].update(
pid=pid,
provider_name=plugin_name,
)
self._scheduler.add_job(
self.start,
service["trigger"],
@@ -705,6 +705,18 @@ app/scheduler.py # APScheduler 兼容 Facade
**步骤**:先把 job 定义数据化,再提执行状态;不在第一步替换 APScheduler。每个 job 必须声明 overlap policy、timeout、manual、recovery 和 owner。
**实施记录(2026-08-21**
- `app.application.scheduling` 新增 `JobSpec``JobCatalog``JobExecutionState` 以及 overlap/recovery 枚举;
系统、媒体服务器、Agent、工作流、插件和 outbox 动态任务均由同一合同生成兼容运行状态。
- 保留 APScheduler 和既有 `Scheduler` Facade;重入判断、开始/结束/失败状态统一由 execution state 收敛,
job 状态稳定暴露 owner、overlap、timeout、manual、recovery 五项策略。
- coroutine job 的非空 timeout 使用 `asyncio.wait_for`,超时会取消底层任务并记录明确终态;同步 job 默认
`timeout=None`,避免用线程强杀制造不可控的半完成副作用。一次性 Agent 任务重启后保持 manual-onlydurable
outbox/备份/整理与 next-schedule 任务的恢复语义可审计。
- 61 个 Scheduler、Agent 定时任务、备份、进度和媒体服务器专项测试通过,覆盖重复 ID、overlap skip、
timeout cancel、restart/manual recovery 与兼容状态字段。
### 阶段 6:可观测性、类型和复杂度预算
#### ARCH-260:统一 request/correlation ID
+88
View File
@@ -0,0 +1,88 @@
"""Scheduler 声明、catalog 与 execution state 测试。"""
import asyncio
from unittest.mock import MagicMock
import pytest
from app.application.scheduling import (
JobCatalog,
JobExecutionState,
JobOverlapPolicy,
JobRecoveryPolicy,
JobSpec,
)
def test_job_spec_exports_complete_legacy_state() -> None:
"""每个声明必须显式暴露 owner、overlap、timeout、manual 和 recovery。"""
state = JobSpec(
"outbox",
"恢复副作用",
MagicMock(),
"outbox",
timeout_seconds=30,
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
).to_runtime_state()
assert state["owner"] == "outbox"
assert state["overlap"] == JobOverlapPolicy.SKIP.value
assert state["timeout_seconds"] == 30
assert state["manual"] is False
assert state["recovery"] == JobRecoveryPolicy.DURABLE_QUEUE.value
def test_job_catalog_rejects_duplicate_ids() -> None:
"""重复 job ID 在 APScheduler 注册前即失败。"""
spec = JobSpec("same", "相同", MagicMock(), "test")
with pytest.raises(ValueError, match="不得重复"):
JobCatalog([spec, spec])
def test_execution_state_skips_overlap_and_records_terminal_state() -> None:
"""统一 execution state 保持旧的跳过重入并记录失败终态。"""
state = JobSpec("job", "任务", MagicMock(), "test").to_runtime_state()
assert JobExecutionState.begin(state, "start") is True
assert JobExecutionState.begin(state, "second") is False
JobExecutionState.finish(state, "finish", "failed")
assert state["running"] is False
assert state["last_started_at"] == "start"
assert state["last_finished_at"] == "finish"
assert state["last_error"] == "failed"
@pytest.mark.asyncio
async def test_execution_state_cancels_coroutine_after_timeout() -> None:
"""声明的 timeout 必须取消协程并向 Facade 抛出超时。"""
cancelled = asyncio.Event()
async def wait_forever() -> None:
"""模拟只能由取消信号结束的协程任务。"""
try:
await asyncio.Event().wait()
finally:
cancelled.set()
with pytest.raises(asyncio.TimeoutError):
await JobExecutionState.await_result(wait_forever(), timeout_seconds=0.01)
assert cancelled.is_set()
def test_manual_job_declares_restart_policy() -> None:
"""只允许人工触发的任务必须显式声明重启后不自动补跑。"""
state = JobSpec(
"manual",
"人工任务",
MagicMock(),
"runtime",
manual=True,
recovery=JobRecoveryPolicy.MANUAL_ONLY,
).to_runtime_state()
assert state["manual"] is True
assert state["recovery"] == JobRecoveryPolicy.MANUAL_ONLY.value