mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-10 18:06:48 +08:00
refactor(scheduler): split runtime owners and preserve plugin ABI
This commit is contained in:
+1
-1
@@ -190,7 +190,7 @@ class SiteChain(InteractionChainMixin, ChainBase):
|
||||
# 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托
|
||||
_interaction_handler_type = SiteInteractionHandler
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""初始化站点管理处理链及特殊站点测试器"""
|
||||
super().__init__()
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ def get_scoped_session() -> scoped_session:
|
||||
#
|
||||
# 做成 __dict__ 里真实存在的函数就两头都成立:导入它不碰引擎,调用它才创建。全仓库对这三个
|
||||
# 名字的用法都是 `X()` 取一个会话,这一形式的语义与原先的 sessionmaker / scoped_session 实例
|
||||
# 完全一致;patch("app.scheduler.SessionFactory", ...) 这类既有测试替身也照旧生效。
|
||||
# 完全一致;测试应在实际数据库 owner 边界替换 SessionFactory。
|
||||
#
|
||||
# 但仅限 `X()` 这一形式:它们不再是 sessionmaker / scoped_session 实例,因此实例上的其余接口
|
||||
# (ScopedSession.remove()、SessionFactory.configure()、AsyncSessionFactory.begin()、
|
||||
|
||||
-2158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
"""调度器的惰性稳定公开入口。"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
_EXPORTS = {
|
||||
"Scheduler": ("app.scheduler.facade", "Scheduler"),
|
||||
"SchedulerChain": ("app.scheduler.chain", "SchedulerChain"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""首次访问时解析旧插件 ABI,避免包根导入宿主实现。"""
|
||||
contract = _EXPORTS.get(name)
|
||||
if contract is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, symbol_name = contract
|
||||
value = getattr(import_module(module_name), symbol_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""向交互式工具暴露稳定公开面。"""
|
||||
return sorted({*globals(), *_EXPORTS})
|
||||
|
||||
|
||||
__all__ = ["Scheduler", "SchedulerChain"]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""调度器事件循环提交与异步句柄桥接。"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import threading
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
from app.scheduler.registry import SchedulerHandle
|
||||
|
||||
|
||||
class SchedulerBridgeOwner(_SchedulerOwnerBase):
|
||||
"""调度器事件循环提交与异步句柄桥接。"""
|
||||
|
||||
def _remove_handle(
|
||||
self,
|
||||
handle: asyncio.Future[Any] | concurrent.futures.Future[Any],
|
||||
) -> None:
|
||||
"""执行句柄完成后从 owner registry 移除。"""
|
||||
with self._lock:
|
||||
self._registry.remove_handle(handle)
|
||||
|
||||
def _register_handle(
|
||||
self,
|
||||
job_id: str,
|
||||
generation: int,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
handle: asyncio.Future[Any] | concurrent.futures.Future[Any],
|
||||
completion: asyncio.Future[Any] | concurrent.futures.Future[Any] | None = None,
|
||||
kind: str = "job",
|
||||
) -> bool:
|
||||
"""登记调度器拥有的句柄;关闭竞态下拒绝并取消新句柄。"""
|
||||
if completion is None:
|
||||
completion = handle
|
||||
with self._lock:
|
||||
if not self._accepts_handle(job_id, generation):
|
||||
if isinstance(handle, concurrent.futures.Future):
|
||||
handle.cancel()
|
||||
elif loop.is_running():
|
||||
loop.call_soon_threadsafe(handle.cancel)
|
||||
else:
|
||||
handle.cancel()
|
||||
return False
|
||||
self._registry.register_handle(
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
loop=loop,
|
||||
handle=handle,
|
||||
completion=completion,
|
||||
kind=kind,
|
||||
)
|
||||
completion.add_done_callback(self._remove_handle)
|
||||
return True
|
||||
|
||||
def _accepts_handle(self, job_id: str, generation: int) -> bool:
|
||||
"""判断新句柄是否属于当前运行期或热重载中的既有任务。"""
|
||||
if self._accepting_submissions():
|
||||
return True
|
||||
current_job = self._jobs.get(job_id)
|
||||
return bool(
|
||||
self._lifecycle_state == "reloading"
|
||||
and current_job is not None
|
||||
and current_job.get("_generation", 0) == generation
|
||||
and current_job.get("running")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cancel_handle(handle: SchedulerHandle) -> None:
|
||||
"""从句柄所属线程安全地请求取消。"""
|
||||
target = handle.handle
|
||||
if isinstance(target, concurrent.futures.Future):
|
||||
target.cancel()
|
||||
return
|
||||
if target.done():
|
||||
return
|
||||
if target.get_loop().is_running():
|
||||
target.get_loop().call_soon_threadsafe(target.cancel)
|
||||
else:
|
||||
target.cancel()
|
||||
|
||||
@staticmethod
|
||||
async def _wait_handle(handle: SchedulerHandle) -> None:
|
||||
"""等待取消请求到达协程 finally,而不是只等待提交代理变为 cancelled。"""
|
||||
target = handle.completion
|
||||
if isinstance(target, concurrent.futures.Future):
|
||||
await asyncio.shield(asyncio.wrap_future(target))
|
||||
return
|
||||
if target.get_loop() is asyncio.get_running_loop():
|
||||
await asyncio.shield(target)
|
||||
|
||||
async def _await_cancelled_handles(
|
||||
self,
|
||||
handles: tuple[SchedulerHandle, ...],
|
||||
) -> None:
|
||||
"""等待已投递协程结束,关闭总预算由应用生命周期统一控制。"""
|
||||
if not handles:
|
||||
return
|
||||
await asyncio.gather(
|
||||
*(self._wait_handle(handle) for handle in handles),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _await_progress_handles(self, job_id: str, generation: int) -> None:
|
||||
"""等待同一轮任务已提交的进度更新,保证最终状态最后写入缓存。"""
|
||||
with self._lock:
|
||||
handles = self._registry.handles(
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
kind="progress",
|
||||
)
|
||||
if not handles:
|
||||
return
|
||||
await asyncio.gather(
|
||||
*(self._wait_handle(handle) for handle in handles),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _track_cross_thread_completion(
|
||||
coro: Any,
|
||||
completion: concurrent.futures.Future[Any],
|
||||
started: threading.Event,
|
||||
) -> Any:
|
||||
"""把跨线程提交代理与协程真实终态分离。"""
|
||||
|
||||
async def _tracked() -> None:
|
||||
started.set()
|
||||
try:
|
||||
result = await coro
|
||||
except asyncio.CancelledError:
|
||||
if not completion.done():
|
||||
completion.cancel()
|
||||
except Exception as err:
|
||||
if not completion.done():
|
||||
completion.set_exception(err)
|
||||
else:
|
||||
if not completion.done():
|
||||
completion.set_result(result)
|
||||
|
||||
return _tracked()
|
||||
|
||||
def _submit_cross_thread(
|
||||
self,
|
||||
coro: Any,
|
||||
*,
|
||||
target_loop: asyncio.AbstractEventLoop,
|
||||
job_id: str,
|
||||
generation: int,
|
||||
on_unstarted_cancel: Optional[Callable[[], None]] = None,
|
||||
kind: str = "job",
|
||||
) -> bool:
|
||||
"""向主循环提交协程,并以独立完成信号跟踪真实收尾。"""
|
||||
completion: concurrent.futures.Future[Any] = concurrent.futures.Future()
|
||||
handle: concurrent.futures.Future[Any] = concurrent.futures.Future()
|
||||
started = threading.Event()
|
||||
tracked = self._track_cross_thread_completion(coro, completion, started)
|
||||
task_lock = threading.Lock()
|
||||
target_task: asyncio.Task[Any] | None = None
|
||||
|
||||
def complete_target_task(task: asyncio.Task[Any]) -> None:
|
||||
if task.cancelled() and not started.is_set():
|
||||
if on_unstarted_cancel:
|
||||
on_unstarted_cancel()
|
||||
if not completion.done():
|
||||
completion.cancel()
|
||||
elif not completion.done():
|
||||
error = task.exception()
|
||||
if error is None:
|
||||
completion.set_result(None)
|
||||
else:
|
||||
completion.set_exception(error)
|
||||
if not handle.done():
|
||||
handle.set_result(None)
|
||||
|
||||
def start_on_target_loop() -> None:
|
||||
nonlocal target_task
|
||||
with task_lock:
|
||||
if handle.cancelled():
|
||||
tracked.close()
|
||||
coro.close()
|
||||
if on_unstarted_cancel:
|
||||
on_unstarted_cancel()
|
||||
completion.cancel()
|
||||
return
|
||||
target_task = target_loop.create_task(tracked)
|
||||
target_task.add_done_callback(complete_target_task)
|
||||
|
||||
def cancel_target_task(submitted: concurrent.futures.Future[Any]) -> None:
|
||||
if not submitted.cancelled():
|
||||
return
|
||||
with task_lock:
|
||||
task = target_task
|
||||
if task is not None and not task.done():
|
||||
target_loop.call_soon_threadsafe(task.cancel)
|
||||
|
||||
with self._lock:
|
||||
if not self._accepts_handle(job_id, generation):
|
||||
tracked.close()
|
||||
coro.close()
|
||||
return False
|
||||
try:
|
||||
target_loop.call_soon_threadsafe(start_on_target_loop)
|
||||
except RuntimeError:
|
||||
tracked.close()
|
||||
coro.close()
|
||||
return False
|
||||
|
||||
registered = self._register_handle(
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
loop=target_loop,
|
||||
handle=handle,
|
||||
completion=completion,
|
||||
kind=kind,
|
||||
)
|
||||
handle.add_done_callback(cancel_target_task)
|
||||
return registered
|
||||
|
||||
def _submit_to_loop(
|
||||
self,
|
||||
coro: Any,
|
||||
*,
|
||||
job_id: str,
|
||||
generation: int = 0,
|
||||
on_unstarted_cancel: Optional[Callable[[], None]] = None,
|
||||
kind: str = "job",
|
||||
) -> bool:
|
||||
"""
|
||||
把协程提交到事件循环执行,兼容以下调用环境:
|
||||
- 应用主循环可用:统一由主循环拥有任务和关闭顺序
|
||||
- 仅调用方循环可用:在当前循环排队为独立任务
|
||||
- 无运行中循环(测试/CLI):新建循环同步执行,确保进度不丢失
|
||||
|
||||
job 标识是所有权键;所有句柄都由 Scheduler 持有,关闭时可以取消并等待。
|
||||
"""
|
||||
try:
|
||||
running_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running_loop = None
|
||||
target_loop = main_loop_registry.current
|
||||
target_loop_available = target_loop is not None and target_loop.is_running() and not target_loop.is_closed()
|
||||
if running_loop and (not target_loop_available or running_loop is target_loop):
|
||||
with self._lock:
|
||||
if not self._accepts_handle(job_id, generation):
|
||||
coro.close()
|
||||
return False
|
||||
handle = running_loop.create_task(coro)
|
||||
registered = self._register_handle(
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
loop=running_loop,
|
||||
handle=handle,
|
||||
kind=kind,
|
||||
)
|
||||
if on_unstarted_cancel:
|
||||
handle.add_done_callback(lambda submitted: on_unstarted_cancel() if submitted.cancelled() else None)
|
||||
return registered
|
||||
elif target_loop is not None and target_loop_available:
|
||||
return self._submit_cross_thread(
|
||||
coro,
|
||||
target_loop=target_loop,
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
on_unstarted_cancel=on_unstarted_cancel,
|
||||
kind=kind,
|
||||
)
|
||||
elif self._lifecycle_state in {"stopping", "stopped"}:
|
||||
coro.close()
|
||||
return False
|
||||
else:
|
||||
asyncio.run(coro)
|
||||
return True
|
||||
@@ -0,0 +1,461 @@
|
||||
"""调度器静态任务目录与 APScheduler 投影。"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, TypedDict
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.system.update import system_update_manager
|
||||
from app.application.configuration import (
|
||||
SchedulerRuntimeConfig,
|
||||
)
|
||||
from app.application.mediaserver import get_mediaserver_configs
|
||||
from app.application.outbox import dispatch_pending_outbox
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
from app.application.scheduling import ( # noqa: E402
|
||||
JobCatalog,
|
||||
JobRecoveryPolicy,
|
||||
JobSpec,
|
||||
)
|
||||
from app.runtime.scheduling import TimerUtils
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
from app.schemas.system import MediaServerConf as _SchemaMediaServerConf
|
||||
|
||||
|
||||
class _MediaServerSchedule(TypedDict):
|
||||
"""媒体服务器同步任务的内部投影。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
server: str
|
||||
interval: int
|
||||
|
||||
|
||||
class SchedulerCatalogOwner(_SchedulerOwnerBase):
|
||||
"""调度器静态任务目录与 APScheduler 投影。"""
|
||||
|
||||
@staticmethod
|
||||
def _get_mediaserver_sync_interval(
|
||||
mediaserver: _SchemaMediaServerConf,
|
||||
default_interval: Optional[int],
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
获取媒体服务器的有效同步间隔,未设置时回退旧全局配置。
|
||||
"""
|
||||
interval = mediaserver.sync_interval
|
||||
if interval is None:
|
||||
interval = default_interval
|
||||
if interval is None:
|
||||
return None
|
||||
try:
|
||||
interval = int(interval)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
return interval if interval > 0 else None
|
||||
|
||||
@classmethod
|
||||
def _build_mediaserver_sync_schedules(
|
||||
cls,
|
||||
mediaservers: List[_SchemaMediaServerConf],
|
||||
default_interval: Optional[int],
|
||||
) -> List[_MediaServerSchedule]:
|
||||
"""
|
||||
构建已启用媒体服务器的独立自动同步任务描述。
|
||||
"""
|
||||
schedules: List[_MediaServerSchedule] = []
|
||||
job_ids: set[str] = set()
|
||||
for mediaserver in mediaservers:
|
||||
if not mediaserver or not mediaserver.enabled or not mediaserver.name:
|
||||
continue
|
||||
interval = cls._get_mediaserver_sync_interval(
|
||||
mediaserver=mediaserver,
|
||||
default_interval=default_interval,
|
||||
)
|
||||
if not interval:
|
||||
continue
|
||||
digest = hashlib.sha256(mediaserver.name.encode("utf-8")).hexdigest()[:12]
|
||||
job_id = f"mediaserver_sync_{digest}"
|
||||
if job_id in job_ids:
|
||||
continue
|
||||
job_ids.add(job_id)
|
||||
schedules.append(
|
||||
{
|
||||
"id": job_id,
|
||||
"name": f"同步媒体服务器 - {mediaserver.name}",
|
||||
"server": mediaserver.name,
|
||||
"interval": interval,
|
||||
}
|
||||
)
|
||||
return schedules
|
||||
|
||||
def _register_database_backup_job(
|
||||
self,
|
||||
config: SchedulerRuntimeConfig,
|
||||
) -> None:
|
||||
"""在共享调度器中按当前配置维护唯一的数据库备份作业。"""
|
||||
if not config.db_backup_enable or not config.db_backup_cron.strip():
|
||||
return
|
||||
|
||||
job_id = "database_backup"
|
||||
job = JobSpec(
|
||||
job_id,
|
||||
"数据库备份",
|
||||
self.database_backup,
|
||||
"database",
|
||||
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
|
||||
).to_runtime_state()
|
||||
self._assign_job_generation(job_id, job)
|
||||
self._jobs[job_id] = job
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
trigger=TimerUtils.build_schedule_trigger(
|
||||
trigger_type="cron",
|
||||
trigger_value=config.db_backup_cron,
|
||||
timezone_name=config.timezone,
|
||||
),
|
||||
id=job_id,
|
||||
name="数据库备份",
|
||||
kwargs={"job_id": job_id},
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
def _initialize_catalog(self, config: SchedulerRuntimeConfig) -> None:
|
||||
"""构建完整任务目录并投影到尚未启动的 APScheduler。"""
|
||||
services = self._scheduler_services()
|
||||
# 各服务的运行状态
|
||||
self._jobs = JobCatalog(
|
||||
[
|
||||
JobSpec("cookiecloud", "同步CookieCloud站点", services.sync_cookies, "site"),
|
||||
JobSpec("mediaserver_sync", "同步媒体服务器", services.sync_mediaserver, "mediaserver"),
|
||||
JobSpec("subscribe_tmdb", "订阅元数据更新", services.check_subscribe, "subscription"),
|
||||
JobSpec(
|
||||
"subscribe_search", "订阅搜索补全", services.search_subscribe, "subscription", kwargs={"state": "R"}
|
||||
),
|
||||
JobSpec(
|
||||
"new_subscribe_search",
|
||||
"新增订阅搜索",
|
||||
services.search_subscribe,
|
||||
"subscription",
|
||||
kwargs={"state": "N"},
|
||||
),
|
||||
JobSpec("subscribe_refresh", "订阅刷新", services.refresh_subscribe, "subscription"),
|
||||
JobSpec("subscribe_follow", "关注的订阅分享", services.follow_subscribe, "subscription"),
|
||||
JobSpec(
|
||||
"transfer",
|
||||
"下载文件整理",
|
||||
services.process_transfer,
|
||||
"transfer",
|
||||
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
|
||||
),
|
||||
JobSpec(
|
||||
"clear_cache",
|
||||
"缓存清理",
|
||||
self.clear_cache,
|
||||
"runtime",
|
||||
manual=True,
|
||||
recovery=JobRecoveryPolicy.MANUAL_ONLY,
|
||||
),
|
||||
JobSpec("data_cleanup", "数据表清理", services.cleanup_data, "database"),
|
||||
JobSpec("user_auth", "用户认证检查", self.user_auth, "security"),
|
||||
JobSpec("scheduler_job", "公共定时服务", services.run_modules, "module"),
|
||||
JobSpec("random_wallpager", "壁纸缓存", services.get_wallpapers, "image"),
|
||||
JobSpec("sitedata_refresh", "站点数据刷新", services.refresh_site_data, "site"),
|
||||
JobSpec("recommend_refresh", "推荐缓存", services.refresh_recommend, "recommend"),
|
||||
JobSpec(
|
||||
"plugin_market_refresh",
|
||||
"插件市场缓存",
|
||||
get_plugin_manager().async_get_online_plugins,
|
||||
"plugin",
|
||||
kwargs={"force": True},
|
||||
),
|
||||
JobSpec("subscribe_calendar_cache", "订阅日历缓存", services.cache_subscribe_calendar, "subscription"),
|
||||
JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"),
|
||||
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
|
||||
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
|
||||
JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system"),
|
||||
]
|
||||
).runtime_states()
|
||||
for job_id, job in self._jobs.items():
|
||||
self._assign_job_generation(job_id, job)
|
||||
|
||||
self._scheduler = BackgroundScheduler(
|
||||
timezone=config.timezone,
|
||||
executors={"default": ThreadPoolExecutor(config.scheduler_workers)},
|
||||
)
|
||||
|
||||
self._register_database_backup_job(config)
|
||||
outbox_job = JobSpec(
|
||||
"outbox_dispatch",
|
||||
"恢复待投递副作用",
|
||||
dispatch_pending_outbox,
|
||||
"outbox",
|
||||
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
|
||||
).to_runtime_state()
|
||||
self._assign_job_generation("outbox_dispatch", outbox_job)
|
||||
self._jobs["outbox_dispatch"] = outbox_job
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="outbox_dispatch",
|
||||
name="恢复待投递副作用",
|
||||
seconds=30,
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)),
|
||||
kwargs={"job_id": "outbox_dispatch"},
|
||||
replace_existing=True,
|
||||
)
|
||||
# CookieCloud定时同步
|
||||
if config.cookiecloud_interval and str(config.cookiecloud_interval).isdigit():
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="cookiecloud",
|
||||
name="同步CookieCloud站点",
|
||||
minutes=int(config.cookiecloud_interval),
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=5),
|
||||
kwargs={"job_id": "cookiecloud"},
|
||||
)
|
||||
|
||||
# 按媒体服务器分别注册自动同步任务
|
||||
mediaserver_schedules = self._build_mediaserver_sync_schedules(
|
||||
mediaservers=get_mediaserver_configs(include_disabled=True),
|
||||
default_interval=config.mediaserver_sync_interval,
|
||||
)
|
||||
for mediaserver_schedule in mediaserver_schedules:
|
||||
job_id = mediaserver_schedule["id"]
|
||||
job = JobSpec(
|
||||
job_id,
|
||||
mediaserver_schedule["name"],
|
||||
services.sync_mediaserver,
|
||||
"mediaserver",
|
||||
kwargs={"server": mediaserver_schedule["server"]},
|
||||
).to_runtime_state()
|
||||
self._assign_job_generation(job_id, job)
|
||||
self._jobs[job_id] = job
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id=job_id,
|
||||
name=mediaserver_schedule["name"],
|
||||
hours=mediaserver_schedule["interval"],
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=10),
|
||||
kwargs={"job_id": job_id},
|
||||
)
|
||||
|
||||
# 新增订阅时搜索(5分钟检查一次)
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="new_subscribe_search",
|
||||
name="新增订阅搜索",
|
||||
minutes=5,
|
||||
kwargs={"job_id": "new_subscribe_search"},
|
||||
)
|
||||
|
||||
# 检查更新订阅TMDB数据(每隔6小时)
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_tmdb",
|
||||
name="订阅元数据更新",
|
||||
hours=6,
|
||||
kwargs={"job_id": "subscribe_tmdb"},
|
||||
)
|
||||
|
||||
# 订阅状态每隔24小时搜索一次
|
||||
if config.subscribe_search:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_search",
|
||||
name="订阅搜索补全",
|
||||
hours=config.subscribe_search_interval,
|
||||
kwargs={"job_id": "subscribe_search"},
|
||||
)
|
||||
|
||||
if config.subscribe_mode == "spider":
|
||||
# 站点首页种子定时刷新模式
|
||||
triggers = TimerUtils.random_scheduler(num_executions=32)
|
||||
for trigger in triggers:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"cron",
|
||||
id=f"subscribe_refresh|{trigger.hour}:{trigger.minute}",
|
||||
name="订阅刷新",
|
||||
hour=trigger.hour,
|
||||
minute=trigger.minute,
|
||||
kwargs={"job_id": "subscribe_refresh"},
|
||||
)
|
||||
else:
|
||||
# RSS订阅模式
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_refresh",
|
||||
name="RSS订阅刷新",
|
||||
minutes=config.subscribe_rss_interval,
|
||||
kwargs={"job_id": "subscribe_refresh"},
|
||||
)
|
||||
|
||||
# 关注订阅分享(每1小时)
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_follow",
|
||||
name="关注的订阅分享",
|
||||
hours=1,
|
||||
kwargs={"job_id": "subscribe_follow"},
|
||||
)
|
||||
|
||||
# 下载器文件转移(每5分钟)
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="transfer",
|
||||
name="下载文件整理",
|
||||
minutes=5,
|
||||
kwargs={"job_id": "transfer"},
|
||||
)
|
||||
|
||||
# 后台刷新TMDB壁纸
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="random_wallpager",
|
||||
name="壁纸缓存",
|
||||
minutes=30,
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=1),
|
||||
kwargs={"job_id": "random_wallpager"},
|
||||
)
|
||||
|
||||
# 公共定时服务
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="scheduler_job",
|
||||
name="公共定时服务",
|
||||
minutes=10,
|
||||
kwargs={"job_id": "scheduler_job"},
|
||||
)
|
||||
|
||||
# 数据表清理服务,每天凌晨执行一次
|
||||
if config.data_cleanup_enable:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"cron",
|
||||
id="data_cleanup",
|
||||
name="数据表清理",
|
||||
hour=3,
|
||||
minute=30,
|
||||
kwargs={"job_id": "data_cleanup"},
|
||||
)
|
||||
|
||||
# 定时检查用户认证,每隔10分钟
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="user_auth",
|
||||
name="用户认证检查",
|
||||
minutes=10,
|
||||
kwargs={"job_id": "user_auth"},
|
||||
)
|
||||
|
||||
# 站点数据刷新
|
||||
if config.sitedata_refresh_interval:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="sitedata_refresh",
|
||||
name="站点数据刷新",
|
||||
minutes=config.sitedata_refresh_interval * 60,
|
||||
kwargs={"job_id": "sitedata_refresh"},
|
||||
)
|
||||
|
||||
# 推荐缓存
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="recommend_refresh",
|
||||
name="推荐缓存",
|
||||
hours=24,
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=5),
|
||||
kwargs={"job_id": "recommend_refresh"},
|
||||
)
|
||||
|
||||
# 插件市场缓存
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="plugin_market_refresh",
|
||||
name="插件市场缓存",
|
||||
minutes=30,
|
||||
kwargs={"job_id": "plugin_market_refresh"},
|
||||
)
|
||||
|
||||
# 更新检查只缓存 Release 元数据,不会在未授权时下载或重启。
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="system_update_check",
|
||||
name="检查系统更新",
|
||||
hours=6,
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=1),
|
||||
kwargs={"job_id": "system_update_check"},
|
||||
)
|
||||
|
||||
# 订阅日历缓存
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_calendar_cache",
|
||||
name="订阅日历缓存",
|
||||
hours=6,
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=2),
|
||||
kwargs={"job_id": "subscribe_calendar_cache"},
|
||||
)
|
||||
|
||||
# 主动内存回收
|
||||
if config.memory_gc_interval:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="full_gc",
|
||||
name="主动内存回收",
|
||||
minutes=config.memory_gc_interval,
|
||||
kwargs={"job_id": "full_gc"},
|
||||
)
|
||||
|
||||
# 智能体定时任务检查
|
||||
if config.ai_agent_enable and config.ai_agent_job_interval:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="agent_heartbeat",
|
||||
name="智能体定时任务",
|
||||
hours=config.ai_agent_job_interval,
|
||||
kwargs={"job_id": "agent_heartbeat"},
|
||||
)
|
||||
|
||||
# 安装版本统计上报
|
||||
if config.usage_statistic_share:
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="usage_report",
|
||||
name="安装版本统计上报",
|
||||
hours=12,
|
||||
kwargs={"job_id": "usage_report"},
|
||||
)
|
||||
|
||||
# 初始化工作流服务
|
||||
self.init_workflow_jobs()
|
||||
|
||||
# 恢复 Agent 自主定时任务
|
||||
if config.ai_agent_enable:
|
||||
self.init_agent_task_jobs()
|
||||
|
||||
# 初始化插件服务
|
||||
self.init_plugin_jobs()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""调度器兼容 Chain。"""
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from app.application.database import get_database_governance
|
||||
from app.chain.base import ChainBase
|
||||
|
||||
|
||||
class SchedulerChain(ChainBase):
|
||||
"""保留插件使用的公共定时任务与数据治理入口。"""
|
||||
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
|
||||
def cleanup(
|
||||
self,
|
||||
batch_size: Optional[int] = None,
|
||||
progress_callback: Optional[Callable[..., None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""按配置保留期执行分批清理。"""
|
||||
return get_database_governance().cleanup(
|
||||
batch_size=batch_size,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
|
||||
# 旧插件可能持久化或比较完整类路径,保持迁移前可观察身份。
|
||||
SchedulerChain.__module__ = "app.scheduler"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Scheduler 职责 owner 的静态组合宿主合同。"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.application.agenttask import AgentTaskRepository
|
||||
from app.scheduler.registry import ExecutionRegistry, SchedulerHandle
|
||||
from app.scheduler.services import SchedulerServices
|
||||
|
||||
class _SchedulerOwnerHost:
|
||||
"""声明 Scheduler Facade 提供给各职责 owner 的共享状态与入口。"""
|
||||
|
||||
_agent_task_interruptions_reconciled: bool
|
||||
_agent_tasks: AgentTaskRepository | None
|
||||
_auth_count: int
|
||||
_auth_message: bool
|
||||
_auth_plugin_routes_pending: bool
|
||||
_event: Any
|
||||
_jobs: dict[str, dict[str, Any]]
|
||||
_lifecycle_state: str
|
||||
_lock: Any
|
||||
_registry: ExecutionRegistry
|
||||
_scheduler: Any
|
||||
_services: SchedulerServices | None
|
||||
|
||||
_accepting_submissions: Callable[..., bool]
|
||||
_accepts_handle: Callable[..., bool]
|
||||
_assign_job_generation: Callable[..., None]
|
||||
_await_cancelled_handles: Callable[..., Any]
|
||||
_await_progress_handles: Callable[..., Any]
|
||||
_build_mediaserver_sync_schedules: Callable[..., Any]
|
||||
_build_progress_callback: Callable[..., Any]
|
||||
_cancel_handle: Callable[[SchedulerHandle], None]
|
||||
_finish_job: Callable[..., Any]
|
||||
_finish_unsubmitted_job: Callable[..., None]
|
||||
_format_time: Callable[..., str]
|
||||
_get_result_error: Callable[..., Any]
|
||||
_get_progress_key: Callable[[str], str]
|
||||
_handle_job_error: Callable[..., None]
|
||||
_initialize_catalog: Callable[..., None]
|
||||
_is_job_active: Callable[[str], bool]
|
||||
_register_handle: Callable[..., bool]
|
||||
_release_job_generation: Callable[..., None]
|
||||
_scheduler_services: Callable[[], SchedulerServices]
|
||||
_shutdown_scheduler_sync: Callable[[Any], None]
|
||||
_supports_progress_callback: Callable[..., bool]
|
||||
_submit_cross_thread: Callable[..., bool]
|
||||
_submit_to_loop: Callable[..., bool]
|
||||
_reconcile_agent_task_interruptions: Callable[..., None]
|
||||
aget_progress: Callable[..., Any]
|
||||
agent_heartbeat: Callable[..., Any]
|
||||
clear_cache: Callable[..., Any]
|
||||
database_backup: Callable[..., Any]
|
||||
execute_agent_task: Callable[..., Any]
|
||||
full_gc: Callable[..., Any]
|
||||
get_progress: Callable[..., Any]
|
||||
init_agent_task_jobs: Callable[..., None]
|
||||
init_plugin_jobs: Callable[..., None]
|
||||
init_workflow_jobs: Callable[..., None]
|
||||
list: Callable[..., Any]
|
||||
remove_agent_task_job: Callable[..., None]
|
||||
remove_plugin_job: Callable[..., None]
|
||||
remove_workflow_job: Callable[..., None]
|
||||
start: Callable[..., bool]
|
||||
stop: Callable[..., None]
|
||||
update_agent_task_job: Callable[..., Any]
|
||||
update_plugin_job: Callable[..., None]
|
||||
update_workflow_job: Callable[..., None]
|
||||
user_auth: Callable[..., Any]
|
||||
|
||||
class _SchedulerOwnerBase(_SchedulerOwnerHost):
|
||||
"""仅向静态检查器暴露完整的 Scheduler 组合宿主合同。"""
|
||||
else:
|
||||
_SchedulerOwnerBase = object
|
||||
@@ -0,0 +1,429 @@
|
||||
"""调度任务准入、执行、进度和事件循环桥接。"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import multiprocessing
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.application.messaging.message import MessageHelper
|
||||
from app.application.scheduling import ( # noqa: E402
|
||||
JobExecutionState,
|
||||
)
|
||||
from app.runtime.correlation import call_with_correlation, get_correlation_id
|
||||
from app.runtime.events import eventmanager
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.runtime.observability import record_metric
|
||||
from app.runtime.progress import ProgressHelper
|
||||
from app.runtime.scheduling import TimerUtils
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
from app.schemas.dashboard import ScheduleInfo as _SchemaScheduleInfo
|
||||
from app.schemas.types import EventType
|
||||
|
||||
_message_helper_factory: Callable[[], MessageHelper] = MessageHelper
|
||||
|
||||
|
||||
class SchedulerExecutionOwner(_SchedulerOwnerBase):
|
||||
"""调度任务准入与函数执行语义。"""
|
||||
|
||||
def _accepting_submissions(self) -> bool:
|
||||
"""判断调度器是否仍允许提交新的运行实例。"""
|
||||
return self._lifecycle_state in {"starting", "running"}
|
||||
|
||||
def _next_job_generation(self, job_id: str) -> int:
|
||||
"""为同一 job 的下一次注册分配单调 generation。"""
|
||||
return self._registry.next_generation(job_id)
|
||||
|
||||
def _assign_job_generation(self, job_id: str, job: dict[str, Any]) -> None:
|
||||
"""把注册 generation 写入可变运行时状态。"""
|
||||
self._registry.assign_generation(job_id, job)
|
||||
|
||||
def _is_job_active(self, job_id: str) -> bool:
|
||||
"""判断任一 generation 的同 ID 任务是否仍在真实执行。"""
|
||||
return self._registry.is_active(job_id)
|
||||
|
||||
def _release_job_generation(self, job_id: str, generation: int) -> None:
|
||||
"""在任务真实收尾后释放对应 generation 的运行所有权。"""
|
||||
self._registry.release_generation(job_id, generation)
|
||||
|
||||
def _prepare_job(self, job_id: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
准备定时任务
|
||||
"""
|
||||
started_at = self._format_time()
|
||||
with self._lock:
|
||||
if not self._accepting_submissions():
|
||||
return None
|
||||
if not self._registry.consume_reservation(
|
||||
job_id,
|
||||
threading.get_ident(),
|
||||
):
|
||||
return None
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return None
|
||||
if self._is_job_active(job_id):
|
||||
logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...")
|
||||
record_metric(
|
||||
"scheduler.job.overlap_skip",
|
||||
owner=str(job.get("owner", "unknown")),
|
||||
)
|
||||
return None
|
||||
if not JobExecutionState.begin(job, started_at):
|
||||
logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...")
|
||||
record_metric(
|
||||
"scheduler.job.overlap_skip",
|
||||
owner=str(job.get("owner", "unknown")),
|
||||
)
|
||||
return None
|
||||
generation = job.get("_generation", 0)
|
||||
if not self._registry.claim_generation(job_id, generation):
|
||||
JobExecutionState.finish(job, started_at, None)
|
||||
return None
|
||||
job["_metric_started_at"] = time.perf_counter()
|
||||
progress = ProgressHelper(self._get_progress_key(job_id))
|
||||
progress.start()
|
||||
progress.update(
|
||||
value=0,
|
||||
text=f"{job.get('name') or job_id} 开始执行 ...",
|
||||
data={
|
||||
"id": job_id,
|
||||
"_generation": job.get("_generation", 0),
|
||||
"name": job.get("name"),
|
||||
"provider": job.get("provider_name", "[系统]"),
|
||||
"status": "running",
|
||||
"success": None,
|
||||
"started_at": started_at,
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
},
|
||||
)
|
||||
return job
|
||||
|
||||
@staticmethod
|
||||
def _handle_job_error(job_id: str, job: dict[str, Any], error: Exception) -> None:
|
||||
"""
|
||||
记录定时任务执行异常并发送系统错误事件。
|
||||
"""
|
||||
logger.error(f"定时任务 {job.get('name')} 执行失败:{str(error)} - {traceback.format_exc()}")
|
||||
_message_helper_factory().put(
|
||||
title=f"{job.get('name')} 执行失败",
|
||||
message=str(error),
|
||||
role="system",
|
||||
)
|
||||
eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
"type": "scheduler",
|
||||
"scheduler_id": job_id,
|
||||
"scheduler_name": job.get("name"),
|
||||
"error": str(error),
|
||||
"traceback": traceback.format_exc(),
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _supports_progress_callback(func: Callable[..., Any]) -> bool:
|
||||
"""
|
||||
判断定时任务函数是否显式支持进度回调参数。
|
||||
"""
|
||||
try:
|
||||
parameters = inspect.signature(func).parameters
|
||||
except TypeError, ValueError:
|
||||
return False
|
||||
return "progress_callback" in parameters
|
||||
|
||||
@staticmethod
|
||||
def _get_result_error(result: Any) -> Optional[str]:
|
||||
"""
|
||||
从定时任务标准失败返回值中提取错误信息。
|
||||
"""
|
||||
if isinstance(result, tuple) and result and isinstance(result[0], bool) and result[0] is False:
|
||||
return str(result[1]) if len(result) > 1 and result[1] else "定时任务返回失败"
|
||||
return None
|
||||
|
||||
async def _run_coro_job(
|
||||
self,
|
||||
coro_factory: Callable[[], Any],
|
||||
job_id: str,
|
||||
job: dict[str, Any],
|
||||
generation: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
在当前事件循环内执行协程定时任务并在真实完成后收敛状态。
|
||||
"""
|
||||
generation = job.get("_generation", 0) if generation is None else generation
|
||||
success = True
|
||||
error = None
|
||||
try:
|
||||
result = await JobExecutionState.await_result(
|
||||
coro_factory(),
|
||||
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 = "任务已取消"
|
||||
raise
|
||||
except Exception as err:
|
||||
success = False
|
||||
error = str(err)
|
||||
self._handle_job_error(job_id=job_id, job=job, error=err)
|
||||
finally:
|
||||
# 协程收尾在事件循环上完成,同步路径(线程池/调用线程)提交到事件循环执行
|
||||
await self._finish_job(
|
||||
job_id=job_id,
|
||||
job=job,
|
||||
generation=generation,
|
||||
success=success,
|
||||
error=error,
|
||||
)
|
||||
|
||||
def start(self, job_id: str, *args: Any, **kwargs: Any) -> bool:
|
||||
"""
|
||||
启动定时服务
|
||||
"""
|
||||
|
||||
def __start_coro(
|
||||
coro_factory: Callable[[], Any],
|
||||
generation: int,
|
||||
runtime_job: dict[str, Any],
|
||||
) -> tuple[bool, bool]:
|
||||
"""
|
||||
启动协程,返回是否异步收尾以及本次提交是否被接受。
|
||||
"""
|
||||
try:
|
||||
running_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running_loop = None
|
||||
target_loop = main_loop_registry.current
|
||||
target_loop_available = target_loop is not None and target_loop.is_running() and not target_loop.is_closed()
|
||||
if running_loop and (not target_loop_available or running_loop is target_loop):
|
||||
started = threading.Event()
|
||||
|
||||
async def run_owned_job() -> None:
|
||||
started.set()
|
||||
await self._run_coro_job(
|
||||
coro_factory=coro_factory,
|
||||
job_id=job_id,
|
||||
job=runtime_job,
|
||||
generation=generation,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
if not self._accepts_handle(job_id, generation):
|
||||
return False, False
|
||||
handle = running_loop.create_task(run_owned_job())
|
||||
registered = self._register_handle(
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
loop=running_loop,
|
||||
handle=handle,
|
||||
)
|
||||
|
||||
def _finish_cancelled_before_start(
|
||||
submitted: asyncio.Future[Any],
|
||||
) -> None:
|
||||
if submitted.cancelled() and not started.is_set():
|
||||
self._finish_unsubmitted_job(
|
||||
job_id=job_id,
|
||||
job=runtime_job,
|
||||
generation=generation,
|
||||
error="任务未提交",
|
||||
)
|
||||
|
||||
handle.add_done_callback(_finish_cancelled_before_start)
|
||||
return registered, registered
|
||||
if target_loop_available:
|
||||
wrapped = self._run_coro_job(
|
||||
coro_factory=coro_factory,
|
||||
job_id=job_id,
|
||||
job=runtime_job,
|
||||
generation=generation,
|
||||
)
|
||||
submitted = self._submit_cross_thread(
|
||||
wrapped,
|
||||
target_loop=target_loop,
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
on_unstarted_cancel=lambda: self._finish_unsubmitted_job(
|
||||
job_id=job_id,
|
||||
job=runtime_job,
|
||||
generation=generation,
|
||||
error="任务未提交",
|
||||
),
|
||||
)
|
||||
return submitted, submitted
|
||||
if self._lifecycle_state in {"stopping", "stopped"}:
|
||||
return False, False
|
||||
asyncio.run(coro_factory())
|
||||
return False, True
|
||||
|
||||
# 获取定时任务
|
||||
job = self._prepare_job(job_id)
|
||||
if not job:
|
||||
return False
|
||||
generation = job.get("_generation", 0)
|
||||
success = True
|
||||
error = None
|
||||
deferred_finish = False
|
||||
accepted = True
|
||||
# 开始运行
|
||||
try:
|
||||
if not kwargs:
|
||||
kwargs = dict(job.get("kwargs") or {})
|
||||
func = job.get("func")
|
||||
if not func:
|
||||
return False
|
||||
if func == self.execute_agent_task:
|
||||
kwargs.setdefault("scheduler_generation", generation)
|
||||
if self._supports_progress_callback(func) and "progress_callback" not in kwargs:
|
||||
kwargs["progress_callback"] = self._build_progress_callback(job_id=job_id, job=job)
|
||||
# 是否多进程运行
|
||||
run_in_process = job.get("run_in_process", False)
|
||||
if inspect.iscoroutinefunction(func):
|
||||
# 协程函数
|
||||
deferred_finish, accepted = __start_coro(
|
||||
lambda: func(*args, **kwargs),
|
||||
generation,
|
||||
job,
|
||||
)
|
||||
elif run_in_process:
|
||||
# 多进程运行
|
||||
p = multiprocessing.Process(
|
||||
target=call_with_correlation,
|
||||
args=(get_correlation_id(), func, args, kwargs),
|
||||
)
|
||||
p.start()
|
||||
p.join()
|
||||
else:
|
||||
# 普通函数
|
||||
result = func(*args, **kwargs)
|
||||
error = self._get_result_error(result)
|
||||
success = error is None
|
||||
except Exception as e:
|
||||
success = False
|
||||
error = str(e)
|
||||
self._handle_job_error(job_id=job_id, job=job, error=e)
|
||||
finally:
|
||||
if not deferred_finish:
|
||||
|
||||
def finish_without_loop() -> None:
|
||||
self._finish_unsubmitted_job(
|
||||
job_id=job_id,
|
||||
job=job,
|
||||
generation=generation,
|
||||
error=error if accepted else "任务未提交",
|
||||
)
|
||||
|
||||
# 同步上下文执行异步收尾:优先提交到当前/全局事件循环,无循环时新建循环
|
||||
finish_submitted = self._submit_to_loop(
|
||||
self._finish_job(
|
||||
job_id=job_id,
|
||||
job=job,
|
||||
generation=generation,
|
||||
success=success,
|
||||
error=error,
|
||||
),
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
on_unstarted_cancel=finish_without_loop,
|
||||
)
|
||||
if not finish_submitted:
|
||||
finish_without_loop()
|
||||
return accepted
|
||||
|
||||
def list(self) -> list[_SchemaScheduleInfo]:
|
||||
"""
|
||||
当前所有任务
|
||||
"""
|
||||
if not self._scheduler:
|
||||
return []
|
||||
with self._lock:
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
# 避免_scheduler.shutdown()处于阻塞状态导致的死锁
|
||||
if not self._scheduler or not self._scheduler.running:
|
||||
return []
|
||||
jobs = self._scheduler.get_jobs()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
# 将正在运行的任务提取出来 (保障一次性任务正常显示)
|
||||
for job_id, service in self._jobs.items():
|
||||
name = service.get("name")
|
||||
provider_name = service.get("provider_name")
|
||||
if (self._is_job_active(job_id) or service.get("running")) and name and provider_name:
|
||||
if job_id not in added:
|
||||
added.append(job_id)
|
||||
progress = self.get_progress(job_id)
|
||||
schedulers.append(
|
||||
_SchemaScheduleInfo(
|
||||
id=job_id,
|
||||
name=name,
|
||||
provider=provider_name,
|
||||
status="正在运行",
|
||||
progress=progress.value if progress else 0,
|
||||
progress_text=progress.text if progress else None,
|
||||
progress_enable=progress.enable if progress else False,
|
||||
progress_detail=progress,
|
||||
)
|
||||
)
|
||||
# 获取其他待执行任务
|
||||
for job in jobs:
|
||||
job_id = job.id.split("|")[0]
|
||||
if job_id not in added:
|
||||
added.append(job_id)
|
||||
else:
|
||||
continue
|
||||
service_state = self._jobs.get(job_id)
|
||||
if not service_state:
|
||||
continue
|
||||
# 任务状态
|
||||
status = "正在运行" if self._is_job_active(job_id) or service_state.get("running") else "等待"
|
||||
# 下次运行时间
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
progress = self.get_progress(job_id)
|
||||
schedulers.append(
|
||||
_SchemaScheduleInfo(
|
||||
id=job_id,
|
||||
name=job.name,
|
||||
provider=service_state.get("provider_name", "[系统]"),
|
||||
status=status,
|
||||
next_run=next_run,
|
||||
progress=progress.value if progress else 0,
|
||||
progress_text=progress.text if progress else None,
|
||||
progress_enable=progress.enable if progress else False,
|
||||
progress_detail=progress,
|
||||
)
|
||||
)
|
||||
# 仅手动执行的任务(未注册到调度器)
|
||||
for job_id, service in self._jobs.items():
|
||||
if not service.get("manual"):
|
||||
continue
|
||||
if job_id in added:
|
||||
continue
|
||||
added.append(job_id)
|
||||
progress = self.get_progress(job_id)
|
||||
schedulers.append(
|
||||
_SchemaScheduleInfo(
|
||||
id=job_id,
|
||||
name=service.get("name"),
|
||||
provider=service.get("provider_name", "[系统]"),
|
||||
status=("正在运行" if self._is_job_active(job_id) else "等待"),
|
||||
progress=progress.value if progress else 0,
|
||||
progress_text=progress.text if progress else None,
|
||||
progress_enable=progress.enable if progress else False,
|
||||
progress_detail=progress,
|
||||
)
|
||||
)
|
||||
return schedulers
|
||||
@@ -0,0 +1,126 @@
|
||||
"""调度器具体实现的组合门面与旧插件 ABI。"""
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from app.application.agenttask import AgentTaskRepository
|
||||
from app.foundation.singleton import SingletonClass
|
||||
from app.runtime.events import Event, eventmanager
|
||||
from app.runtime.reload import ConfigReloadMixin
|
||||
from app.scheduler.bridge import SchedulerBridgeOwner
|
||||
from app.scheduler.catalog import SchedulerCatalogOwner
|
||||
from app.scheduler.execution import SchedulerExecutionOwner
|
||||
from app.scheduler.lifecycle import SchedulerLifecycleOwner
|
||||
from app.scheduler.maintenance import SchedulerMaintenanceOwner
|
||||
from app.scheduler.progress import SchedulerProgressOwner
|
||||
from app.scheduler.reconcile import SchedulerReconcileOwner
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
from app.scheduler.services import SchedulerServices
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
|
||||
_PUBLIC_MODULE = "app.scheduler"
|
||||
_Handler = TypeVar("_Handler", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def _public_handler(handler: _Handler) -> _Handler:
|
||||
"""在事件注册前恢复插件可见的稳定模块身份。"""
|
||||
handler.__module__ = _PUBLIC_MODULE
|
||||
return handler
|
||||
|
||||
|
||||
class Scheduler(
|
||||
SchedulerLifecycleOwner,
|
||||
SchedulerReconcileOwner,
|
||||
SchedulerBridgeOwner,
|
||||
SchedulerProgressOwner,
|
||||
SchedulerExecutionOwner,
|
||||
SchedulerCatalogOwner,
|
||||
SchedulerMaintenanceOwner,
|
||||
ConfigReloadMixin,
|
||||
metaclass=SingletonClass,
|
||||
):
|
||||
"""
|
||||
定时任务管理
|
||||
"""
|
||||
|
||||
__module__ = _PUBLIC_MODULE
|
||||
|
||||
CONFIG_WATCH = {
|
||||
"DEV",
|
||||
"COOKIECLOUD_INTERVAL",
|
||||
"MEDIASERVER_SYNC_INTERVAL",
|
||||
SystemConfigKey.MediaServers.value,
|
||||
"SUBSCRIBE_SEARCH",
|
||||
"SUBSCRIBE_SEARCH_INTERVAL",
|
||||
"SUBSCRIBE_MODE",
|
||||
"SUBSCRIBE_RSS_INTERVAL",
|
||||
"SITEDATA_REFRESH_INTERVAL",
|
||||
"AI_AGENT_ENABLE",
|
||||
"AI_AGENT_JOB_INTERVAL",
|
||||
"DATA_CLEANUP_ENABLE",
|
||||
"DATA_CLEANUP_MESSAGE_DAYS",
|
||||
"DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS",
|
||||
"DATA_CLEANUP_SITE_USERDATA_DAYS",
|
||||
"DATA_CLEANUP_TRANSFER_HISTORY_DAYS",
|
||||
"DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS",
|
||||
"DATA_CLEANUP_SUBSCRIBE_HISTORY_DAYS",
|
||||
"DATA_CLEANUP_AGENT_CHAT_DAYS",
|
||||
"DATA_CLEANUP_AGENT_TASK_RUN_DAYS",
|
||||
"DATA_CLEANUP_OUTBOX_COMPLETED_DAYS",
|
||||
"DATA_CLEANUP_OUTBOX_DEAD_DAYS",
|
||||
"DB_BACKUP_ENABLE",
|
||||
"DB_BACKUP_CRON",
|
||||
"USAGE_STATISTIC_SHARE",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""创建调度器状态;后台任务由应用生命周期显式启动。"""
|
||||
# 定时服务
|
||||
self._scheduler = None
|
||||
# 退出事件
|
||||
self._event = threading.Event()
|
||||
# 锁
|
||||
self._lock = threading.RLock()
|
||||
# 各服务的运行状态
|
||||
self._jobs = {}
|
||||
# 生命周期门禁与事件循环句柄由调度器实例独立持有。
|
||||
self._lifecycle_state = "new"
|
||||
self._registry = ExecutionRegistry(self._lock)
|
||||
# 进程启动时只对账一次,配置热重载不得改写仍在执行的任务状态
|
||||
self._agent_task_interruptions_reconciled = False
|
||||
# 用户认证失败次数
|
||||
self._auth_count = 0
|
||||
# 用户认证失败消息发送
|
||||
self._auth_message = False
|
||||
# 插件已按认证结果重建,但动态路由尚未完成投影时保留重试状态。
|
||||
self._auth_plugin_routes_pending = False
|
||||
self._agent_tasks: AgentTaskRepository | None = None
|
||||
self._services: SchedulerServices | None = None
|
||||
|
||||
def configure_services(self, services: SchedulerServices) -> None:
|
||||
"""在调度器启动前绑定由组合根构造的业务能力。"""
|
||||
if self._lifecycle_state not in {"new", "stopped"}:
|
||||
raise RuntimeError("Scheduler 已运行,不能替换业务能力")
|
||||
self._services = services
|
||||
|
||||
def _scheduler_services(self) -> SchedulerServices:
|
||||
"""返回显式注入能力;缺少组合根装配时稳定失败。"""
|
||||
if self._services is None:
|
||||
raise RuntimeError("Scheduler 的业务能力尚未注入")
|
||||
return self._services
|
||||
|
||||
def get_reload_name(self) -> str:
|
||||
"""
|
||||
获取配置重载日志中的服务名称。
|
||||
"""
|
||||
return "定时服务"
|
||||
|
||||
@eventmanager.register(EventType.PluginReload) # type: ignore[misc]
|
||||
@_public_handler
|
||||
def on_plugin_reload(self, event: Event) -> None:
|
||||
"""插件重载后按当前实例重新注册全部定时服务"""
|
||||
plugin_id = event.event_data.get("plugin_id")
|
||||
if not plugin_id:
|
||||
return
|
||||
self.update_plugin_job(plugin_id)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Scheduler 启动、热重载和关闭生命周期。"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from app.application.configuration import (
|
||||
get_scheduler_runtime_config,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.stop import runtime_stop_state
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
from app.scheduler.registry import SchedulerHandle
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class SchedulerLifecycleOwner(_SchedulerOwnerBase):
|
||||
"""Scheduler 启动、热重载和关闭生命周期。"""
|
||||
|
||||
async def on_config_changed(self) -> None:
|
||||
"""
|
||||
配置变更后重新初始化定时服务。
|
||||
"""
|
||||
reload_started, scheduler = self._begin_reload()
|
||||
if not reload_started:
|
||||
return
|
||||
await asyncio.to_thread(self._shutdown_scheduler_sync, scheduler)
|
||||
with self._lock:
|
||||
if self._lifecycle_state != "reloading":
|
||||
return
|
||||
self.init(_already_stopped=True)
|
||||
|
||||
def init(self, *, _already_stopped: bool = False) -> None:
|
||||
"""
|
||||
初始化定时服务
|
||||
"""
|
||||
|
||||
config = get_scheduler_runtime_config()
|
||||
# 停止定时服务
|
||||
if not _already_stopped:
|
||||
self.stop()
|
||||
|
||||
# 调试模式不启动定时服务
|
||||
if config.dev:
|
||||
with self._lock:
|
||||
self._lifecycle_state = "stopped"
|
||||
return
|
||||
|
||||
# 对账上个进程未收口的 Agent 任务;进程内重复初始化不会重复改写状态。
|
||||
self._reconcile_agent_task_interruptions()
|
||||
with lock:
|
||||
with self._lock:
|
||||
self._event.clear()
|
||||
self._lifecycle_state = "starting"
|
||||
self._initialize_catalog(config)
|
||||
|
||||
# 启动定时服务
|
||||
self._scheduler.start()
|
||||
with self._lock:
|
||||
self._lifecycle_state = "running"
|
||||
|
||||
def _begin_stop(self) -> tuple[Any, tuple[SchedulerHandle, ...]]:
|
||||
"""关闭提交入口并摘出当前调度器与其拥有的异步句柄。"""
|
||||
with self._lock:
|
||||
self._lifecycle_state = "stopping"
|
||||
self._event.set()
|
||||
scheduler = self._scheduler
|
||||
self._scheduler = None
|
||||
handles = self._registry.stop_snapshot()
|
||||
if scheduler:
|
||||
try:
|
||||
scheduler.remove_all_jobs()
|
||||
except Exception as err:
|
||||
logger.error("移除定时任务失败:%s", err)
|
||||
return scheduler, handles
|
||||
|
||||
def _begin_reload(self) -> tuple[bool, Any]:
|
||||
"""停止旧计划的提交入口,保留已开始任务直到其自然完成。"""
|
||||
with self._lock:
|
||||
if runtime_stop_state.is_system_stopped or self._lifecycle_state in {"stopping", "reloading"}:
|
||||
return False, None
|
||||
self._lifecycle_state = "reloading"
|
||||
self._event.set()
|
||||
scheduler = self._scheduler
|
||||
self._scheduler = None
|
||||
self._registry.clear_reservations()
|
||||
if scheduler:
|
||||
try:
|
||||
scheduler.remove_all_jobs()
|
||||
except Exception as err:
|
||||
logger.error("移除定时任务失败:%s", err)
|
||||
return True, scheduler
|
||||
|
||||
@staticmethod
|
||||
def _shutdown_scheduler_sync(scheduler: Any) -> None:
|
||||
"""等待 APScheduler 自有线程池停止。"""
|
||||
if scheduler and scheduler.running:
|
||||
scheduler.shutdown()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
关闭定时服务的同步兼容入口。
|
||||
|
||||
应用生命周期使用 ``stop_async``,以便等待事件循环中的协程句柄;同步
|
||||
调用方仍可请求取消并等待 APScheduler 自有线程池收口。
|
||||
"""
|
||||
with lock:
|
||||
try:
|
||||
scheduler, handles = self._begin_stop()
|
||||
for handle in handles:
|
||||
self._cancel_handle(handle)
|
||||
self._shutdown_scheduler_sync(scheduler)
|
||||
with self._lock:
|
||||
self._lifecycle_state = "stopped"
|
||||
logger.info("定时任务停止完成")
|
||||
except Exception as err:
|
||||
logger.error(f"停止定时任务失败:{err} - {traceback.format_exc()}")
|
||||
|
||||
async def stop_async(self) -> None:
|
||||
"""关闭调度器并等待已投递协程收口。"""
|
||||
scheduler, handles = self._begin_stop()
|
||||
for handle in handles:
|
||||
self._cancel_handle(handle)
|
||||
await asyncio.to_thread(self._shutdown_scheduler_sync, scheduler)
|
||||
await self._await_cancelled_handles(handles)
|
||||
with self._lock:
|
||||
self._lifecycle_state = "stopped"
|
||||
logger.info("定时任务停止完成")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""调度器内置维护任务。"""
|
||||
|
||||
import gc
|
||||
|
||||
from app.application.backup import BackupArtifact
|
||||
from app.application.database import get_database_governance
|
||||
from app.runtime.gc import get_memory_usage
|
||||
from app.runtime.log import logger
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
|
||||
|
||||
class SchedulerMaintenanceOwner(_SchedulerOwnerBase):
|
||||
"""调度器内置维护任务。"""
|
||||
|
||||
@staticmethod
|
||||
def database_backup() -> BackupArtifact:
|
||||
"""按当前宿主策略创建一次定时数据库备份。"""
|
||||
return get_database_governance().create_backup()
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
清理缓存
|
||||
"""
|
||||
self._scheduler_services().clear_cache()
|
||||
|
||||
@staticmethod
|
||||
def full_gc() -> None:
|
||||
"""
|
||||
主动内存回收
|
||||
"""
|
||||
memory_before = get_memory_usage()
|
||||
collected = gc.collect()
|
||||
memory_after = get_memory_usage()
|
||||
memory_freed = memory_before - memory_after
|
||||
logger.info(f"主动内存回收完成,回收对象数: {collected},释放内存: {memory_freed:.2f} MB")
|
||||
|
||||
@staticmethod
|
||||
async def agent_heartbeat() -> None:
|
||||
"""
|
||||
智能体心跳唤醒:检查并执行待处理的定时任务
|
||||
"""
|
||||
from app.application.agent import get_running_agent_manager
|
||||
|
||||
manager = get_running_agent_manager()
|
||||
if manager is None:
|
||||
logger.debug("智能助手服务未运行,跳过心跳任务")
|
||||
return
|
||||
await manager.heartbeat_check_jobs()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""调度任务进度快照与终态收敛。"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.application.scheduling import ( # noqa: E402
|
||||
JobExecutionState,
|
||||
)
|
||||
from app.runtime.observability import record_metric
|
||||
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress
|
||||
|
||||
SCHEDULER_PROGRESS_PREFIX = "scheduler"
|
||||
|
||||
|
||||
class SchedulerProgressOwner(_SchedulerOwnerBase):
|
||||
"""调度任务进度快照与终态收敛。"""
|
||||
|
||||
def _finish_unsubmitted_job(
|
||||
self,
|
||||
job_id: str,
|
||||
job: dict[str, Any],
|
||||
generation: int,
|
||||
error: Optional[str],
|
||||
) -> None:
|
||||
"""收尾协程无法提交时同步释放任务状态和运行所有权。"""
|
||||
finished_at = self._format_time()
|
||||
metric_started_at = None
|
||||
with self._lock:
|
||||
if generation not in self._registry.active_generations(job_id):
|
||||
return
|
||||
current_job = self._jobs.get(job_id)
|
||||
if current_job is job and current_job.get("_generation", 0) == generation:
|
||||
JobExecutionState.finish(job, finished_at, error)
|
||||
metric_started_at = job.pop("_metric_started_at", None)
|
||||
self._release_job_generation(job_id, generation)
|
||||
if metric_started_at is not None:
|
||||
record_metric(
|
||||
"scheduler.job.duration",
|
||||
time.perf_counter() - metric_started_at,
|
||||
owner=str(job.get("owner", "unknown")),
|
||||
outcome="success" if error is None else "error",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_progress_key(job_id: str) -> str:
|
||||
"""
|
||||
获取定时服务进度缓存键。
|
||||
"""
|
||||
return f"{SCHEDULER_PROGRESS_PREFIX}:{job_id}"
|
||||
|
||||
@staticmethod
|
||||
def _format_time(value: Optional[datetime] = None) -> str:
|
||||
"""
|
||||
格式化进度事件时间。
|
||||
"""
|
||||
return (value or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
async def _finish_job(
|
||||
self,
|
||||
job_id: str,
|
||||
job: dict[str, Any],
|
||||
generation: int,
|
||||
success: bool = True,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
完成定时任务
|
||||
"""
|
||||
# 业务函数返回前提交的进度回调可能仍在等待 Redis I/O;先收敛它们,
|
||||
# 避免迟到的 running 快照覆盖 success/failed 终态。
|
||||
await self._await_progress_handles(job_id, generation)
|
||||
finished_at = self._format_time()
|
||||
with self._lock:
|
||||
current_job = self._jobs.get(job_id)
|
||||
if current_job is not job or current_job.get("_generation", 0) != generation:
|
||||
self._release_job_generation(job_id, generation)
|
||||
return
|
||||
JobExecutionState.finish(job, finished_at, error)
|
||||
metric_started_at = job.pop("_metric_started_at", None)
|
||||
if metric_started_at is not None:
|
||||
record_metric(
|
||||
"scheduler.job.duration",
|
||||
time.perf_counter() - metric_started_at,
|
||||
owner=str(job.get("owner", "unknown")),
|
||||
outcome="success" if success else "error",
|
||||
)
|
||||
job_name = job.get("name") if job else job_id
|
||||
# 收尾可能发生在事件循环上(_run_coro_job),使用异步进度后端避免阻塞
|
||||
progress = AsyncProgressHelper(self._get_progress_key(job_id))
|
||||
try:
|
||||
current_progress = await progress.get() or {}
|
||||
progress_value = 100 if success else current_progress.get("value", 0)
|
||||
await progress.end(
|
||||
text=f"{job_name} {'执行完成' if success else '执行失败'}",
|
||||
data={
|
||||
"id": job_id,
|
||||
"_generation": generation,
|
||||
"name": job_name,
|
||||
"provider": job.get("provider_name", "[系统]") if job else None,
|
||||
"status": "success" if success else "failed",
|
||||
"success": success,
|
||||
"finished_at": finished_at,
|
||||
"error": error,
|
||||
},
|
||||
value=progress_value,
|
||||
)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._release_job_generation(job_id, generation)
|
||||
|
||||
def get_progress(self, job_id: str) -> Optional[_SchemaScheduleProgress]:
|
||||
"""
|
||||
查询指定定时服务的执行进度。
|
||||
"""
|
||||
if not job_id:
|
||||
return None
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
job_name = job.get("name") if job else job_id
|
||||
provider_name = job.get("provider_name", "[系统]") if job else None
|
||||
running = bool(job and (self._is_job_active(job_id) or job.get("running")))
|
||||
last_started_at = job.get("last_started_at") if job else None
|
||||
last_finished_at = job.get("last_finished_at") if job else None
|
||||
last_error = job.get("last_error") if job else None
|
||||
detail = ProgressHelper(self._get_progress_key(job_id)).get() or {}
|
||||
if not job and not detail:
|
||||
return None
|
||||
data = dict(detail.get("data") or {})
|
||||
progress_generation = data.pop("_generation", None)
|
||||
if job and progress_generation is not None and progress_generation != job.get("_generation", 0):
|
||||
detail = {}
|
||||
data = {}
|
||||
value = detail.get("value", 0)
|
||||
try:
|
||||
value = float(value)
|
||||
except TypeError, ValueError:
|
||||
value = 0.0
|
||||
return _SchemaScheduleProgress(
|
||||
id=job_id,
|
||||
name=data.get("name") or job_name,
|
||||
provider=data.get("provider") or provider_name,
|
||||
enable=bool(detail.get("enable", running)),
|
||||
value=max(min(value, 100), 0),
|
||||
text=detail.get("text"),
|
||||
status=data.get("status") or ("running" if running else "waiting"),
|
||||
success=data.get("success"),
|
||||
started_at=data.get("started_at") or last_started_at,
|
||||
finished_at=data.get("finished_at") or last_finished_at,
|
||||
error=data.get("error") or last_error,
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def aget_progress(self, job_id: str) -> Optional[_SchemaScheduleProgress]:
|
||||
"""
|
||||
查询指定定时服务的执行进度(异步版本,供事件循环上的端点使用)。
|
||||
"""
|
||||
if not job_id:
|
||||
return None
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
job_name = job.get("name") if job else job_id
|
||||
provider_name = job.get("provider_name", "[系统]") if job else None
|
||||
running = bool(job and (self._is_job_active(job_id) or job.get("running")))
|
||||
last_started_at = job.get("last_started_at") if job else None
|
||||
last_finished_at = job.get("last_finished_at") if job else None
|
||||
last_error = job.get("last_error") if job else None
|
||||
# 异步后端读取,避免在事件循环上阻塞
|
||||
detail = await AsyncProgressHelper(self._get_progress_key(job_id)).get() or {}
|
||||
if not job and not detail:
|
||||
return None
|
||||
data = dict(detail.get("data") or {})
|
||||
progress_generation = data.pop("_generation", None)
|
||||
if job and progress_generation is not None and progress_generation != job.get("_generation", 0):
|
||||
detail = {}
|
||||
data = {}
|
||||
value = detail.get("value", 0)
|
||||
try:
|
||||
value = float(value)
|
||||
except TypeError, ValueError:
|
||||
value = 0.0
|
||||
return _SchemaScheduleProgress(
|
||||
id=job_id,
|
||||
name=data.get("name") or job_name,
|
||||
provider=data.get("provider") or provider_name,
|
||||
enable=bool(detail.get("enable", running)),
|
||||
value=max(min(value, 100), 0),
|
||||
text=detail.get("text"),
|
||||
status=data.get("status") or ("running" if running else "waiting"),
|
||||
success=data.get("success"),
|
||||
started_at=data.get("started_at") or last_started_at,
|
||||
finished_at=data.get("finished_at") or last_finished_at,
|
||||
error=data.get("error") or last_error,
|
||||
data=data,
|
||||
)
|
||||
|
||||
def _build_progress_callback(self, job_id: str, job: dict[str, Any]) -> Callable[..., None]:
|
||||
"""
|
||||
构建传递给定时任务内部的进度更新回调。
|
||||
"""
|
||||
generation = job.get("_generation", 0)
|
||||
|
||||
def update_progress(
|
||||
value: Optional[float] = None,
|
||||
text: Optional[str] = None,
|
||||
data: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
更新当前定时任务进度。
|
||||
"""
|
||||
progress_data = {
|
||||
"id": job_id,
|
||||
"_generation": generation,
|
||||
"name": job.get("name"),
|
||||
"provider": job.get("provider_name", "[系统]"),
|
||||
"status": "running",
|
||||
"success": None,
|
||||
}
|
||||
if data:
|
||||
progress_data.update(data)
|
||||
key = self._get_progress_key(job_id)
|
||||
|
||||
async def _update() -> None:
|
||||
with self._lock:
|
||||
current_job = self._jobs.get(job_id)
|
||||
if current_job is not job or current_job.get("_generation", 0) != generation:
|
||||
return
|
||||
# 异步后端更新,避免任务函数在事件循环内调用回调时阻塞
|
||||
await AsyncProgressHelper(key).update(
|
||||
value=value,
|
||||
text=text,
|
||||
data=progress_data,
|
||||
)
|
||||
|
||||
# 回调可能在事件循环内(async 任务)或线程池中(sync 任务)被调用,
|
||||
# 统一经事件循环提交;无运行中循环时同步执行兜底
|
||||
self._submit_to_loop(
|
||||
_update(),
|
||||
job_id=job_id,
|
||||
generation=job.get("_generation", 0),
|
||||
kind="progress",
|
||||
)
|
||||
|
||||
return update_progress
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Agent、插件和工作流动态任务对账。"""
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from apscheduler.jobstores.base import JobLookupError
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.application.agenttask import AgentTaskRepository
|
||||
from app.application.configuration import (
|
||||
get_configured_system_config,
|
||||
get_scheduler_runtime_config,
|
||||
)
|
||||
from app.application.plugin.routes import register_plugin_api
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
from app.application.scheduling import ( # noqa: E402
|
||||
AGENT_TASK_JOB_PREFIX,
|
||||
JobRecoveryPolicy,
|
||||
JobSpec,
|
||||
)
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
|
||||
from app.application.workflow import WorkflowSnapshot
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.scheduling import TimerUtils
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.types import MessageType, SystemConfigKey
|
||||
|
||||
|
||||
class SchedulerReconcileOwner(_SchedulerOwnerBase):
|
||||
"""Agent、插件和工作流动态任务对账。"""
|
||||
|
||||
def configure_agent_tasks(self, repository: AgentTaskRepository) -> None:
|
||||
"""在调度器启动前绑定唯一自主任务仓储。"""
|
||||
if self._lifecycle_state not in {"new", "stopped"}:
|
||||
raise RuntimeError("Scheduler 已运行,不能替换 AgentTask 仓储")
|
||||
self._agent_tasks = repository
|
||||
|
||||
def _agent_task_repository(self) -> AgentTaskRepository:
|
||||
"""返回显式注入仓储;缺少组合根装配时稳定失败。"""
|
||||
if self._agent_tasks is None:
|
||||
raise RuntimeError("Scheduler 的 AgentTask 仓储尚未注入")
|
||||
return self._agent_tasks
|
||||
|
||||
@staticmethod
|
||||
def _get_agent_task_job_id(task_id: int) -> str:
|
||||
"""生成 Agent 自主定时任务的调度器 Job ID。"""
|
||||
return f"{AGENT_TASK_JOB_PREFIX}-{task_id}"
|
||||
|
||||
def start_agent_task(self, task_id: int) -> bool:
|
||||
"""
|
||||
将指定 Agent 自主定时任务提交到运行时调度器立即执行。
|
||||
|
||||
:param task_id: Agent 自主定时任务 ID
|
||||
:return: 任务存在且未运行时返回 True,否则返回 False
|
||||
"""
|
||||
job_id = self._get_agent_task_job_id(task_id)
|
||||
owner = threading.get_ident()
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not self._accepting_submissions() or not job or self._is_job_active(job_id) or job.get("running"):
|
||||
return False
|
||||
if not self._registry.reserve(job_id, owner):
|
||||
return False
|
||||
try:
|
||||
result = self.start(job_id, task_id=task_id, trigger_source="manual")
|
||||
return result is not False
|
||||
finally:
|
||||
self._registry.release_reservation(job_id, owner)
|
||||
|
||||
def init_agent_task_jobs(self) -> None:
|
||||
"""
|
||||
按数据库当前状态注册所有启用的 Agent 自主定时任务。
|
||||
"""
|
||||
for task in self._agent_task_repository().list(enabled=True):
|
||||
self.update_agent_task_job(task.id)
|
||||
|
||||
def _reconcile_agent_task_interruptions(self) -> None:
|
||||
"""
|
||||
将上个进程未收口的 Agent 任务标记为结果未知。
|
||||
|
||||
配置变更会在同一进程内重建调度器,因此该对账在实例生命周期内只能
|
||||
成功执行一次,避免把当前进程仍在运行的任务误判为中断。
|
||||
"""
|
||||
with self._lock:
|
||||
if self._agent_task_interruptions_reconciled:
|
||||
return
|
||||
oper = self._agent_task_repository()
|
||||
for task in oper.list():
|
||||
if task.last_status == "running":
|
||||
oper.mark_interrupted(
|
||||
task_id=task.id,
|
||||
result=(
|
||||
"服务重启时任务执行被中断,结果未知,可能已有部分操作;请先检查实际状态,再决定是否重新执行"
|
||||
),
|
||||
)
|
||||
self._agent_task_interruptions_reconciled = True
|
||||
|
||||
def update_agent_task_job(self, task_id: int) -> Optional[str]:
|
||||
"""
|
||||
按数据库中的最新配置新增或替换 Agent 自主定时任务。
|
||||
|
||||
:param task_id: Agent 定时任务 ID
|
||||
:return: 下一次执行时间,不可调度时返回 None
|
||||
"""
|
||||
config = get_scheduler_runtime_config()
|
||||
self.remove_agent_task_job(task_id)
|
||||
task = self._agent_task_repository().get(task_id)
|
||||
if not config.ai_agent_enable or not task or not task.enabled or not self._scheduler:
|
||||
return None
|
||||
|
||||
trigger_value = task.cron_expression if task.trigger_type == "cron" else task.run_at
|
||||
if trigger_value is None:
|
||||
logger.error(f"Agent 定时任务 {task_id} 缺少触发配置")
|
||||
return None
|
||||
manual_only = task.trigger_type == "date" and task.last_status == "interrupted"
|
||||
trigger = None
|
||||
if not manual_only:
|
||||
try:
|
||||
trigger = TimerUtils.build_schedule_trigger(
|
||||
trigger_type=task.trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=config.timezone,
|
||||
)
|
||||
except (TypeError, ValueError) as err:
|
||||
logger.error(f"Agent 定时任务 {task_id} 的触发配置无效:{str(err)}")
|
||||
return None
|
||||
|
||||
job_id = self._get_agent_task_job_id(task_id)
|
||||
with self._lock:
|
||||
job = JobSpec(
|
||||
job_id,
|
||||
task.name,
|
||||
self.execute_agent_task,
|
||||
"agent",
|
||||
recovery=JobRecoveryPolicy.NEXT_SCHEDULE,
|
||||
kwargs={"task_id": task_id},
|
||||
).to_runtime_state()
|
||||
self._assign_job_generation(job_id, job)
|
||||
job["_agent_task_run_id"] = task.last_run_id
|
||||
job["_agent_task_status"] = task.last_status
|
||||
self._jobs[job_id] = job
|
||||
self._jobs[job_id]["provider_name"] = "[Agent]"
|
||||
# 已开始的一次任务在重启后结果未知,只保留显式执行入口,不能按
|
||||
# 过期触发时间自动重放可能已经发生的外部副作用。
|
||||
if manual_only:
|
||||
return None
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
name=task.name,
|
||||
kwargs={"job_id": job_id, "task_id": task_id},
|
||||
coalesce=True,
|
||||
max_instances=1,
|
||||
misfire_grace_time=None,
|
||||
replace_existing=True,
|
||||
)
|
||||
return self.get_agent_task_next_run(task_id)
|
||||
|
||||
def remove_agent_task_job(self, task_id: int) -> None:
|
||||
"""
|
||||
从运行时调度器移除 Agent 自主定时任务。
|
||||
|
||||
:param task_id: Agent 定时任务 ID
|
||||
"""
|
||||
job_id = self._get_agent_task_job_id(task_id)
|
||||
with self._lock:
|
||||
self._jobs.pop(job_id, None)
|
||||
if not self._scheduler:
|
||||
return
|
||||
try:
|
||||
self._scheduler.remove_job(job_id)
|
||||
except JobLookupError:
|
||||
pass
|
||||
|
||||
def _remove_agent_task_job_generation(
|
||||
self,
|
||||
task_id: int,
|
||||
generation: int,
|
||||
run_id: str,
|
||||
) -> bool:
|
||||
"""移除本次执行或其运行中重载产生的 AgentTask 调度注册。"""
|
||||
job_id = self._get_agent_task_job_id(task_id)
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
if job.get("_generation", 0) != generation and not (
|
||||
job.get("_agent_task_run_id") == run_id and job.get("_agent_task_status") == "running"
|
||||
):
|
||||
return False
|
||||
self._jobs.pop(job_id, None)
|
||||
if self._scheduler:
|
||||
try:
|
||||
self._scheduler.remove_job(job_id)
|
||||
except JobLookupError:
|
||||
pass
|
||||
return True
|
||||
|
||||
def get_agent_task_next_run(self, task_id: int) -> Optional[str]:
|
||||
"""
|
||||
查询 Agent 自主定时任务的下一次执行时间。
|
||||
|
||||
:param task_id: Agent 定时任务 ID
|
||||
:return: 带时区的 ISO 8601 时间,不再执行时返回 None
|
||||
"""
|
||||
config = get_scheduler_runtime_config()
|
||||
job_id = self._get_agent_task_job_id(task_id)
|
||||
if self._scheduler:
|
||||
job = self._scheduler.get_job(job_id)
|
||||
next_run_time = getattr(job, "next_run_time", None) if job else None
|
||||
if next_run_time:
|
||||
return cast(str, next_run_time.isoformat(timespec="seconds"))
|
||||
|
||||
task = self._agent_task_repository().get(task_id)
|
||||
if not task or not task.enabled:
|
||||
return None
|
||||
if task.trigger_type == "date" and task.last_status == "interrupted":
|
||||
return None
|
||||
trigger_value = task.cron_expression if task.trigger_type == "cron" else task.run_at
|
||||
if trigger_value is None:
|
||||
return None
|
||||
try:
|
||||
next_run_time = TimerUtils.get_schedule_next_run_time(
|
||||
trigger_type=task.trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=config.timezone,
|
||||
)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
return next_run_time.isoformat(timespec="seconds") if next_run_time else None
|
||||
|
||||
async def execute_agent_task(
|
||||
self,
|
||||
task_id: int,
|
||||
trigger_source: str = "scheduled",
|
||||
scheduler_generation: int | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
唤醒 Agent 执行指定自主定时任务。
|
||||
|
||||
:param task_id: Agent 定时任务 ID
|
||||
:param trigger_source: 触发入口,scheduled-自动调度,manual-显式立即执行
|
||||
:return: 执行是否成功及结果摘要
|
||||
"""
|
||||
from app.application.agent import get_running_agent_manager
|
||||
|
||||
manager = get_running_agent_manager()
|
||||
if manager is None:
|
||||
logger.warning("智能助手服务未运行,跳过 Agent 定时任务")
|
||||
return False, "智能助手服务未运行"
|
||||
if scheduler_generation is None:
|
||||
job_id = self._get_agent_task_job_id(task_id)
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is not None:
|
||||
scheduler_generation = job.get("_generation", 0)
|
||||
kwargs: dict[str, Any] = {"trigger_source": trigger_source}
|
||||
if scheduler_generation is not None:
|
||||
kwargs.update(
|
||||
scheduler_generation=scheduler_generation,
|
||||
remove_schedule=self._remove_agent_task_job_generation,
|
||||
)
|
||||
return cast(
|
||||
tuple[bool, str],
|
||||
await manager.execute_scheduled_task(task_id, **kwargs),
|
||||
)
|
||||
|
||||
def init_plugin_jobs(self) -> None:
|
||||
"""
|
||||
初始化插件定时服务
|
||||
"""
|
||||
for pid in get_plugin_manager().get_running_plugin_ids():
|
||||
self.update_plugin_job(pid)
|
||||
|
||||
def init_workflow_jobs(self) -> None:
|
||||
"""
|
||||
初始化工作流定时服务
|
||||
"""
|
||||
for workflow in self._scheduler_services().list_workflows() or []:
|
||||
self.update_workflow_job(workflow)
|
||||
|
||||
def remove_workflow_job(self, workflow: WorkflowSnapshot) -> None:
|
||||
"""
|
||||
移除工作流服务
|
||||
"""
|
||||
if not self._scheduler:
|
||||
return
|
||||
with self._lock:
|
||||
job_id = f"workflow-{workflow.id}"
|
||||
service = self._jobs.pop(job_id, {})
|
||||
if not service:
|
||||
return
|
||||
try:
|
||||
# 在调度器中查找并移除对应的 job
|
||||
job_removed = False
|
||||
for job in list(self._scheduler.get_jobs()):
|
||||
if job_id == job.id:
|
||||
try:
|
||||
self._scheduler.remove_job(job.id)
|
||||
job_removed = True
|
||||
except JobLookupError:
|
||||
pass
|
||||
break
|
||||
if job_removed:
|
||||
logger.info(f"移除工作流服务:{service.get('name')}")
|
||||
except Exception as e:
|
||||
logger.error(f"移除工作流服务失败:{str(e)} - {job_id}: {service}")
|
||||
self._scheduler_services().put_message(
|
||||
title=f"工作流 {workflow.name} 服务移除失败",
|
||||
message=str(e),
|
||||
role="system",
|
||||
)
|
||||
|
||||
def remove_plugin_job(self, pid: str, job_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
移除定时服务,可以是单个服务(包括默认服务)或整个插件的所有服务
|
||||
:param pid: 插件 ID
|
||||
:param job_id: 可选,指定要移除的单个服务的 job_id。如果不提供,则移除该插件的所有服务,当移除单个服务时,默认服务也包含在内
|
||||
"""
|
||||
if not self._scheduler:
|
||||
return
|
||||
with self._lock:
|
||||
if job_id:
|
||||
# 移除单个服务
|
||||
service = self._jobs.pop(job_id, None)
|
||||
if not service:
|
||||
return
|
||||
jobs_to_remove = [(job_id, service)]
|
||||
else:
|
||||
# 移除插件的所有服务
|
||||
jobs_to_remove = [
|
||||
(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid
|
||||
]
|
||||
for job_id, _ in jobs_to_remove:
|
||||
self._jobs.pop(job_id, None)
|
||||
if not jobs_to_remove:
|
||||
return
|
||||
plugin_name = get_plugin_manager().get_plugin_attr(pid, "plugin_name")
|
||||
# 遍历移除任务
|
||||
for job_id, service in jobs_to_remove:
|
||||
try:
|
||||
# 在调度器中查找并移除对应的 job
|
||||
job_removed = False
|
||||
for job in list(self._scheduler.get_jobs()):
|
||||
job_id_from_service = job.id.split("|")[0]
|
||||
if job_id == job_id_from_service:
|
||||
try:
|
||||
self._scheduler.remove_job(job.id)
|
||||
job_removed = True
|
||||
except JobLookupError:
|
||||
pass
|
||||
if job_removed:
|
||||
logger.info(f"移除插件服务({plugin_name}):{service.get('name')}") # noqa
|
||||
except Exception as e:
|
||||
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")
|
||||
self._scheduler_services().put_message(
|
||||
title=f"插件 {plugin_name} 服务移除失败",
|
||||
message=str(e),
|
||||
role="system",
|
||||
)
|
||||
|
||||
def update_workflow_job(self, workflow: WorkflowSnapshot) -> None:
|
||||
"""
|
||||
更新工作流定时服务
|
||||
"""
|
||||
if not self._scheduler:
|
||||
return
|
||||
# 移除该工作流的全部服务
|
||||
self.remove_workflow_job(workflow)
|
||||
# 添加工作流服务
|
||||
with self._lock:
|
||||
try:
|
||||
job_id = f"workflow-{workflow.id}"
|
||||
job = JobSpec(
|
||||
job_id,
|
||||
workflow.name,
|
||||
self._scheduler_services().process_workflow,
|
||||
"workflow",
|
||||
).to_runtime_state()
|
||||
self._assign_job_generation(job_id, job)
|
||||
job["provider_name"] = "工作流"
|
||||
self._jobs[job_id] = job
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
trigger=CronTrigger.from_crontab(workflow.timer),
|
||||
id=job_id,
|
||||
name=workflow.name,
|
||||
kwargs={"job_id": job_id, "workflow_id": workflow.id},
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info(f"注册工作流服务:{workflow.name} - {workflow.timer}")
|
||||
except Exception as e:
|
||||
logger.error(f"注册工作流服务失败:{workflow.name} - {str(e)}")
|
||||
self._scheduler_services().put_message(
|
||||
title=f"工作流 {workflow.name} 服务注册失败",
|
||||
message=str(e),
|
||||
role="system",
|
||||
)
|
||||
|
||||
def update_plugin_job(self, pid: str) -> None:
|
||||
"""
|
||||
更新插件定时服务
|
||||
"""
|
||||
if not self._scheduler or not pid:
|
||||
return
|
||||
# 移除该插件的全部服务
|
||||
self.remove_plugin_job(pid)
|
||||
# 获取插件服务列表
|
||||
with self._lock:
|
||||
plugin_manager = get_plugin_manager()
|
||||
try:
|
||||
plugin_services = plugin_manager.get_plugin_services(pid=pid)
|
||||
except Exception as e:
|
||||
logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}")
|
||||
return
|
||||
# 获取插件名称
|
||||
plugin_name = plugin_manager.get_plugin_attr(pid, "plugin_name")
|
||||
# 开始注册插件服务
|
||||
for service in plugin_services:
|
||||
try:
|
||||
sid = f"{pid}_{service['id']}"
|
||||
job_id = sid.split("|")[0]
|
||||
self.remove_plugin_job(pid, job_id)
|
||||
job = JobSpec(
|
||||
job_id,
|
||||
service["name"],
|
||||
service["func"],
|
||||
f"plugin:{pid}",
|
||||
kwargs=service.get("func_kwargs") or {},
|
||||
).to_runtime_state()
|
||||
self._assign_job_generation(job_id, job)
|
||||
job.update(
|
||||
pid=pid,
|
||||
provider_name=plugin_name,
|
||||
)
|
||||
self._jobs[job_id] = job
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=sid,
|
||||
name=service["name"],
|
||||
**(service.get("kwargs") or {}),
|
||||
kwargs={"job_id": job_id},
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
|
||||
self._scheduler_services().put_message(
|
||||
title=f"插件 {plugin_name} 服务注册失败",
|
||||
message=str(e),
|
||||
role="system",
|
||||
)
|
||||
|
||||
def user_auth(self) -> None:
|
||||
"""
|
||||
用户认证检查
|
||||
"""
|
||||
config = get_scheduler_runtime_config()
|
||||
if SitesHelper().auth_level >= 2:
|
||||
if self._auth_plugin_routes_pending:
|
||||
register_plugin_api()
|
||||
self._auth_plugin_routes_pending = False
|
||||
return
|
||||
# 最大重试次数
|
||||
__max_try__ = 30
|
||||
if self._auth_count > __max_try__:
|
||||
if not self._auth_message:
|
||||
self._scheduler_services().put_message(
|
||||
title="用户认证失败",
|
||||
message="用户认证失败次数过多,将不再尝试认证!",
|
||||
role="system",
|
||||
)
|
||||
self._auth_message = True
|
||||
return
|
||||
logger.info("用户未认证,正在尝试认证...")
|
||||
auth_conf = get_configured_system_config().get(SystemConfigKey.UserSiteAuthParams)
|
||||
if auth_conf:
|
||||
status, msg = SitesHelper().check_user(**auth_conf)
|
||||
else:
|
||||
status, msg = SitesHelper().check_user()
|
||||
if status:
|
||||
self._auth_count = 0
|
||||
logger.info(f"{msg} 用户认证成功")
|
||||
self._scheduler_services().post_message(
|
||||
Message(
|
||||
mtype=MessageType.Manual,
|
||||
title="MoviePilot用户认证成功",
|
||||
text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。",
|
||||
link=config.site_link,
|
||||
)
|
||||
)
|
||||
# 认证通过后重新初始化插件
|
||||
get_plugin_manager().init_config()
|
||||
self.init_plugin_jobs()
|
||||
self._auth_plugin_routes_pending = True
|
||||
register_plugin_api()
|
||||
self._auth_plugin_routes_pending = False
|
||||
|
||||
else:
|
||||
self._auth_count += 1
|
||||
logger.error(f"用户认证失败,{msg},共失败 {self._auth_count} 次")
|
||||
if self._auth_count >= __max_try__:
|
||||
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Scheduler 执行 generation、预约与异步句柄的唯一状态 owner。"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
_FutureHandle = Union[
|
||||
asyncio.Future[Any],
|
||||
concurrent.futures.Future[Any],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SchedulerHandle:
|
||||
"""记录 Scheduler 投递的执行句柄及其真实完成信号。"""
|
||||
|
||||
job_id: str
|
||||
generation: int
|
||||
loop: asyncio.AbstractEventLoop
|
||||
handle: _FutureHandle
|
||||
completion: _FutureHandle
|
||||
kind: str
|
||||
|
||||
|
||||
class ExecutionRegistry:
|
||||
"""原子维护任务 generation、运行所有权、预约和异步句柄。"""
|
||||
|
||||
def __init__(self, lock: Optional[Any] = None) -> None:
|
||||
"""创建空 registry;可复用 Facade 的可重入锁形成统一临界区。"""
|
||||
self._lock = lock if lock is not None else threading.RLock()
|
||||
self._handles: dict[int, SchedulerHandle] = {}
|
||||
self._generations: dict[str, int] = {}
|
||||
self._active_generations: dict[str, set[int]] = {}
|
||||
self._reservations: dict[str, int] = {}
|
||||
|
||||
def next_generation(self, job_id: str) -> int:
|
||||
"""为指定任务原子分配单调递增的 generation。"""
|
||||
with self._lock:
|
||||
generation = self._generations.get(job_id, 0) + 1
|
||||
self._generations[job_id] = generation
|
||||
return generation
|
||||
|
||||
def assign_generation(self, job_id: str, job: dict[str, Any]) -> int:
|
||||
"""分配 generation 并写入兼容 Facade 使用的任务状态。"""
|
||||
with self._lock:
|
||||
generation = self._generations.get(job_id, 0) + 1
|
||||
self._generations[job_id] = generation
|
||||
job["_generation"] = generation
|
||||
return generation
|
||||
|
||||
def current_generation(self, job_id: str) -> int:
|
||||
"""返回任务最近分配的 generation,尚未分配时返回零。"""
|
||||
with self._lock:
|
||||
return self._generations.get(job_id, 0)
|
||||
|
||||
def claim_generation(self, job_id: str, generation: int) -> bool:
|
||||
"""在同 ID 无活跃任务时取得 generation 的唯一运行所有权。"""
|
||||
with self._lock:
|
||||
active = self._active_generations.get(job_id)
|
||||
if active:
|
||||
return False
|
||||
self._active_generations[job_id] = {generation}
|
||||
return True
|
||||
|
||||
def is_active(self, job_id: str) -> bool:
|
||||
"""判断任一 generation 的同 ID 任务是否仍在执行。"""
|
||||
with self._lock:
|
||||
return bool(self._active_generations.get(job_id))
|
||||
|
||||
def active_generations(self, job_id: str) -> frozenset[int]:
|
||||
"""返回指定任务当前活跃 generation 的不可变快照。"""
|
||||
with self._lock:
|
||||
return frozenset(self._active_generations.get(job_id, set()))
|
||||
|
||||
def release_generation(self, job_id: str, generation: int) -> bool:
|
||||
"""释放匹配的运行所有权,返回本次是否实际移除 generation。"""
|
||||
with self._lock:
|
||||
active = self._active_generations.get(job_id)
|
||||
if not active or generation not in active:
|
||||
return False
|
||||
active.remove(generation)
|
||||
if not active:
|
||||
self._active_generations.pop(job_id, None)
|
||||
return True
|
||||
|
||||
def reserve(self, job_id: str, owner: int) -> bool:
|
||||
"""为手动触发原子预约任务;已活跃或已预约时拒绝。"""
|
||||
with self._lock:
|
||||
if self._active_generations.get(job_id) or job_id in self._reservations:
|
||||
return False
|
||||
self._reservations[job_id] = owner
|
||||
return True
|
||||
|
||||
def reservation_owner(self, job_id: str) -> Optional[int]:
|
||||
"""返回当前预约线程标识,无预约时返回 None。"""
|
||||
with self._lock:
|
||||
return self._reservations.get(job_id)
|
||||
|
||||
def consume_reservation(self, job_id: str, owner: int) -> bool:
|
||||
"""校验并消费调用方预约;无预约时允许普通调度继续。"""
|
||||
with self._lock:
|
||||
reserved_owner = self._reservations.get(job_id)
|
||||
if reserved_owner is None:
|
||||
return True
|
||||
if reserved_owner != owner:
|
||||
return False
|
||||
self._reservations.pop(job_id, None)
|
||||
return True
|
||||
|
||||
def release_reservation(self, job_id: str, owner: Optional[int] = None) -> bool:
|
||||
"""释放预约;指定 owner 时不得清除其他调用方的预约。"""
|
||||
with self._lock:
|
||||
reserved_owner = self._reservations.get(job_id)
|
||||
if reserved_owner is None or (owner is not None and reserved_owner != owner):
|
||||
return False
|
||||
self._reservations.pop(job_id, None)
|
||||
return True
|
||||
|
||||
def register_handle(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
generation: int,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
handle: _FutureHandle,
|
||||
completion: Optional[_FutureHandle] = None,
|
||||
kind: str = "job",
|
||||
) -> SchedulerHandle:
|
||||
"""按真实完成信号登记 Scheduler 拥有的异步句柄。"""
|
||||
if completion is None:
|
||||
completion = handle
|
||||
scheduler_handle = SchedulerHandle(
|
||||
job_id=job_id,
|
||||
generation=generation,
|
||||
loop=loop,
|
||||
handle=handle,
|
||||
completion=completion,
|
||||
kind=kind,
|
||||
)
|
||||
with self._lock:
|
||||
self._handles[id(completion)] = scheduler_handle
|
||||
return scheduler_handle
|
||||
|
||||
def remove_handle(self, completion: _FutureHandle) -> bool:
|
||||
"""按真实完成信号摘除句柄,返回是否存在对应登记。"""
|
||||
with self._lock:
|
||||
return self._handles.pop(id(completion), None) is not None
|
||||
|
||||
def handles(
|
||||
self,
|
||||
*,
|
||||
job_id: Optional[str] = None,
|
||||
generation: Optional[int] = None,
|
||||
kind: Optional[str] = None,
|
||||
) -> tuple[SchedulerHandle, ...]:
|
||||
"""按任务、generation 与用途返回当前句柄的稳定快照。"""
|
||||
with self._lock:
|
||||
return tuple(
|
||||
handle
|
||||
for handle in self._handles.values()
|
||||
if (job_id is None or handle.job_id == job_id)
|
||||
and (generation is None or handle.generation == generation)
|
||||
and (kind is None or handle.kind == kind)
|
||||
)
|
||||
|
||||
def stop_snapshot(self) -> tuple[SchedulerHandle, ...]:
|
||||
"""清除尚未消费的预约并返回停止阶段需要收口的句柄快照。"""
|
||||
with self._lock:
|
||||
self._reservations.clear()
|
||||
return tuple(self._handles.values())
|
||||
|
||||
def clear_reservations(self) -> None:
|
||||
"""在热重载封闭提交入口时清除尚未消费的手动预约。"""
|
||||
with self._lock:
|
||||
self._reservations.clear()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""调度器由启动组合根注入的业务能力。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from app.application.workflow import WorkflowSnapshot
|
||||
from app.schemas.message import Message
|
||||
|
||||
JobCallable = Callable[..., Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SchedulerServices:
|
||||
"""保存任务目录和动态投影所需的已装配业务 callable。"""
|
||||
|
||||
sync_cookies: JobCallable
|
||||
sync_mediaserver: JobCallable
|
||||
check_subscribe: JobCallable
|
||||
search_subscribe: JobCallable
|
||||
refresh_subscribe: JobCallable
|
||||
follow_subscribe: JobCallable
|
||||
process_transfer: JobCallable
|
||||
clear_cache: JobCallable
|
||||
cleanup_data: JobCallable
|
||||
run_modules: JobCallable
|
||||
get_wallpapers: JobCallable
|
||||
refresh_site_data: JobCallable
|
||||
refresh_recommend: JobCallable
|
||||
cache_subscribe_calendar: JobCallable
|
||||
list_workflows: Callable[[], Iterable[WorkflowSnapshot]]
|
||||
process_workflow: JobCallable
|
||||
put_message: JobCallable
|
||||
post_message: Callable[[Message], Any]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""插件可使用的窄调度服务门面。"""
|
||||
|
||||
from app.application.scheduling import (
|
||||
get_agent_task_next_run,
|
||||
list_scheduler_jobs,
|
||||
remove_agent_task_job,
|
||||
remove_plugin_job,
|
||||
start_agent_task,
|
||||
start_scheduler_job,
|
||||
update_agent_task_job,
|
||||
update_plugin_job,
|
||||
)
|
||||
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
|
||||
|
||||
__all__ = [
|
||||
"ScheduleInfo",
|
||||
"ScheduleProgress",
|
||||
"get_agent_task_next_run",
|
||||
"list_scheduler_jobs",
|
||||
"remove_agent_task_job",
|
||||
"remove_plugin_job",
|
||||
"start_agent_task",
|
||||
"start_scheduler_job",
|
||||
"update_agent_task_job",
|
||||
"update_plugin_job",
|
||||
]
|
||||
@@ -660,7 +660,7 @@ def get_host_event_handler_factories() -> dict[type, Callable[[], object]]:
|
||||
from app.chain.subscribe.facade import SubscribeChain
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.command import Command
|
||||
from app.scheduler import Scheduler
|
||||
from app.scheduler.facade import Scheduler
|
||||
|
||||
return {
|
||||
Command: Command,
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
"""调度器业务能力装配与生命周期入口。"""
|
||||
|
||||
import asyncio
|
||||
from typing import Awaitable, Optional
|
||||
|
||||
from app.application.agenttask import AgentTaskRepository
|
||||
from app.application.image import WallpaperHelper
|
||||
from app.application.scheduling import (
|
||||
get_scheduler,
|
||||
register_scheduler_class,
|
||||
reset_scheduler_class,
|
||||
)
|
||||
from app.scheduler import Scheduler
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.subscribe.facade import SubscribeChain
|
||||
from app.chain.transfer.facade import TransferChain
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.scheduler.chain import SchedulerChain
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.scheduler.services import SchedulerServices
|
||||
|
||||
|
||||
def configure_scheduler_runtime() -> None:
|
||||
@@ -25,11 +37,45 @@ def configure_scheduler_agent_tasks(repository: AgentTaskRepository) -> None:
|
||||
get_scheduler().configure_agent_tasks(repository)
|
||||
|
||||
|
||||
def init_scheduler():
|
||||
def configure_scheduler_services() -> None:
|
||||
"""构造一次调度任务能力,并在启动前注入 concrete Scheduler。"""
|
||||
scheduler_chain = SchedulerChain()
|
||||
site_chain = SiteChain()
|
||||
mediaserver_chain = MediaServerChain()
|
||||
subscribe_chain = SubscribeChain()
|
||||
transfer_chain = TransferChain()
|
||||
recommend_chain = RecommendChain()
|
||||
workflow_chain = WorkflowChain()
|
||||
Scheduler().configure_services(
|
||||
SchedulerServices(
|
||||
sync_cookies=site_chain.sync_cookies,
|
||||
sync_mediaserver=mediaserver_chain.sync,
|
||||
check_subscribe=subscribe_chain.check,
|
||||
search_subscribe=subscribe_chain.search,
|
||||
refresh_subscribe=subscribe_chain.refresh,
|
||||
follow_subscribe=subscribe_chain.follow,
|
||||
process_transfer=transfer_chain.process,
|
||||
clear_cache=scheduler_chain.clear_cache,
|
||||
cleanup_data=scheduler_chain.cleanup,
|
||||
run_modules=scheduler_chain.scheduler_job,
|
||||
get_wallpapers=WallpaperHelper().get_wallpapers,
|
||||
refresh_site_data=site_chain.refresh_userdatas,
|
||||
refresh_recommend=recommend_chain.refresh_recommend,
|
||||
cache_subscribe_calendar=subscribe_chain.cache_calendar,
|
||||
list_workflows=workflow_chain.get_timer_workflows,
|
||||
process_workflow=workflow_chain.process,
|
||||
put_message=scheduler_chain.messagehelper.put,
|
||||
post_message=scheduler_chain.post_message,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def init_scheduler() -> None:
|
||||
"""
|
||||
初始化定时器
|
||||
"""
|
||||
configure_scheduler_runtime()
|
||||
configure_scheduler_services()
|
||||
try:
|
||||
Scheduler().init()
|
||||
except Exception:
|
||||
@@ -37,7 +83,7 @@ def init_scheduler():
|
||||
raise
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
def stop_scheduler() -> Optional[Awaitable[None]]:
|
||||
"""
|
||||
停止定时器;生命周期事件循环中返回可等待的收口协程。
|
||||
"""
|
||||
@@ -61,7 +107,7 @@ def stop_scheduler():
|
||||
return stop_and_reset()
|
||||
|
||||
|
||||
def restart_scheduler():
|
||||
def restart_scheduler() -> None:
|
||||
"""
|
||||
重启定时器
|
||||
"""
|
||||
@@ -69,7 +115,7 @@ def restart_scheduler():
|
||||
Scheduler().init()
|
||||
|
||||
|
||||
def init_plugin_scheduler():
|
||||
def init_plugin_scheduler() -> None:
|
||||
"""
|
||||
初始化插件定时器
|
||||
"""
|
||||
|
||||
@@ -529,7 +529,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
|
||||
dependencies=("插件",),
|
||||
mode=LifecycleMode.NORMAL_ONLY,
|
||||
start=init_scheduler,
|
||||
stop=offload_shutdown_callback(stop_scheduler),
|
||||
stop=stop_scheduler,
|
||||
start_order=100,
|
||||
stop_order=50,
|
||||
start_timeout_seconds=120,
|
||||
|
||||
@@ -17,8 +17,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
1. **边界有形、合同偏弱**:部分 Application Port 仍是 `Callable[[], Any]`,返回 ORM 或
|
||||
动态代理;生产路径因此继续依赖无 Session Oper、全局 provider 和隐式构造。
|
||||
2. **用例编排过度集中**:Chain、Scheduler、Plugin、Agent、LLM 和部分 API 文件同时承担决策、
|
||||
I/O、状态、生命周期与兼容职责,私有长方法又处于复杂度门禁盲区。
|
||||
2. **用例编排过度集中**:部分 Chain、Plugin、Agent、LLM 和 API 文件仍同时承担决策、I/O、
|
||||
状态、生命周期与兼容职责;Scheduler 已完成生产代码拆包和综合验收,其他私有长方法仍可能
|
||||
处于复杂度门禁盲区。
|
||||
3. **可靠性声明强于实现**:整理 pending 目前只能做最小重放,尚不满足 ADR 中声明的 E3
|
||||
状态机语义;部分 commit 后副作用仍存在“业务已提交但调用方收到失败”或进程退出后丢失的窗口。
|
||||
4. **治理事实源不一致**:架构规则、总览、AST 基线和 CI 语义存在漂移;快照门禁能证明“没有变化”,
|
||||
@@ -62,14 +63,14 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| `db/oper` | Model/Oper 已不自建 Session、不自行提交 | 无 Session Facade 仍被宿主组合根注入,业务操作可能拆成多个事务 |
|
||||
| `db/adapters` | 已有显式 Session/UoW 的参考切片 | Port 返回类型不够稳定;Outbox 的 stage 与自提交 dispatcher store 混在一个类型 |
|
||||
| `startup` | 已是 HostRuntime 和生命周期组合根 | `initializers/modules.py` 高扇出,仍有导入期 provider 注册和具体对象目录装配 |
|
||||
| API/Command/Scheduler | 多数入口已转向 Chain/Application | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 |
|
||||
| API/Command/Scheduler | 多数入口已转向 Chain/Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 |
|
||||
| `sdk`/`compat` | 精确映射、稳定插件 ABI 和宿主 canonical 路径已落地 | SDK 仍暴露部分可变全局对象/具体 Manager,只能渐进收窄,不能直接删除 |
|
||||
|
||||
### 3.2 量化快照
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 893 / 7,532 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 906 / 7,612 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -77,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
|
||||
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
||||
| 全量 mypy 历史债务 | 11,734 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
||||
| Ruff 历史诊断 | 706 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 全量 mypy 历史债务 | 11,179 / 600 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
||||
| Ruff 历史诊断 | 700 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 79.83%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
@@ -94,7 +95,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| `app/api/endpoints/agent.py` | 2,346 | HTTP/SSE、文件/音频、Agent 会话和事件编排 |
|
||||
| `app/chain/download.py` | 2,230 | 选择、提交、历史、通知、模块后处理和批量执行 |
|
||||
| `app/chain/media.py` | 2,191 | 识别、来源投影、缓存、音乐匹配和兼容入口 |
|
||||
| `app/scheduler.py` | 2,111 | 作业目录、执行状态、恢复、生命周期和领域任务 |
|
||||
| `app/scheduler/` | Facade 128 行 | 原 2,111 行单体已退役;catalog、execution、bridge、progress、registry、reconcile、lifecycle、maintenance 与注入合同已有独立 owner |
|
||||
|
||||
热点不是按行数机械拆文件的依据。只有在提取出稳定合同、保留旧入口委托并有行为测试时,拆分才算
|
||||
降低复杂度;把长方法原样移动到新目录不算完成。
|
||||
@@ -114,7 +115,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| ARCH-106 | P1 | 进行中 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 日志与消息 owner 已收口;GlobalVar/provider 继续治理 |
|
||||
| ARCH-107 | P1 | 已验证 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 |
|
||||
| ARCH-108 | P1 | 执行中 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Application Adapter/DNS 债务已清零;Chain Adapter 与宿主 HTTP 债务继续迁移 |
|
||||
| ARCH-109 | P1 | 待执行 | 按用例拆分超大 Chain、Scheduler 和厚 API | 稳定 Facade 保留,决策/I/O/状态/生命周期各有 owner |
|
||||
| ARCH-109 | P1 | 执行中 | 按用例拆分超大 Chain、Scheduler 和厚 API | Transfer、Subscribe、Scheduler 已验证;Download/Search 与厚 API 尚待执行 |
|
||||
| ARCH-110 | P1 | 待执行 | Module/Event Contract 分可信级执行 | 宿主 provider 严格,第三方插件仍兼容诊断 |
|
||||
| ARCH-111 | P1 | 待执行 | 升级复杂度、类型、覆盖率和并发原语门禁 | 高风险私有路径也进入只降不增的治理面 |
|
||||
| ARCH-201 | P2 | 渐进 | 收窄 PluginHelper/PluginManager 与 SDK 暴露面 | ABI Facade 只委托,构造和具体服务归组合根 |
|
||||
@@ -551,6 +552,13 @@ Chain 构造点;队列/恢复、规划、执行、结算、历史/通知、请
|
||||
分别进入同名 package 的单词文件。包根只保留 `TransferChain` 与插件已使用的 `task_lock` 身份,
|
||||
不重复导出 `JobManager`、durable runner 或内部 owner;旧 `transfer.py` 和 `_transfer.py` 均已删除。
|
||||
|
||||
`Scheduler` 切片已完成验证:旧 `app/scheduler.py` 已删除,稳定 Facade 与
|
||||
`SchedulerChain` 兼容类型由 `app.scheduler` 包根惰性导出;catalog、执行、事件循环桥接、进度、
|
||||
`ExecutionRegistry`、领域 reconcile、生命周期和维护任务分别位于同名 package 的单词文件。
|
||||
`app/startup/initializers/scheduler.py` 统一构造 Chain 与 `SchedulerServices`,Scheduler 包内不再无参
|
||||
构造业务 Chain;新插件使用 `app.sdk.scheduler` 的窄函数门面,内部 owner 不进入 SDK 或包根 ABI。
|
||||
功能、生命周期、架构、兼容和文档批次均通过,官方插件基线语义未变化。
|
||||
|
||||
### ARCH-110 分可信级执行 Module/Event Contract
|
||||
|
||||
**问题与证据**
|
||||
|
||||
@@ -73,7 +73,7 @@ flowchart TB
|
||||
AgentPkg["app/agent<br/>AI Agent 运行时"]
|
||||
Monitor["app/monitor<br/>目录监控"]
|
||||
Workflow["app/workflow<br/>工作流"]
|
||||
Scheduler["app/scheduler<br/>定时任务"]
|
||||
Scheduler["app/scheduler/<br/>定时任务职责包"]
|
||||
CLI["app/cli<br/>命令行"]
|
||||
PluginPkg["插件运行时目录<br/>app/plugins/*(副本/覆盖层)"]
|
||||
end
|
||||
@@ -283,6 +283,14 @@ sequenceDiagram
|
||||
`HostRuntime.settings` 的窄服务读写可变部署设置,业务域不接触 Settings 实例;生产与测试组合根统一
|
||||
复用 `startup/composition/configuration.py` 的映射。`ApiDataPorts` 仅保留旧导入 ABI,不参与正式请求链路。
|
||||
- **安全模式**:`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。
|
||||
- **Scheduler 同名职责包**:旧 `app/scheduler.py` 单体已退役;`catalog.py` 负责作业目录和计划投影,
|
||||
`execution.py`、`bridge.py`、`progress.py` 分别负责执行、跨循环句柄和进度终态,`registry.py`
|
||||
唯一持有 generation、active generation、reservation 与 handle,`reconcile.py` 和 `lifecycle.py`
|
||||
分别负责动态任务协调与启动/重载/关闭。
|
||||
- **Scheduler 显式装配**:`startup/initializers/scheduler.py` 构造业务 Chain 一次,将绑定 callable
|
||||
组成 frozen `SchedulerServices` 后注入 Scheduler;Scheduler 包内不再构造业务 Chain。
|
||||
`app.scheduler` 包根只惰性保留 `Scheduler`/`SchedulerChain` 旧 ABI,新插件经 `app.sdk.scheduler`
|
||||
使用窄调度服务,内部 owner 不重复导出。
|
||||
- **进程拓扑**:全功能 V3 强制 `API_WORKERS=1`,避免每个 worker 重复启动插件和后台控制面;安全模式可临时使用多 worker 诊断,但不是正式扩容方案。
|
||||
- **健康语义**:`/health/live` 只确认进程和事件循环可响应;`/health/ready` 仅在数据库
|
||||
到达当前 head 且生命周期完成后返回 200,启动失败或关停阶段返回 503。两者不公开路径、
|
||||
@@ -714,8 +722,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 893 |
|
||||
| 内部导入边 | 7,532 |
|
||||
| Python 模块 | 906 |
|
||||
| 内部导入边 | 7,612 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 55(债务已清零,55 条精确 containment) |
|
||||
@@ -748,6 +756,8 @@ flowchart LR
|
||||
订阅关键副作用经 `app/application/outbox.py` 与 `app/db/adapters/outbox.py` 进入同事务 Outbox;
|
||||
搜索逐页任务、Agent/消息事件和插件市场子任务均遵守请求或生命周期 owner,不再由入口模块维护
|
||||
无法追踪的裸任务集合。
|
||||
- Scheduler 已从单体迁入 `app.scheduler` 同名职责包,startup 是业务 callable 的唯一构造与注入边界;
|
||||
功能、生命周期、架构、兼容、文档与官方插件基线均已完成独立验证。
|
||||
- 判断是否需要新增 manifest 映射的标准:只有当旧物理模块被删除、改名或公开符号迁移时才登记;
|
||||
物理文件仍是稳定入口的,不应为了目录规整新增“自己映射自己”的别名,也不应在 canonical 包中
|
||||
保留多余导出。
|
||||
|
||||
@@ -136,7 +136,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
|
||||
|---|---|---|---|
|
||||
| S3-L1 TransferChain | `VERIFIED` | S1-L1 | 旧 `transfer.py`/`_transfer.py` 单体退役,由 `app.chain.transfer` 同名职责包承接;queue/recovery、plan、execute、settle、history/notify 各有唯一 owner,原 836 行执行方法消失,包根只保留稳定 ABI |
|
||||
| S3-L2 SubscribeChain | `VERIFIED` | S1-L5 | 旧 `subscribe.py` 单体退役,由 `app.chain.subscribe` 同名职责包承接;search、match、refresh、completion、reference reconciliation、notification 各有唯一 owner,包根只保留稳定 `SubscribeChain` |
|
||||
| S3-L3 Scheduler | `PLANNED` | S2-L3 | JobCatalog、ExecutionRegistry、reconciler、lifecycle 分离,无参 Chain 构造清零 |
|
||||
| S3-L3 Scheduler | `VERIFIED` | S2-L3 | 旧 `app/scheduler.py` 已退役并迁入 `app.scheduler` 同名包;catalog、execution/bridge/progress、`ExecutionRegistry`、reconciler、lifecycle 与 maintenance 已分离,业务 callable 由 startup 经 `SchedulerServices` 注入,包内无参 Chain 构造清零;功能/生命周期 215 项、架构/兼容/文档 189 项通过,官方插件基线语义未变化 |
|
||||
| S3-L4 DownloadChain | `PLANNED` | S1-L6 | selection、submission、history、post-processing 分离,提交后动作遵守新完成语义 |
|
||||
| S3-L5 SearchChain | `PLANNED` | S2-L7 | plan、provider fan-out、result state、pagination 分离,状态 owner 唯一 |
|
||||
| S3-L6 MediaChain | `PLANNED` | S2-L2 | recognition、source projection、music alignment、cache 分离,兼容仅经统一层 |
|
||||
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
|
||||
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
|
||||
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
|
||||
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
|
||||
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 706 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
|
||||
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 700 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
|
||||
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
||||
|
||||
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
||||
|
||||
@@ -125,6 +125,20 @@ API, Scheduler and Chain deployment values are exposed as frozen snapshots from
|
||||
`HostRuntime.configuration`; canonical callers must not add a fresh direct
|
||||
`settings` import when the required field belongs to an existing snapshot.
|
||||
|
||||
The historical `app/scheduler.py` monolith must not be recreated. Scheduler
|
||||
implementation lives in the same-named `app/scheduler/` package: `catalog.py`
|
||||
owns built-in job declarations and APScheduler projection, `execution.py`,
|
||||
`bridge.py` and `progress.py` own execution and completion mechanics,
|
||||
`registry.py` is the sole owner of generations, active ownership, reservations
|
||||
and handles, `reconcile.py` owns dynamic Agent/Workflow/plugin job projection,
|
||||
and `lifecycle.py` owns init, reload and shutdown. Business Chain instances are
|
||||
constructed only by `app/startup/initializers/scheduler.py` and injected as the
|
||||
frozen callable-only `SchedulerServices`; files under `app/scheduler/` must not
|
||||
construct a business Chain. The `app.scheduler` package root lazily preserves
|
||||
only the historical `Scheduler` and `SchedulerChain` identities. New plugins use
|
||||
the narrow `app.sdk.scheduler` facade; internal Scheduler owners must not be
|
||||
re-exported from the package root, SDK or Compat.
|
||||
|
||||
`app.schemas` and the `app.db` package root are compatibility facades, not
|
||||
implementation dependency hubs. Host code imports concrete schema submodules; the schema root
|
||||
resolves its generated export manifest lazily for plugins and legacy callers.
|
||||
@@ -768,6 +782,9 @@ driven workflow registration.
|
||||
| `app/application/transfer/workflow.py` | Transfer task, durable admission, versioned planning input/checkpoint contracts and queue use case |
|
||||
| `app/db/adapters/transfer/admission.py` | SQLAlchemy admission/checkpoint persistence, CAS state transition and detached snapshot adapter |
|
||||
| `app/application/scheduling.py` | Runtime scheduler facade for Agent tools and endpoints; `Scheduler` class registered by `app/startup/initializers/scheduler.py` |
|
||||
| `app/scheduler/` | Scheduler implementation package: stable facade plus catalog, execution/bridge/progress, registry, reconcile, lifecycle and maintenance owners; no business Chain construction |
|
||||
| `app/startup/initializers/scheduler.py` | Registers the concrete Scheduler and injects the AgentTask repository plus frozen callable-only `SchedulerServices` before lifecycle start |
|
||||
| `app/sdk/scheduler.py` | Narrow scheduling facade for new plugins; does not expose concrete Scheduler owners or lifecycle state |
|
||||
| `app/application/commands.py` | Command registry facade for Agent tools and endpoints; `Command` class registered by `app/startup/initializers/command.py` |
|
||||
| `app/application/workflow.py` | Workflow use cases, frozen query snapshot and typed runtime ports consumed by API, Agent, Chain and Scheduler; `WorkflowManager` is registered by `app/startup/initializers/workflow.py` |
|
||||
| `app/db/adapters/workflow.py` | Short-session Workflow query projection and execution-state transaction adapters |
|
||||
|
||||
@@ -32,7 +32,7 @@ SCAN_ROOTS = (
|
||||
"app/command.py",
|
||||
"app/factory.py",
|
||||
"app/main.py",
|
||||
"app/scheduler.py",
|
||||
"app/scheduler",
|
||||
)
|
||||
|
||||
_SYNC_HTTP_METHODS = {
|
||||
|
||||
@@ -34,6 +34,10 @@ MYPY_PATH_MIGRATIONS = {
|
||||
("app/chain/transfer.py", "app/chain/_transfer.py"),
|
||||
("app/chain/transfer/",),
|
||||
),
|
||||
"scheduler-package": (
|
||||
("app/scheduler.py",),
|
||||
("app/scheduler/",),
|
||||
),
|
||||
}
|
||||
|
||||
# 形如 app/foo.py:12: error: 消息说明 [error-code];个别错误可能缺代码。
|
||||
|
||||
@@ -33,10 +33,9 @@ class RuntimeFacadePolicy:
|
||||
RUNTIME_FACADE_POLICIES = (
|
||||
RuntimeFacadePolicy(
|
||||
name="scheduler",
|
||||
dependency="app.scheduler",
|
||||
dependency="app.scheduler.facade",
|
||||
exact_consumers=frozenset(
|
||||
{
|
||||
"app.scheduler",
|
||||
"app.startup.initializers.modules",
|
||||
"app.startup.initializers.scheduler",
|
||||
}
|
||||
|
||||
+1
-1
@@ -436,7 +436,7 @@ def configure_plugin_system_services():
|
||||
configure_agent_chat_persistence(agent_chat_persistence)
|
||||
configure_agent_chat_service(agent_chat_service)
|
||||
from app.agent.tools.manager import moviepilot_tool_manager
|
||||
from app.scheduler import Scheduler
|
||||
from app.scheduler.facade import Scheduler
|
||||
|
||||
moviepilot_tool_manager.set_data_context(agent_data_context)
|
||||
Scheduler().configure_agent_tasks(agent_task_repository)
|
||||
|
||||
+145
-52
@@ -1119,8 +1119,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 7532,
|
||||
"edge_sha256": "ae6eff386c809faeb9b9ba2d61d0d66bdf8389a9fe977389a52923fc93ad8d12",
|
||||
"edge_count": 7612,
|
||||
"edge_sha256": "15b211a9bc9400fa8a28480223372039344ae8e584bd1ac769359a808e4610be",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -7732,55 +7732,117 @@
|
||||
"app.runtime.version -> app.runtime",
|
||||
"app.runtime.version -> app.runtime.log",
|
||||
"app.runtime.version -> app.runtime.settings",
|
||||
"app.scheduler -> app.adapters",
|
||||
"app.scheduler -> app.adapters.external",
|
||||
"app.scheduler -> app.adapters.external.server",
|
||||
"app.scheduler -> app.adapters.system",
|
||||
"app.scheduler -> app.adapters.system.update",
|
||||
"app.scheduler -> app.application",
|
||||
"app.scheduler -> app.application.agent",
|
||||
"app.scheduler -> app.application.agenttask",
|
||||
"app.scheduler -> app.application.configuration",
|
||||
"app.scheduler -> app.application.database",
|
||||
"app.scheduler -> app.application.image",
|
||||
"app.scheduler -> app.application.mediaserver",
|
||||
"app.scheduler -> app.application.messaging",
|
||||
"app.scheduler -> app.application.messaging.message",
|
||||
"app.scheduler -> app.application.outbox",
|
||||
"app.scheduler -> app.application.plugin",
|
||||
"app.scheduler -> app.application.plugin.routes",
|
||||
"app.scheduler -> app.application.plugin.runtime",
|
||||
"app.scheduler -> app.application.scheduling",
|
||||
"app.scheduler -> app.application.site",
|
||||
"app.scheduler -> app.application.workflow",
|
||||
"app.scheduler -> app.chain",
|
||||
"app.scheduler -> app.chain.base",
|
||||
"app.scheduler -> app.chain.mediaserver",
|
||||
"app.scheduler -> app.chain.recommend",
|
||||
"app.scheduler -> app.chain.site",
|
||||
"app.scheduler -> app.chain.subscribe",
|
||||
"app.scheduler -> app.chain.subscribe.facade",
|
||||
"app.scheduler -> app.chain.transfer",
|
||||
"app.scheduler -> app.chain.transfer.facade",
|
||||
"app.scheduler -> app.chain.workflow",
|
||||
"app.scheduler -> app.foundation",
|
||||
"app.scheduler -> app.foundation.singleton",
|
||||
"app.scheduler -> app.runtime",
|
||||
"app.scheduler -> app.runtime.correlation",
|
||||
"app.scheduler -> app.runtime.events",
|
||||
"app.scheduler -> app.runtime.gc",
|
||||
"app.scheduler -> app.runtime.log",
|
||||
"app.scheduler -> app.runtime.loop",
|
||||
"app.scheduler -> app.runtime.observability",
|
||||
"app.scheduler -> app.runtime.progress",
|
||||
"app.scheduler -> app.runtime.reload",
|
||||
"app.scheduler -> app.runtime.scheduling",
|
||||
"app.scheduler -> app.runtime.stop",
|
||||
"app.scheduler -> app.schemas",
|
||||
"app.scheduler -> app.schemas.dashboard",
|
||||
"app.scheduler -> app.schemas.message",
|
||||
"app.scheduler -> app.schemas.system",
|
||||
"app.scheduler -> app.schemas.types",
|
||||
"app.scheduler.bridge -> app.runtime",
|
||||
"app.scheduler.bridge -> app.runtime.loop",
|
||||
"app.scheduler.bridge -> app.scheduler",
|
||||
"app.scheduler.bridge -> app.scheduler.contract",
|
||||
"app.scheduler.bridge -> app.scheduler.registry",
|
||||
"app.scheduler.catalog -> app.adapters",
|
||||
"app.scheduler.catalog -> app.adapters.external",
|
||||
"app.scheduler.catalog -> app.adapters.external.server",
|
||||
"app.scheduler.catalog -> app.adapters.system",
|
||||
"app.scheduler.catalog -> app.adapters.system.update",
|
||||
"app.scheduler.catalog -> app.application",
|
||||
"app.scheduler.catalog -> app.application.configuration",
|
||||
"app.scheduler.catalog -> app.application.mediaserver",
|
||||
"app.scheduler.catalog -> app.application.outbox",
|
||||
"app.scheduler.catalog -> app.application.plugin",
|
||||
"app.scheduler.catalog -> app.application.plugin.runtime",
|
||||
"app.scheduler.catalog -> app.application.scheduling",
|
||||
"app.scheduler.catalog -> app.runtime",
|
||||
"app.scheduler.catalog -> app.runtime.scheduling",
|
||||
"app.scheduler.catalog -> app.scheduler",
|
||||
"app.scheduler.catalog -> app.scheduler.contract",
|
||||
"app.scheduler.catalog -> app.schemas",
|
||||
"app.scheduler.catalog -> app.schemas.system",
|
||||
"app.scheduler.chain -> app.application",
|
||||
"app.scheduler.chain -> app.application.database",
|
||||
"app.scheduler.chain -> app.chain",
|
||||
"app.scheduler.chain -> app.chain.base",
|
||||
"app.scheduler.execution -> app.application",
|
||||
"app.scheduler.execution -> app.application.messaging",
|
||||
"app.scheduler.execution -> app.application.messaging.message",
|
||||
"app.scheduler.execution -> app.application.scheduling",
|
||||
"app.scheduler.execution -> app.runtime",
|
||||
"app.scheduler.execution -> app.runtime.correlation",
|
||||
"app.scheduler.execution -> app.runtime.events",
|
||||
"app.scheduler.execution -> app.runtime.log",
|
||||
"app.scheduler.execution -> app.runtime.loop",
|
||||
"app.scheduler.execution -> app.runtime.observability",
|
||||
"app.scheduler.execution -> app.runtime.progress",
|
||||
"app.scheduler.execution -> app.runtime.scheduling",
|
||||
"app.scheduler.execution -> app.scheduler",
|
||||
"app.scheduler.execution -> app.scheduler.contract",
|
||||
"app.scheduler.execution -> app.schemas",
|
||||
"app.scheduler.execution -> app.schemas.dashboard",
|
||||
"app.scheduler.execution -> app.schemas.types",
|
||||
"app.scheduler.facade -> app.application",
|
||||
"app.scheduler.facade -> app.application.agenttask",
|
||||
"app.scheduler.facade -> app.foundation",
|
||||
"app.scheduler.facade -> app.foundation.singleton",
|
||||
"app.scheduler.facade -> app.runtime",
|
||||
"app.scheduler.facade -> app.runtime.events",
|
||||
"app.scheduler.facade -> app.runtime.reload",
|
||||
"app.scheduler.facade -> app.scheduler",
|
||||
"app.scheduler.facade -> app.scheduler.bridge",
|
||||
"app.scheduler.facade -> app.scheduler.catalog",
|
||||
"app.scheduler.facade -> app.scheduler.execution",
|
||||
"app.scheduler.facade -> app.scheduler.lifecycle",
|
||||
"app.scheduler.facade -> app.scheduler.maintenance",
|
||||
"app.scheduler.facade -> app.scheduler.progress",
|
||||
"app.scheduler.facade -> app.scheduler.reconcile",
|
||||
"app.scheduler.facade -> app.scheduler.registry",
|
||||
"app.scheduler.facade -> app.scheduler.services",
|
||||
"app.scheduler.facade -> app.schemas",
|
||||
"app.scheduler.facade -> app.schemas.types",
|
||||
"app.scheduler.lifecycle -> app.application",
|
||||
"app.scheduler.lifecycle -> app.application.configuration",
|
||||
"app.scheduler.lifecycle -> app.runtime",
|
||||
"app.scheduler.lifecycle -> app.runtime.log",
|
||||
"app.scheduler.lifecycle -> app.runtime.stop",
|
||||
"app.scheduler.lifecycle -> app.scheduler",
|
||||
"app.scheduler.lifecycle -> app.scheduler.contract",
|
||||
"app.scheduler.lifecycle -> app.scheduler.registry",
|
||||
"app.scheduler.maintenance -> app.application",
|
||||
"app.scheduler.maintenance -> app.application.agent",
|
||||
"app.scheduler.maintenance -> app.application.backup",
|
||||
"app.scheduler.maintenance -> app.application.database",
|
||||
"app.scheduler.maintenance -> app.runtime",
|
||||
"app.scheduler.maintenance -> app.runtime.gc",
|
||||
"app.scheduler.maintenance -> app.runtime.log",
|
||||
"app.scheduler.maintenance -> app.scheduler",
|
||||
"app.scheduler.maintenance -> app.scheduler.contract",
|
||||
"app.scheduler.progress -> app.application",
|
||||
"app.scheduler.progress -> app.application.scheduling",
|
||||
"app.scheduler.progress -> app.runtime",
|
||||
"app.scheduler.progress -> app.runtime.observability",
|
||||
"app.scheduler.progress -> app.runtime.progress",
|
||||
"app.scheduler.progress -> app.scheduler",
|
||||
"app.scheduler.progress -> app.scheduler.contract",
|
||||
"app.scheduler.progress -> app.schemas",
|
||||
"app.scheduler.progress -> app.schemas.dashboard",
|
||||
"app.scheduler.reconcile -> app.application",
|
||||
"app.scheduler.reconcile -> app.application.agent",
|
||||
"app.scheduler.reconcile -> app.application.agenttask",
|
||||
"app.scheduler.reconcile -> app.application.configuration",
|
||||
"app.scheduler.reconcile -> app.application.plugin",
|
||||
"app.scheduler.reconcile -> app.application.plugin.routes",
|
||||
"app.scheduler.reconcile -> app.application.plugin.runtime",
|
||||
"app.scheduler.reconcile -> app.application.scheduling",
|
||||
"app.scheduler.reconcile -> app.application.site",
|
||||
"app.scheduler.reconcile -> app.application.workflow",
|
||||
"app.scheduler.reconcile -> app.runtime",
|
||||
"app.scheduler.reconcile -> app.runtime.log",
|
||||
"app.scheduler.reconcile -> app.runtime.scheduling",
|
||||
"app.scheduler.reconcile -> app.scheduler",
|
||||
"app.scheduler.reconcile -> app.scheduler.contract",
|
||||
"app.scheduler.reconcile -> app.schemas",
|
||||
"app.scheduler.reconcile -> app.schemas.message",
|
||||
"app.scheduler.reconcile -> app.schemas.types",
|
||||
"app.scheduler.services -> app.application",
|
||||
"app.scheduler.services -> app.application.workflow",
|
||||
"app.scheduler.services -> app.schemas",
|
||||
"app.scheduler.services -> app.schemas.message",
|
||||
"app.schemas -> app.schemas.exports",
|
||||
"app.schemas.agent -> app.schemas",
|
||||
"app.schemas.agent -> app.schemas.common",
|
||||
@@ -7978,6 +8040,10 @@
|
||||
"app.sdk.queries -> app.application.query",
|
||||
"app.sdk.queries -> app.schemas",
|
||||
"app.sdk.queries -> app.schemas.query",
|
||||
"app.sdk.scheduler -> app.application",
|
||||
"app.sdk.scheduler -> app.application.scheduling",
|
||||
"app.sdk.scheduler -> app.schemas",
|
||||
"app.sdk.scheduler -> app.schemas.dashboard",
|
||||
"app.sdk.security -> app.adapters",
|
||||
"app.sdk.security -> app.adapters.web",
|
||||
"app.sdk.security -> app.adapters.web.security",
|
||||
@@ -8299,6 +8365,7 @@
|
||||
"app.startup.initializers.modules -> app.runtime.tasks",
|
||||
"app.startup.initializers.modules -> app.runtime.thread",
|
||||
"app.startup.initializers.modules -> app.scheduler",
|
||||
"app.startup.initializers.modules -> app.scheduler.facade",
|
||||
"app.startup.initializers.modules -> app.schemas",
|
||||
"app.startup.initializers.modules -> app.schemas.message",
|
||||
"app.startup.initializers.modules -> app.schemas.types",
|
||||
@@ -8393,8 +8460,21 @@
|
||||
"app.startup.initializers.routers -> app.api.servcookie",
|
||||
"app.startup.initializers.scheduler -> app.application",
|
||||
"app.startup.initializers.scheduler -> app.application.agenttask",
|
||||
"app.startup.initializers.scheduler -> app.application.image",
|
||||
"app.startup.initializers.scheduler -> app.application.scheduling",
|
||||
"app.startup.initializers.scheduler -> app.chain",
|
||||
"app.startup.initializers.scheduler -> app.chain.mediaserver",
|
||||
"app.startup.initializers.scheduler -> app.chain.recommend",
|
||||
"app.startup.initializers.scheduler -> app.chain.site",
|
||||
"app.startup.initializers.scheduler -> app.chain.subscribe",
|
||||
"app.startup.initializers.scheduler -> app.chain.subscribe.facade",
|
||||
"app.startup.initializers.scheduler -> app.chain.transfer",
|
||||
"app.startup.initializers.scheduler -> app.chain.transfer.facade",
|
||||
"app.startup.initializers.scheduler -> app.chain.workflow",
|
||||
"app.startup.initializers.scheduler -> app.scheduler",
|
||||
"app.startup.initializers.scheduler -> app.scheduler.chain",
|
||||
"app.startup.initializers.scheduler -> app.scheduler.facade",
|
||||
"app.startup.initializers.scheduler -> app.scheduler.services",
|
||||
"app.startup.initializers.site -> app.adapters",
|
||||
"app.startup.initializers.site -> app.adapters.external",
|
||||
"app.startup.initializers.site -> app.adapters.external.cookiecloud",
|
||||
@@ -8655,7 +8735,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 893,
|
||||
"module_count": 906,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9440,6 +9520,18 @@
|
||||
"app.runtime.version",
|
||||
"app.runtime.webpush",
|
||||
"app.scheduler",
|
||||
"app.scheduler.bridge",
|
||||
"app.scheduler.catalog",
|
||||
"app.scheduler.chain",
|
||||
"app.scheduler.contract",
|
||||
"app.scheduler.execution",
|
||||
"app.scheduler.facade",
|
||||
"app.scheduler.lifecycle",
|
||||
"app.scheduler.maintenance",
|
||||
"app.scheduler.progress",
|
||||
"app.scheduler.reconcile",
|
||||
"app.scheduler.registry",
|
||||
"app.scheduler.services",
|
||||
"app.schemas",
|
||||
"app.schemas.agent",
|
||||
"app.schemas.cache",
|
||||
@@ -9500,6 +9592,7 @@
|
||||
"app.sdk.network",
|
||||
"app.sdk.plugins",
|
||||
"app.sdk.queries",
|
||||
"app.sdk.scheduler",
|
||||
"app.sdk.security",
|
||||
"app.sdk.services",
|
||||
"app.sdk.string",
|
||||
|
||||
+4
-22
@@ -708,7 +708,6 @@
|
||||
"app/agent/tools/impl/test_site.py": {
|
||||
"misc": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"override": 1
|
||||
},
|
||||
@@ -775,7 +774,6 @@
|
||||
"app/agent/tools/impl/update_site_cookie.py": {
|
||||
"misc": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"override": 1
|
||||
},
|
||||
@@ -988,7 +986,7 @@
|
||||
"assignment": 2,
|
||||
"attr-defined": 3,
|
||||
"misc": 26,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"return-value": 3,
|
||||
"type-arg": 6,
|
||||
@@ -1389,7 +1387,7 @@
|
||||
"arg-type": 21,
|
||||
"assignment": 2,
|
||||
"no-any-return": 11,
|
||||
"no-untyped-call": 10,
|
||||
"no-untyped-call": 8,
|
||||
"no-untyped-def": 6,
|
||||
"type-arg": 2,
|
||||
"union-attr": 1,
|
||||
@@ -1441,7 +1439,7 @@
|
||||
"attr-defined": 1,
|
||||
"misc": 6,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-def": 8,
|
||||
"no-untyped-def": 7,
|
||||
"return-value": 2,
|
||||
"type-arg": 5
|
||||
},
|
||||
@@ -1639,7 +1637,7 @@
|
||||
"arg-type": 3,
|
||||
"assignment": 5,
|
||||
"misc": 6,
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-def": 4,
|
||||
"type-arg": 7,
|
||||
"valid-type": 1,
|
||||
@@ -3236,18 +3234,6 @@
|
||||
"app/runtime/thread.py": {
|
||||
"unused-ignore": 1
|
||||
},
|
||||
"app/scheduler.py": {
|
||||
"arg-type": 7,
|
||||
"attr-defined": 1,
|
||||
"import-untyped": 1,
|
||||
"misc": 1,
|
||||
"no-any-return": 3,
|
||||
"no-untyped-call": 6,
|
||||
"no-untyped-def": 13,
|
||||
"return-value": 1,
|
||||
"type-arg": 7,
|
||||
"union-attr": 1
|
||||
},
|
||||
"app/schemas/agent.py": {
|
||||
"misc": 26
|
||||
},
|
||||
@@ -3418,10 +3404,6 @@
|
||||
"app/startup/initializers/routers.py": {
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/startup/initializers/scheduler.py": {
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-def": 4
|
||||
},
|
||||
"app/startup/initializers/workflow.py": {
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
|
||||
-18
@@ -975,15 +975,9 @@
|
||||
"tests/test_dashboard_system_info.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_data_cleanup_chain.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_database_backup_cli_sdk.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_database_backup_scheduler.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_database_index_migration.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1143,9 +1137,6 @@
|
||||
"tests/test_mediaserver_image_signing.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_mediaserver_sync_scheduler.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_message_channel_permissions.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1242,9 +1233,6 @@
|
||||
"tests/test_plugin_lifecycle_status.py": {
|
||||
"F401": 1
|
||||
},
|
||||
"tests/test_plugin_local_sync.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_plugin_market_default.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1287,12 +1275,6 @@
|
||||
"tests/test_rust_accel_toggle.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_scheduler_cache_expiry.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_scheduler_contracts.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_search_ai_recommend.py": {
|
||||
"E402": 8,
|
||||
"I001": 1
|
||||
|
||||
+59
-7
@@ -1433,12 +1433,12 @@
|
||||
"registration_kind": "listener"
|
||||
},
|
||||
{
|
||||
"caller": "app.scheduler",
|
||||
"caller": "app.scheduler.facade",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.PluginReload"
|
||||
],
|
||||
"fingerprint": "055c11260c15adabe65ddf1c9fd39000a7c1b7e255848e3c028e3985731e0778",
|
||||
"fingerprint": "8780e71625cff4efebb1e39c32cff3c8cfd29c3cdf5eb1922d5e0d240ebd477a",
|
||||
"handler": "Scheduler.on_plugin_reload",
|
||||
"invalid": false,
|
||||
"method": "register",
|
||||
@@ -1752,7 +1752,7 @@
|
||||
},
|
||||
"EventType.PluginReload": {
|
||||
"consumer_fingerprints": [
|
||||
"055c11260c15adabe65ddf1c9fd39000a7c1b7e255848e3c028e3985731e0778"
|
||||
"8780e71625cff4efebb1e39c32cff3c8cfd29c3cdf5eb1922d5e0d240ebd477a"
|
||||
],
|
||||
"producer_fingerprints": []
|
||||
},
|
||||
@@ -1839,7 +1839,7 @@
|
||||
"consumer_fingerprints": [],
|
||||
"producer_fingerprints": [
|
||||
"8b725b957d7699c989b20606ab2ed4df85768a16096eba922b9ddf1232f3a9fd",
|
||||
"a812b122b031e0ed33df0300292fad84360bd09223de07f3d13d0bd3d1a59429",
|
||||
"b11f2671831e389e6c6ce0ee2d47da41d4ff088376e85ae8350a46a3b48315b5",
|
||||
"b80e0452e3c7ad08b2c5a83cd7bea4185e0ed3f3012d20f2f1688597e6cefe57",
|
||||
"dcfe851d124af4d4c3781b65b0c0115934a9c2422ee20aca53d625dda3190289"
|
||||
]
|
||||
@@ -2786,15 +2786,15 @@
|
||||
"receiver_kind": "canonical_singleton"
|
||||
},
|
||||
{
|
||||
"caller": "app.scheduler",
|
||||
"caller": "app.scheduler.execution",
|
||||
"dynamic": false,
|
||||
"events": [
|
||||
"EventType.SystemError"
|
||||
],
|
||||
"fingerprint": "a812b122b031e0ed33df0300292fad84360bd09223de07f3d13d0bd3d1a59429",
|
||||
"fingerprint": "b11f2671831e389e6c6ce0ee2d47da41d4ff088376e85ae8350a46a3b48315b5",
|
||||
"invalid": false,
|
||||
"method": "send_event",
|
||||
"qualname": "Scheduler.__handle_job_error",
|
||||
"qualname": "SchedulerExecutionOwner._handle_job_error",
|
||||
"receiver_kind": "canonical_singleton"
|
||||
},
|
||||
{
|
||||
@@ -10363,6 +10363,58 @@
|
||||
"target": ""
|
||||
}
|
||||
],
|
||||
"app.sdk.scheduler": [
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "ScheduleInfo",
|
||||
"target": "app.schemas.dashboard.ScheduleInfo"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "ScheduleProgress",
|
||||
"target": "app.schemas.dashboard.ScheduleProgress"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "get_agent_task_next_run",
|
||||
"target": "app.application.scheduling.get_agent_task_next_run"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "list_scheduler_jobs",
|
||||
"target": "app.application.scheduling.list_scheduler_jobs"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "remove_agent_task_job",
|
||||
"target": "app.application.scheduling.remove_agent_task_job"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "remove_plugin_job",
|
||||
"target": "app.application.scheduling.remove_plugin_job"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "start_agent_task",
|
||||
"target": "app.application.scheduling.start_agent_task"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "start_scheduler_job",
|
||||
"target": "app.application.scheduling.start_scheduler_job"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "update_agent_task_job",
|
||||
"target": "app.application.scheduling.update_agent_task_job"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "update_plugin_job",
|
||||
"target": "app.application.scheduling.update_plugin_job"
|
||||
}
|
||||
],
|
||||
"app.sdk.security": [
|
||||
{
|
||||
"kind": "import",
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
"reason": "配置重载 Mixin 为声明式宿主类型安装统一刷新处理器。"
|
||||
},
|
||||
{
|
||||
"caller": "app.scheduler",
|
||||
"caller": "app.scheduler.facade",
|
||||
"qualname": "Scheduler",
|
||||
"method": "register",
|
||||
"receiver_kind": "canonical_singleton",
|
||||
@@ -257,9 +257,9 @@
|
||||
"handler": "Scheduler.on_plugin_reload",
|
||||
"registration_kind": "decorator",
|
||||
"priority": "<default>",
|
||||
"fingerprint": "055c11260c15adabe65ddf1c9fd39000a7c1b7e255848e3c028e3985731e0778",
|
||||
"fingerprint": "8780e71625cff4efebb1e39c32cff3c8cfd29c3cdf5eb1922d5e0d240ebd477a",
|
||||
"classification": "approved_static_registration",
|
||||
"owner": "app.scheduler",
|
||||
"owner": "app.scheduler.facade",
|
||||
"reason": "插件重载后由 Scheduler 对齐插件提供的定时任务。"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -44,7 +44,8 @@ from app.db.models.agenttask import AgentTask
|
||||
from app.db.oper.agenttask import AgentTaskOper
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.scheduling import TimerUtils
|
||||
from app.scheduler import Scheduler
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
from app.schemas import ScheduleInfo
|
||||
|
||||
|
||||
@@ -135,10 +136,7 @@ def _build_agent_task_scheduler(reconcile: bool = False) -> Scheduler:
|
||||
scheduler._jobs = {}
|
||||
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
scheduler._agent_task_interruptions_reconciled = False
|
||||
scheduler._agent_tasks = TransactionalAgentTaskRepository(SessionFactory)
|
||||
if reconcile:
|
||||
@@ -334,10 +332,7 @@ def test_scheduler_registers_and_removes_agent_task_job() -> None:
|
||||
scheduler._jobs = {}
|
||||
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
scheduler._agent_tasks = TransactionalAgentTaskRepository(SessionFactory)
|
||||
|
||||
next_run_at = scheduler.update_agent_task_job(task.id)
|
||||
@@ -422,7 +417,7 @@ async def test_date_task_reload_job_is_removed_after_run_finishes(
|
||||
release.set()
|
||||
|
||||
async def wait_until_released() -> None:
|
||||
while scheduler._handles:
|
||||
while scheduler._registry.handles():
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||
@@ -811,12 +806,12 @@ async def test_scheduler_config_reload_preserves_active_agent_task(
|
||||
|
||||
await scheduler.on_config_changed()
|
||||
assert AgentTaskOper().get(task.id).last_status == "running"
|
||||
assert scheduler._handles
|
||||
assert scheduler._registry.handles()
|
||||
|
||||
release.set()
|
||||
|
||||
async def wait_until_released() -> None:
|
||||
while scheduler._handles:
|
||||
while scheduler._registry.handles():
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||
@@ -884,10 +879,7 @@ def test_scheduler_starts_registered_agent_task_without_waiting() -> None:
|
||||
}
|
||||
}
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
scheduler.start = Mock()
|
||||
|
||||
assert scheduler.start_agent_task(7) is True
|
||||
|
||||
@@ -960,7 +960,13 @@ def test_transfer_admission_chain_context_field_is_typed():
|
||||
|
||||
def test_scheduler_does_not_depend_on_database_implementation():
|
||||
"""Scheduler 只能消费应用端口,不得重新直连 app.db 实现。"""
|
||||
dependencies = _build_module_graph().get("app.scheduler", set())
|
||||
dependencies = set().union(
|
||||
*(
|
||||
module_dependencies
|
||||
for module_name, module_dependencies in _build_module_graph().items()
|
||||
if module_name == "app.scheduler" or module_name.startswith("app.scheduler.")
|
||||
)
|
||||
)
|
||||
assert {
|
||||
dependency
|
||||
for dependency in dependencies
|
||||
@@ -989,7 +995,7 @@ def test_canonical_service_config_consumers_use_application_directory():
|
||||
APP_ROOT / "chain" / "_messaging.py",
|
||||
APP_ROOT / "chain" / "mediaserver.py",
|
||||
APP_ROOT / "api" / "endpoints" / "message.py",
|
||||
APP_ROOT / "scheduler.py",
|
||||
*(APP_ROOT / "scheduler").glob("*.py"),
|
||||
APP_ROOT / "agent" / "llm" / "capability.py",
|
||||
APP_ROOT / "agent" / "tools" / "base.py",
|
||||
APP_ROOT / "agent" / "tools" / "impl" / "query_library_latest.py",
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_async_blocking_scan_covers_runtime_entrypoints() -> None:
|
||||
"app/command.py",
|
||||
"app/factory.py",
|
||||
"app/main.py",
|
||||
"app/scheduler.py",
|
||||
"app/scheduler",
|
||||
}.issubset({str(path) for path in SCAN_ROOTS})
|
||||
|
||||
|
||||
|
||||
@@ -7,19 +7,19 @@ from unittest.mock import MagicMock, patch
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.database import DatabaseGovernance, configure_database_governance
|
||||
from app.application.maintenance import (
|
||||
DataCleanupService,
|
||||
read_cleanup_policy,
|
||||
)
|
||||
from app.db import Base
|
||||
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
|
||||
from app.db.maintenance import DatabaseCleanupRepository
|
||||
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
||||
from app.db.models.message import Message
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.runtime.config import settings
|
||||
from app.scheduler import SchedulerChain
|
||||
from app.application.maintenance import (
|
||||
DataCleanupService,
|
||||
read_cleanup_policy,
|
||||
)
|
||||
from app.application.database import DatabaseGovernance, configure_database_governance
|
||||
from app.db.maintenance import DatabaseCleanupRepository
|
||||
|
||||
|
||||
class DataCleanupChainTest(unittest.TestCase):
|
||||
|
||||
@@ -7,7 +7,8 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from app.application.maintenance import CleanupPolicy, DataCleanupService
|
||||
from app.scheduler import SchedulerChain
|
||||
from app.scheduler import chain as scheduler_chain
|
||||
from app.scheduler.chain import SchedulerChain
|
||||
|
||||
|
||||
class FakeCleanupRepository:
|
||||
@@ -168,7 +169,7 @@ def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
|
||||
governance.cleanup.return_value = {"enabled": True}
|
||||
progress = MagicMock()
|
||||
|
||||
with patch("app.scheduler.get_database_governance", return_value=governance):
|
||||
with patch.object(scheduler_chain, "get_database_governance", return_value=governance):
|
||||
result = SchedulerChain().cleanup(batch_size=7, progress_callback=progress)
|
||||
|
||||
assert result == {"enabled": True}
|
||||
@@ -180,10 +181,14 @@ def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
|
||||
|
||||
def test_scheduler_does_not_reclaim_database_cleanup_ownership() -> None:
|
||||
"""调度模块不得重新导入清理模型或数据库会话。"""
|
||||
scheduler_path = Path(__file__).parents[1] / "app" / "scheduler.py"
|
||||
tree = ast.parse(scheduler_path.read_text(encoding="utf-8"))
|
||||
scheduler_root = Path(__file__).parents[1] / "app" / "scheduler"
|
||||
trees = [
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
for path in scheduler_root.glob("*.py")
|
||||
]
|
||||
imports = {
|
||||
node.module
|
||||
for tree in trees
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ImportFrom) and node.module
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app import scheduler as scheduler_module
|
||||
from app.scheduler import Scheduler
|
||||
from app.application.configuration import SchedulerRuntimeConfig
|
||||
from app.scheduler import catalog as scheduler_catalog
|
||||
from app.scheduler import maintenance as scheduler_maintenance
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
|
||||
|
||||
class _SchedulerStub:
|
||||
@@ -30,10 +32,7 @@ def _scheduler() -> Scheduler:
|
||||
scheduler._jobs = {}
|
||||
scheduler._lock = threading.RLock()
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -93,7 +92,7 @@ def test_enabled_database_backup_without_cron_does_not_register_job() -> None:
|
||||
def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -> None:
|
||||
scheduler = _scheduler()
|
||||
trigger = object()
|
||||
monkeypatch.setattr(scheduler_module.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger))
|
||||
monkeypatch.setattr(scheduler_catalog.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger))
|
||||
config = _config(db_backup_enable=True, db_backup_cron="0 3 * * *")
|
||||
|
||||
scheduler._register_database_backup_job(config)
|
||||
@@ -105,7 +104,7 @@ def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -
|
||||
|
||||
def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> None:
|
||||
governance = Mock()
|
||||
monkeypatch.setattr(scheduler_module, "get_database_governance", lambda: governance)
|
||||
monkeypatch.setattr(scheduler_maintenance, "get_database_governance", lambda: governance)
|
||||
|
||||
result = Scheduler.database_backup()
|
||||
|
||||
@@ -114,11 +113,11 @@ def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> No
|
||||
|
||||
|
||||
def test_scheduler_database_dependencies_are_explicit_module_imports() -> None:
|
||||
tree = ast.parse(
|
||||
(Path(__file__).parents[1] / "app" / "scheduler.py").read_text(encoding="utf-8")
|
||||
)
|
||||
scheduler_root = Path(__file__).parents[1] / "app" / "scheduler"
|
||||
trees = [ast.parse(path.read_text(encoding="utf-8")) for path in scheduler_root.glob("*.py")]
|
||||
function_imports = [
|
||||
node
|
||||
for tree in trees
|
||||
for function in ast.walk(tree)
|
||||
if isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
for node in ast.walk(function)
|
||||
|
||||
@@ -371,6 +371,29 @@ _ORDERED_SHUTDOWN_STEPS = (
|
||||
)
|
||||
|
||||
|
||||
def test_scheduler_shutdown_callback_is_awaited_on_lifecycle_loop(monkeypatch) -> None:
|
||||
"""定时器关闭必须在生命周期主循环等待异步句柄收口。"""
|
||||
awaited = False
|
||||
|
||||
async def stop_scheduler_async() -> None:
|
||||
"""记录 manifest 回调返回的协程已被生命周期等待。"""
|
||||
nonlocal awaited
|
||||
awaited = True
|
||||
|
||||
monkeypatch.setattr(lifecycle, "stop_scheduler", stop_scheduler_async)
|
||||
component = next(
|
||||
item
|
||||
for item in lifecycle.build_lifecycle_components(FastAPI())
|
||||
if item.name == "定时器"
|
||||
)
|
||||
|
||||
assert component.stop is stop_scheduler_async
|
||||
assert asyncio.run(
|
||||
lifecycle.run_shutdown_step("定时器", component.stop)
|
||||
) is True
|
||||
assert awaited is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failing_step",
|
||||
(
|
||||
|
||||
@@ -429,7 +429,7 @@ def test_scheduler_progress_texts_have_english_translations():
|
||||
"""定时任务相关进度文案应有英文翻译,避免前端切英文后回退中文。"""
|
||||
untranslated = []
|
||||
progress_paths = [
|
||||
Path("app/scheduler.py"),
|
||||
*sorted(Path("app/scheduler").glob("*.py")),
|
||||
Path("app/chain/torrents.py"),
|
||||
Path("app/chain/mediaserver.py"),
|
||||
Path("app/chain/site.py"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.schemas.system import MediaServerConf
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
|
||||
def test_build_mediaserver_sync_schedules_uses_server_interval_and_legacy_fallback():
|
||||
|
||||
@@ -11,10 +11,12 @@ from watchfiles import Change
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.events import Event, eventmanager
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.runtime.extensions.plugin.paths import PluginPathResolver
|
||||
from app.runtime.extensions.plugin.system import get_plugin_system
|
||||
from app.scheduler import Scheduler
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.scheduler import reconcile as scheduler_reconcile
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
|
||||
|
||||
@@ -188,10 +190,7 @@ def _build_scheduler_for_plugin_reload(jobs: dict, backend) -> Scheduler:
|
||||
scheduler._jobs = jobs
|
||||
scheduler._scheduler = backend
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -835,7 +834,7 @@ def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch):
|
||||
}
|
||||
]
|
||||
plugin_manager.get_plugin_attr.return_value = "测试插件"
|
||||
monkeypatch.setattr("app.scheduler.get_plugin_manager", lambda: plugin_manager)
|
||||
monkeypatch.setattr(scheduler_reconcile, "get_plugin_manager", lambda: plugin_manager)
|
||||
backend = _FakeSchedulerBackend(["DemoPlugin_old"])
|
||||
scheduler = _build_scheduler_for_plugin_reload(
|
||||
jobs={
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Scheduler 同名包职责与兼容边界门禁。"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from app.scheduler import Scheduler, SchedulerChain
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_ROOT = PROJECT_ROOT / "app"
|
||||
SCHEDULER_ROOT = APP_ROOT / "scheduler"
|
||||
|
||||
|
||||
def _tree(path: Path) -> ast.Module:
|
||||
"""解析指定 Python 文件。"""
|
||||
return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
|
||||
|
||||
def test_scheduler_monolith_is_retired_and_package_owners_exist() -> None:
|
||||
"""旧单体必须消失,路线图声明的职责 owner 必须各有文件。"""
|
||||
assert not (APP_ROOT / "scheduler.py").exists()
|
||||
assert {
|
||||
"bridge.py",
|
||||
"catalog.py",
|
||||
"execution.py",
|
||||
"facade.py",
|
||||
"lifecycle.py",
|
||||
"progress.py",
|
||||
"reconcile.py",
|
||||
"registry.py",
|
||||
"services.py",
|
||||
} <= {path.name for path in SCHEDULER_ROOT.glob("*.py")}
|
||||
|
||||
|
||||
def test_scheduler_package_root_is_only_stable_legacy_abi() -> None:
|
||||
"""包根只延迟公开迁移前的两个类型,不重复承载实现。"""
|
||||
package_tree = _tree(SCHEDULER_ROOT / "__init__.py")
|
||||
classes = [node.name for node in package_tree.body if isinstance(node, ast.ClassDef)]
|
||||
|
||||
assert classes == []
|
||||
assert Scheduler.__module__ == "app.scheduler"
|
||||
assert SchedulerChain.__module__ == "app.scheduler"
|
||||
|
||||
|
||||
def test_scheduler_facade_remains_thin_and_composes_named_owners() -> None:
|
||||
"""Facade 只组合职责 owner,不重新吸收业务实现。"""
|
||||
facade_path = SCHEDULER_ROOT / "facade.py"
|
||||
facade_tree = _tree(facade_path)
|
||||
scheduler_class = next(
|
||||
node
|
||||
for node in facade_tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "Scheduler"
|
||||
)
|
||||
methods = {
|
||||
node.name
|
||||
for node in scheduler_class.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
|
||||
assert len(facade_path.read_text(encoding="utf-8").splitlines()) <= 160
|
||||
assert methods == {
|
||||
"__init__",
|
||||
"_scheduler_services",
|
||||
"configure_services",
|
||||
"get_reload_name",
|
||||
"on_plugin_reload",
|
||||
}
|
||||
|
||||
|
||||
def test_scheduler_package_does_not_construct_chains() -> None:
|
||||
"""业务 Chain 只能由 startup 组合根构造后注入 Scheduler。"""
|
||||
violations: list[str] = []
|
||||
for path in SCHEDULER_ROOT.glob("*.py"):
|
||||
for node in ast.walk(_tree(path)):
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
|
||||
continue
|
||||
if node.func.id.endswith("Chain"):
|
||||
violations.append(f"{path.name}:{node.lineno}:{node.func.id}")
|
||||
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_host_code_does_not_import_scheduler_plugin_abi_root() -> None:
|
||||
"""除兼容包自身外,宿主只能使用 application 门面或 concrete 子模块。"""
|
||||
violations: list[str] = []
|
||||
for path in APP_ROOT.rglob("*.py"):
|
||||
if "plugins" in path.parts or path == SCHEDULER_ROOT / "__init__.py":
|
||||
continue
|
||||
for node in ast.walk(_tree(path)):
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "app.scheduler":
|
||||
violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}")
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name == "app.scheduler":
|
||||
violations.append(
|
||||
f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}"
|
||||
)
|
||||
|
||||
assert violations == []
|
||||
@@ -4,9 +4,12 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app import scheduler as scheduler_module
|
||||
from app.scheduler import Scheduler
|
||||
from app.application.configuration import SchedulerRuntimeConfig
|
||||
from app.scheduler import catalog as scheduler_catalog
|
||||
from app.scheduler import lifecycle as scheduler_lifecycle
|
||||
from app.scheduler import reconcile as scheduler_reconcile
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
from app.startup.initializers import scheduler as scheduler_initializer
|
||||
|
||||
|
||||
@@ -78,26 +81,15 @@ def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch):
|
||||
def test_clear_cache_is_manual_only(monkeypatch):
|
||||
"""缓存清理任务应仅手动执行,不注册到调度器自动运行。"""
|
||||
background_scheduler = _BackgroundSchedulerStub()
|
||||
generic_chain = Mock()
|
||||
for name in [
|
||||
"MediaServerChain",
|
||||
"RecommendChain",
|
||||
"SchedulerChain",
|
||||
"SiteChain",
|
||||
"SubscribeChain",
|
||||
"TransferChain",
|
||||
"WallpaperHelper",
|
||||
"WorkflowChain",
|
||||
"get_plugin_manager",
|
||||
]:
|
||||
monkeypatch.setattr(scheduler_module, name, lambda: generic_chain)
|
||||
services = Mock()
|
||||
monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock())
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_catalog,
|
||||
"get_mediaserver_configs",
|
||||
lambda **_kwargs: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_catalog,
|
||||
"BackgroundScheduler",
|
||||
lambda **kwargs: background_scheduler,
|
||||
)
|
||||
@@ -106,7 +98,7 @@ def test_clear_cache_is_manual_only(monkeypatch):
|
||||
monkeypatch.setattr(Scheduler, "init_agent_task_jobs", lambda self: None)
|
||||
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_lifecycle,
|
||||
"get_scheduler_runtime_config",
|
||||
lambda: SchedulerRuntimeConfig(
|
||||
False, "Asia/Shanghai", 1, False, "", 0, None, False, 24,
|
||||
@@ -120,13 +112,11 @@ def test_clear_cache_is_manual_only(monkeypatch):
|
||||
scheduler._lock = threading.RLock()
|
||||
scheduler._jobs = {}
|
||||
scheduler._lifecycle_state = "new"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
scheduler._agent_task_interruptions_reconciled = True
|
||||
scheduler._auth_count = 0
|
||||
scheduler._auth_message = False
|
||||
scheduler._services = services
|
||||
|
||||
scheduler.init()
|
||||
|
||||
@@ -148,28 +138,28 @@ def test_user_auth_refreshes_plugin_routes_after_runtime_reinitialization(monkey
|
||||
refresh_routes = Mock()
|
||||
message_chain = Mock()
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_reconcile,
|
||||
"get_scheduler_runtime_config",
|
||||
lambda: Mock(site_link="https://example.invalid"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_reconcile,
|
||||
"SitesHelper",
|
||||
lambda: Mock(auth_level=0, check_user=Mock(return_value=(True, "demo"))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_reconcile,
|
||||
"get_configured_system_config",
|
||||
lambda: Mock(get=Mock(return_value=None)),
|
||||
)
|
||||
monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain)
|
||||
scheduler._services = Mock(post_message=message_chain.post_message)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_reconcile,
|
||||
"get_plugin_manager",
|
||||
lambda: plugin_manager,
|
||||
)
|
||||
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
|
||||
monkeypatch.setattr(scheduler_module, "register_plugin_api", refresh_routes)
|
||||
monkeypatch.setattr(scheduler_reconcile, "register_plugin_api", refresh_routes)
|
||||
|
||||
scheduler.user_auth()
|
||||
|
||||
@@ -191,24 +181,24 @@ def test_user_auth_retries_pending_plugin_route_projection(monkeypatch):
|
||||
message_chain = Mock()
|
||||
sites = Mock(auth_level=0, check_user=Mock(return_value=(True, "demo")))
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_reconcile,
|
||||
"get_scheduler_runtime_config",
|
||||
lambda: Mock(site_link="https://example.invalid"),
|
||||
)
|
||||
monkeypatch.setattr(scheduler_module, "SitesHelper", lambda: sites)
|
||||
monkeypatch.setattr(scheduler_reconcile, "SitesHelper", lambda: sites)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_reconcile,
|
||||
"get_configured_system_config",
|
||||
lambda: Mock(get=Mock(return_value=None)),
|
||||
)
|
||||
monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain)
|
||||
scheduler._services = Mock(post_message=message_chain.post_message)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_reconcile,
|
||||
"get_plugin_manager",
|
||||
lambda: plugin_manager,
|
||||
)
|
||||
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
|
||||
monkeypatch.setattr(scheduler_module, "register_plugin_api", refresh_routes)
|
||||
monkeypatch.setattr(scheduler_reconcile, "register_plugin_api", refresh_routes)
|
||||
|
||||
with pytest.raises(RuntimeError, match="loop unavailable"):
|
||||
scheduler.user_auth()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Scheduler 旧插件 ABI 与 concrete 单例身份测试。"""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
from app.scheduler import Scheduler, SchedulerChain
|
||||
from app.scheduler.facade import Scheduler as ConcreteScheduler
|
||||
|
||||
|
||||
def test_scheduler_package_root_keeps_legacy_type_identity() -> None:
|
||||
"""旧静态与动态导入必须解析为同一个 concrete 类型。"""
|
||||
dynamic = getattr(importlib.import_module("app.scheduler"), "Scheduler")
|
||||
|
||||
assert Scheduler is ConcreteScheduler
|
||||
assert dynamic is ConcreteScheduler
|
||||
assert Scheduler.__module__ == "app.scheduler"
|
||||
assert SchedulerChain.__module__ == "app.scheduler"
|
||||
|
||||
|
||||
def test_scheduler_legacy_methods_keep_plugin_call_shapes() -> None:
|
||||
"""旧插件使用的四个方法继续保留原调用参数形态。"""
|
||||
start = inspect.signature(Scheduler.start)
|
||||
remove = inspect.signature(Scheduler.remove_plugin_job)
|
||||
|
||||
assert list(start.parameters) == ["self", "job_id", "args", "kwargs"]
|
||||
assert list(remove.parameters) == ["self", "pid", "job_id"]
|
||||
assert remove.parameters["job_id"].default is None
|
||||
assert callable(Scheduler.list)
|
||||
assert callable(Scheduler.update_plugin_job)
|
||||
|
||||
|
||||
def test_scheduler_legacy_constructor_reuses_single_state_owner() -> None:
|
||||
"""兼容构造必须复用同一 concrete 单例和私有状态桥。"""
|
||||
first = Scheduler()
|
||||
second = Scheduler()
|
||||
|
||||
assert first is second
|
||||
assert first._jobs is second._jobs
|
||||
assert first._lock is second._lock
|
||||
assert first._scheduler is second._scheduler
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Scheduler 声明、catalog 与 execution state 测试。"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -5,12 +5,15 @@ import gc
|
||||
import inspect
|
||||
import threading
|
||||
import warnings
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app import scheduler as scheduler_module
|
||||
from app.runtime.config import global_vars
|
||||
from app.scheduler import Scheduler
|
||||
from app.scheduler import bridge as scheduler_bridge
|
||||
from app.scheduler import execution as scheduler_execution
|
||||
from app.scheduler import progress as scheduler_progress
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
|
||||
|
||||
class _ProgressStub:
|
||||
@@ -47,6 +50,26 @@ class _AsyncProgressStub:
|
||||
"""记录终态但不访问外部缓存。"""
|
||||
|
||||
|
||||
def _patch_progress(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async_progress: type = _AsyncProgressStub,
|
||||
) -> None:
|
||||
"""替换执行启动与进度 owner 各自持有的缓存边界。"""
|
||||
monkeypatch.setattr(scheduler_execution, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_progress, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_progress, "AsyncProgressHelper", async_progress)
|
||||
|
||||
|
||||
def _patch_main_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
"""让执行与桥接 owner 共享同一个测试事件循环登记。"""
|
||||
registry = SimpleNamespace(current=loop)
|
||||
monkeypatch.setattr(scheduler_execution, "main_loop_registry", registry)
|
||||
monkeypatch.setattr(scheduler_bridge, "main_loop_registry", registry)
|
||||
|
||||
|
||||
def _scheduler(job_id: str, func) -> Scheduler:
|
||||
"""构造已启动但不拥有 APScheduler 线程的实例。"""
|
||||
scheduler = object.__new__(Scheduler)
|
||||
@@ -63,10 +86,8 @@ def _scheduler(job_id: str, func) -> Scheduler:
|
||||
}
|
||||
}
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {job_id: 1}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
scheduler._registry.assign_generation(job_id, scheduler._jobs[job_id])
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -91,20 +112,19 @@ async def test_stop_async_cancels_and_awaits_scheduler_owned_job(monkeypatch) ->
|
||||
finally:
|
||||
cleaned.set()
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||
_patch_progress(monkeypatch)
|
||||
scheduler = _scheduler("lifecycle-job", job)
|
||||
|
||||
assert scheduler.start("lifecycle-job") is True
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
assert scheduler._handles
|
||||
assert scheduler._registry.handles()
|
||||
|
||||
await scheduler.stop_async()
|
||||
|
||||
assert cleaned.is_set()
|
||||
assert scheduler._jobs["lifecycle-job"]["running"] is False
|
||||
assert scheduler._jobs["lifecycle-job"]["last_error"] == "任务已取消"
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
assert scheduler._lifecycle_state == "stopped"
|
||||
|
||||
|
||||
@@ -125,12 +145,7 @@ async def test_stop_during_final_progress_does_not_mark_completed_job_unsubmitte
|
||||
async def job() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
"AsyncProgressHelper",
|
||||
BlockingFinishProgress,
|
||||
)
|
||||
_patch_progress(monkeypatch, BlockingFinishProgress)
|
||||
scheduler = _scheduler("final-progress-stop", job)
|
||||
|
||||
assert scheduler.start("final-progress-stop") is True
|
||||
@@ -140,19 +155,18 @@ async def test_stop_during_final_progress_does_not_mark_completed_job_unsubmitte
|
||||
|
||||
assert scheduler._jobs["final-progress-stop"]["running"] is False
|
||||
assert scheduler._jobs["final-progress-stop"]["last_error"] is None
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._active_job_generations == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
assert scheduler._registry.is_active("final-progress-stop") is False
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
"AsyncProgressHelper",
|
||||
_AsyncProgressStub,
|
||||
)
|
||||
_patch_progress(monkeypatch)
|
||||
scheduler._lifecycle_state = "running"
|
||||
assert scheduler.start("final-progress-stop") is True
|
||||
|
||||
async def wait_until_finished() -> None:
|
||||
while scheduler._handles or scheduler._active_job_generations:
|
||||
while (
|
||||
scheduler._registry.handles()
|
||||
or scheduler._registry.is_active("final-progress-stop")
|
||||
):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_finished(), timeout=1)
|
||||
@@ -180,9 +194,8 @@ async def test_foreign_loop_submission_runs_on_main_loop_and_finishes_before_sto
|
||||
await cleanup_release.wait()
|
||||
raise
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
|
||||
_patch_progress(monkeypatch)
|
||||
_patch_main_loop(monkeypatch, main_loop)
|
||||
scheduler = _scheduler("foreign-loop-job", job)
|
||||
|
||||
def submit_from_foreign_loop() -> bool:
|
||||
@@ -204,18 +217,18 @@ async def test_foreign_loop_submission_runs_on_main_loop_and_finishes_before_sto
|
||||
stop_task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await stop_task
|
||||
assert scheduler._handles
|
||||
assert scheduler._registry.handles()
|
||||
assert scheduler._lifecycle_state == "stopping"
|
||||
|
||||
cleanup_release.set()
|
||||
|
||||
async def wait_until_released() -> None:
|
||||
while scheduler._handles:
|
||||
while scheduler._registry.handles():
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||
await scheduler.stop_async()
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
assert scheduler._lifecycle_state == "stopped"
|
||||
|
||||
|
||||
@@ -231,9 +244,8 @@ async def test_cross_thread_submission_is_registered_before_stop_snapshot(
|
||||
async def job() -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
|
||||
_patch_progress(monkeypatch)
|
||||
_patch_main_loop(monkeypatch, main_loop)
|
||||
scheduler = _scheduler("atomic-submit", job)
|
||||
register_handle = scheduler._register_handle
|
||||
|
||||
@@ -287,12 +299,12 @@ async def test_submit_to_loop_tracks_internal_progress_or_finish_tasks() -> None
|
||||
)
|
||||
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
assert len(scheduler._handles) == 1
|
||||
assert len(scheduler._registry.handles()) == 1
|
||||
|
||||
await scheduler.stop_async()
|
||||
|
||||
assert cancelled.is_set()
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -331,9 +343,8 @@ async def test_sync_job_callback_and_finish_handles_are_owned(monkeypatch) -> No
|
||||
async def end(self, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", BlockingProgress)
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", asyncio.get_running_loop())
|
||||
_patch_progress(monkeypatch, BlockingProgress)
|
||||
_patch_main_loop(monkeypatch, asyncio.get_running_loop())
|
||||
|
||||
def job(progress_callback) -> None:
|
||||
progress_callback(value=50)
|
||||
@@ -343,13 +354,13 @@ async def test_sync_job_callback_and_finish_handles_are_owned(monkeypatch) -> No
|
||||
await asyncio.wait_for(update_started.wait(), timeout=1)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(scheduler._handles) == 2
|
||||
assert len(scheduler._registry.handles()) == 2
|
||||
assert not finish_started.is_set()
|
||||
|
||||
await scheduler.stop_async()
|
||||
|
||||
assert cancelled == 1
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -364,10 +375,10 @@ async def test_stale_progress_cannot_update_replaced_job(monkeypatch) -> None:
|
||||
async def update(self, **kwargs) -> None:
|
||||
updates.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", RecordingProgress)
|
||||
monkeypatch.setattr(scheduler_progress, "AsyncProgressHelper", RecordingProgress)
|
||||
scheduler = _scheduler("generation-progress", lambda: None)
|
||||
old_job = scheduler._jobs["generation-progress"]
|
||||
callback = scheduler._Scheduler__build_progress_callback(
|
||||
callback = scheduler._build_progress_callback(
|
||||
"generation-progress",
|
||||
old_job,
|
||||
)
|
||||
@@ -383,7 +394,7 @@ async def test_stale_progress_cannot_update_replaced_job(monkeypatch) -> None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert updates == []
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -409,8 +420,7 @@ async def test_final_progress_waits_for_pending_update(monkeypatch) -> None:
|
||||
async def job(progress_callback) -> None:
|
||||
progress_callback(value=100, text="业务处理完成")
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", BlockingProgress)
|
||||
_patch_progress(monkeypatch, BlockingProgress)
|
||||
scheduler = _scheduler("progress-order", job)
|
||||
|
||||
assert scheduler.start("progress-order") is True
|
||||
@@ -451,14 +461,15 @@ async def test_replaced_job_keeps_active_state_without_stale_progress(monkeypatc
|
||||
async def get(self):
|
||||
return detail
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", RecordingProgress)
|
||||
monkeypatch.setattr(scheduler_execution, "ProgressHelper", RecordingProgress)
|
||||
monkeypatch.setattr(scheduler_progress, "ProgressHelper", RecordingProgress)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
scheduler_progress,
|
||||
"AsyncProgressHelper",
|
||||
RecordingAsyncProgress,
|
||||
)
|
||||
scheduler = _scheduler("generation-cache", lambda: None)
|
||||
old_job = scheduler._Scheduler__prepare_job("generation-cache")
|
||||
old_job = scheduler._prepare_job("generation-cache")
|
||||
assert old_job is not None
|
||||
assert detail["data"]["_generation"] == 1
|
||||
|
||||
@@ -486,7 +497,7 @@ async def test_replaced_job_keeps_active_state_without_stale_progress(monkeypatc
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_generation_cannot_finish_replaced_job(monkeypatch) -> None:
|
||||
"""旧 generation 收尾不得改写同 ID 的新任务状态或进度。"""
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||
monkeypatch.setattr(scheduler_progress, "AsyncProgressHelper", _AsyncProgressStub)
|
||||
scheduler = _scheduler("generation-job", lambda: None)
|
||||
old_job = scheduler._jobs["generation-job"]
|
||||
old_job["running"] = True
|
||||
@@ -498,7 +509,7 @@ async def test_stale_generation_cannot_finish_replaced_job(monkeypatch) -> None:
|
||||
}
|
||||
scheduler._jobs["generation-job"] = new_job
|
||||
|
||||
await scheduler._Scheduler__finish_job(
|
||||
await scheduler._finish_job(
|
||||
job_id="generation-job",
|
||||
job=old_job,
|
||||
generation=1,
|
||||
@@ -539,7 +550,7 @@ def test_agent_task_manual_start_has_single_reservation() -> None:
|
||||
|
||||
assert second is False
|
||||
assert results == [True]
|
||||
assert scheduler._agent_task_reservations == {}
|
||||
assert scheduler._registry.reservation_owner("agent-task-1") is None
|
||||
|
||||
|
||||
def test_scheduler_rejects_new_submission_after_stop() -> None:
|
||||
@@ -624,8 +635,7 @@ async def test_config_reload_preserves_overlap_guard_across_job_generations(
|
||||
await release.wait()
|
||||
finished.set()
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||
_patch_progress(monkeypatch)
|
||||
scheduler = _scheduler("reload-overlap", job)
|
||||
|
||||
class ActiveScheduler:
|
||||
@@ -666,20 +676,26 @@ async def test_config_reload_preserves_overlap_guard_across_job_generations(
|
||||
assert listed[0].status == "正在运行"
|
||||
assert scheduler.start("reload-overlap") is False
|
||||
assert run_count == 1
|
||||
assert len(scheduler._handles) == 1
|
||||
assert len(scheduler._registry.handles()) == 1
|
||||
|
||||
release.set()
|
||||
await asyncio.wait_for(finished.wait(), timeout=1)
|
||||
|
||||
async def wait_until_released() -> None:
|
||||
while scheduler._active_job_generations or scheduler._handles:
|
||||
while (
|
||||
scheduler._registry.is_active("reload-overlap")
|
||||
or scheduler._registry.handles()
|
||||
):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||
assert scheduler.start("reload-overlap") is True
|
||||
|
||||
async def wait_until_second_run_finishes() -> None:
|
||||
while scheduler._active_job_generations or scheduler._handles:
|
||||
while (
|
||||
scheduler._registry.is_active("reload-overlap")
|
||||
or scheduler._registry.handles()
|
||||
):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_second_run_finishes(), timeout=1)
|
||||
@@ -698,27 +714,27 @@ def test_stop_between_prepare_and_submission_releases_active_generation(
|
||||
calls += 1
|
||||
|
||||
scheduler._jobs["stop-race"]["func"] = job
|
||||
original_prepare = scheduler._Scheduler__prepare_job
|
||||
original_prepare = scheduler._prepare_job
|
||||
|
||||
def prepare_then_stop(job_id: str):
|
||||
prepared = original_prepare(job_id)
|
||||
scheduler._begin_stop()
|
||||
return prepared
|
||||
|
||||
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", prepare_then_stop)
|
||||
monkeypatch.setattr(scheduler, "_prepare_job", prepare_then_stop)
|
||||
|
||||
assert scheduler.start("stop-race") is False
|
||||
assert calls == 0
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._active_job_generations == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
assert scheduler._registry.is_active("stop-race") is False
|
||||
assert scheduler._jobs["stop-race"]["running"] is False
|
||||
assert scheduler._jobs["stop-race"]["last_error"] == "任务未提交"
|
||||
|
||||
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", original_prepare)
|
||||
monkeypatch.setattr(scheduler, "_prepare_job", original_prepare)
|
||||
scheduler._lifecycle_state = "running"
|
||||
assert scheduler.start("stop-race") is True
|
||||
assert calls == 1
|
||||
assert scheduler._active_job_generations == {}
|
||||
assert scheduler._registry.is_active("stop-race") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -737,8 +753,8 @@ async def test_cross_thread_rejection_closes_unstarted_business_coroutine(
|
||||
calls += 1
|
||||
|
||||
scheduler._jobs["cross-thread-stop-race"]["func"] = job
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
|
||||
original_prepare = scheduler._Scheduler__prepare_job
|
||||
_patch_main_loop(monkeypatch, main_loop)
|
||||
original_prepare = scheduler._prepare_job
|
||||
|
||||
def prepare_then_wait(job_id: str):
|
||||
result = original_prepare(job_id)
|
||||
@@ -746,7 +762,7 @@ async def test_cross_thread_rejection_closes_unstarted_business_coroutine(
|
||||
release.wait(timeout=1)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", prepare_then_wait)
|
||||
monkeypatch.setattr(scheduler, "_prepare_job", prepare_then_wait)
|
||||
|
||||
with warnings.catch_warnings(record=True) as captured:
|
||||
warnings.simplefilter("always", RuntimeWarning)
|
||||
@@ -760,8 +776,8 @@ async def test_cross_thread_rejection_closes_unstarted_business_coroutine(
|
||||
gc.collect()
|
||||
|
||||
assert calls == 0
|
||||
assert scheduler._active_job_generations == {}
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.is_active("cross-thread-stop-race") is False
|
||||
assert scheduler._registry.handles() == ()
|
||||
assert not any("was never awaited" in str(item.message) for item in captured)
|
||||
|
||||
|
||||
@@ -795,13 +811,13 @@ def test_cancelled_cross_thread_proxy_waits_for_target_loop_cleanup(
|
||||
calls += 1
|
||||
|
||||
scheduler._jobs["cancel-before-start"]["func"] = business
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", target_loop)
|
||||
_patch_main_loop(monkeypatch, target_loop)
|
||||
|
||||
with warnings.catch_warnings(record=True) as captured:
|
||||
warnings.simplefilter("always", RuntimeWarning)
|
||||
try:
|
||||
assert scheduler.start("cancel-before-start") is True
|
||||
scheduler_handle = next(iter(scheduler._handles.values()))
|
||||
scheduler_handle = scheduler._registry.handles()[0]
|
||||
scheduler._cancel_handle(scheduler_handle)
|
||||
assert not scheduler_handle.completion.done()
|
||||
|
||||
@@ -817,6 +833,6 @@ def test_cancelled_cross_thread_proxy_waits_for_target_loop_cleanup(
|
||||
|
||||
assert calls == 0
|
||||
assert loop_errors == []
|
||||
assert scheduler._active_job_generations == {}
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.is_active("cancel-before-start") is False
|
||||
assert scheduler._registry.handles() == ()
|
||||
assert not any("was never awaited" in str(item.message) for item in captured)
|
||||
|
||||
@@ -5,7 +5,8 @@ from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.scheduler import Scheduler
|
||||
from app.scheduler.facade import Scheduler
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -31,10 +32,7 @@ def _build_scheduler(job_id, func):
|
||||
}
|
||||
}
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -77,7 +75,7 @@ def test_scheduler_failure_preserves_last_progress(monkeypatch):
|
||||
scheduler = _build_scheduler(job_id, task)
|
||||
monkeypatch.setattr(
|
||||
scheduler,
|
||||
"_Scheduler__handle_job_error",
|
||||
"_handle_job_error",
|
||||
lambda **kwargs: None,
|
||||
)
|
||||
|
||||
@@ -147,7 +145,10 @@ def test_scheduler_runs_async_job_from_current_event_loop(monkeypatch):
|
||||
|
||||
async def wait_until_finished() -> None:
|
||||
"""等待任务及其异步进度句柄全部收敛。"""
|
||||
while scheduler._handles or scheduler._active_job_generations:
|
||||
while (
|
||||
scheduler._registry.handles()
|
||||
or scheduler._registry.is_active(job_id)
|
||||
):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_finished(), timeout=1)
|
||||
@@ -176,9 +177,9 @@ def test_scheduler_records_cancelled_async_job_as_failed():
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def run_task():
|
||||
job = scheduler._Scheduler__prepare_job(job_id)
|
||||
job = scheduler._prepare_job(job_id)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await scheduler._Scheduler__run_coro_job(task, job_id, job)
|
||||
await scheduler._run_coro_job(task, job_id, job)
|
||||
|
||||
scheduler = _build_scheduler(job_id, task)
|
||||
asyncio.run(run_task())
|
||||
@@ -211,13 +212,13 @@ def test_scheduler_stop_async_cancels_owned_async_jobs():
|
||||
"""在当前事件循环启动并收口异步作业。"""
|
||||
scheduler.start(job_id)
|
||||
await started.wait()
|
||||
assert len(scheduler._handles) == 1
|
||||
assert len(scheduler._registry.handles()) == 1
|
||||
await scheduler.stop_async()
|
||||
|
||||
asyncio.run(run_task())
|
||||
|
||||
assert cancelled.is_set()
|
||||
assert scheduler._handles == {}
|
||||
assert scheduler._registry.handles() == ()
|
||||
|
||||
|
||||
def test_scheduler_returns_none_for_unknown_job():
|
||||
@@ -227,9 +228,6 @@ def test_scheduler_returns_none_for_unknown_job():
|
||||
scheduler._lock = threading.RLock()
|
||||
scheduler._jobs = {}
|
||||
scheduler._lifecycle_state = "running"
|
||||
scheduler._handles = {}
|
||||
scheduler._job_generations = {}
|
||||
scheduler._active_job_generations = {}
|
||||
scheduler._agent_task_reservations = {}
|
||||
scheduler._registry = ExecutionRegistry(scheduler._lock)
|
||||
|
||||
assert scheduler.get_progress(job_id) is None
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Scheduler ExecutionRegistry 的状态所有权与原子操作测试。"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
|
||||
from app.scheduler.registry import ExecutionRegistry
|
||||
|
||||
|
||||
def test_registry_assigns_monotonic_generations() -> None:
|
||||
"""同 ID generation 单调递增,不同任务分别计数。"""
|
||||
registry = ExecutionRegistry()
|
||||
state: dict[str, object] = {}
|
||||
|
||||
assert registry.assign_generation("job", state) == 1
|
||||
assert state["_generation"] == 1
|
||||
assert registry.next_generation("job") == 2
|
||||
assert registry.next_generation("other") == 1
|
||||
assert registry.current_generation("job") == 2
|
||||
assert registry.current_generation("missing") == 0
|
||||
|
||||
|
||||
def test_registry_allocates_unique_generations_across_threads() -> None:
|
||||
"""并发分配同 ID 时每个调用都必须取得唯一 generation。"""
|
||||
registry = ExecutionRegistry()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
|
||||
generations = list(
|
||||
executor.map(lambda _index: registry.next_generation("job"), range(64))
|
||||
)
|
||||
|
||||
assert sorted(generations) == list(range(1, 65))
|
||||
|
||||
|
||||
def test_registry_claims_and_releases_one_active_generation() -> None:
|
||||
"""旧 generation 活跃期间拒绝同 ID 新 generation,并精确释放 owner。"""
|
||||
registry = ExecutionRegistry()
|
||||
|
||||
assert registry.claim_generation("job", 1) is True
|
||||
assert registry.claim_generation("job", 2) is False
|
||||
assert registry.is_active("job") is True
|
||||
assert registry.active_generations("job") == frozenset({1})
|
||||
assert registry.release_generation("job", 2) is False
|
||||
assert registry.release_generation("job", 1) is True
|
||||
assert registry.is_active("job") is False
|
||||
|
||||
|
||||
def test_registry_reservation_preserves_owner_identity() -> None:
|
||||
"""预约只能由原 owner 消费或释放,且活跃任务不能再次预约。"""
|
||||
registry = ExecutionRegistry()
|
||||
|
||||
assert registry.reserve("job", owner=11) is True
|
||||
assert registry.reserve("job", owner=12) is False
|
||||
assert registry.reservation_owner("job") == 11
|
||||
assert registry.consume_reservation("job", owner=12) is False
|
||||
assert registry.release_reservation("job", owner=12) is False
|
||||
assert registry.consume_reservation("job", owner=11) is True
|
||||
assert registry.consume_reservation("job", owner=12) is True
|
||||
|
||||
assert registry.claim_generation("job", 1) is True
|
||||
assert registry.reserve("job", owner=11) is False
|
||||
|
||||
|
||||
def test_registry_filters_and_removes_handles_by_completion_identity() -> None:
|
||||
"""句柄查询按 owner 字段过滤,摘除键使用真实完成信号身份。"""
|
||||
registry = ExecutionRegistry()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
submitted: concurrent.futures.Future[object] = concurrent.futures.Future()
|
||||
completed: concurrent.futures.Future[object] = concurrent.futures.Future()
|
||||
progress: concurrent.futures.Future[object] = concurrent.futures.Future()
|
||||
job_handle = registry.register_handle(
|
||||
job_id="job",
|
||||
generation=1,
|
||||
loop=loop,
|
||||
handle=submitted,
|
||||
completion=completed,
|
||||
)
|
||||
progress_handle = registry.register_handle(
|
||||
job_id="job",
|
||||
generation=1,
|
||||
loop=loop,
|
||||
handle=progress,
|
||||
kind="progress",
|
||||
)
|
||||
|
||||
assert registry.handles(job_id="job", generation=1) == (
|
||||
job_handle,
|
||||
progress_handle,
|
||||
)
|
||||
assert registry.handles(job_id="job", kind="progress") == (
|
||||
progress_handle,
|
||||
)
|
||||
assert registry.remove_handle(submitted) is False
|
||||
assert registry.remove_handle(completed) is True
|
||||
assert registry.handles() == (progress_handle,)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_registry_stop_snapshot_clears_reservations_but_retains_handles() -> None:
|
||||
"""停止快照封存当前句柄并清理尚未消费的手动预约。"""
|
||||
registry = ExecutionRegistry()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
completion: concurrent.futures.Future[object] = concurrent.futures.Future()
|
||||
handle = registry.register_handle(
|
||||
job_id="job",
|
||||
generation=1,
|
||||
loop=loop,
|
||||
handle=completion,
|
||||
)
|
||||
assert registry.reserve("manual", owner=11) is True
|
||||
|
||||
assert registry.stop_snapshot() == (handle,)
|
||||
assert registry.reservation_owner("manual") is None
|
||||
assert registry.handles() == (handle,)
|
||||
assert registry.remove_handle(completion) is True
|
||||
assert registry.handles() == ()
|
||||
finally:
|
||||
loop.close()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""插件调度 SDK 的公开面测试。"""
|
||||
|
||||
import app.sdk.scheduler as scheduler_sdk
|
||||
|
||||
|
||||
def test_scheduler_sdk_exposes_only_narrow_service_contracts() -> None:
|
||||
"""新插件 SDK 不得泄漏 concrete Scheduler 或其可变运行状态。"""
|
||||
assert set(scheduler_sdk.__all__) == {
|
||||
"ScheduleInfo",
|
||||
"ScheduleProgress",
|
||||
"get_agent_task_next_run",
|
||||
"list_scheduler_jobs",
|
||||
"remove_agent_task_job",
|
||||
"remove_plugin_job",
|
||||
"start_agent_task",
|
||||
"start_scheduler_job",
|
||||
"update_agent_task_job",
|
||||
"update_plugin_job",
|
||||
}
|
||||
assert not hasattr(scheduler_sdk, "Scheduler")
|
||||
assert not hasattr(scheduler_sdk, "BackgroundScheduler")
|
||||
assert not hasattr(scheduler_sdk, "_jobs")
|
||||
@@ -17,7 +17,7 @@ def test_host_runtime_consumers_use_application_facades() -> None:
|
||||
def test_service_locator_gate_detects_each_concrete_runtime_family() -> None:
|
||||
"""单一扫描器必须覆盖五类运行时,并保留明确兼容边界。"""
|
||||
graph = {
|
||||
"app.api.scheduler_bypass": {"app.scheduler"},
|
||||
"app.api.scheduler_bypass": {"app.scheduler.facade"},
|
||||
"app.api.module_bypass": {"app.runtime.extensions.module_manager"},
|
||||
"app.api.plugin_bypass": {"app.runtime.extensions.plugin_manager"},
|
||||
"app.api.command_bypass": {"app.command"},
|
||||
@@ -30,7 +30,7 @@ def test_service_locator_gate_detects_each_concrete_runtime_family() -> None:
|
||||
"app.command",
|
||||
"app.runtime.extensions.module_manager",
|
||||
"app.runtime.extensions.plugin_manager",
|
||||
"app.scheduler",
|
||||
"app.scheduler.facade",
|
||||
},
|
||||
"app.workflow.manager": {"app.workflow"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user