refactor: govern background tasks and query ownership

This commit is contained in:
jxxghp
2026-08-23 13:24:04 +08:00
parent 43c173a0e7
commit f1e542bef0
37 changed files with 1570 additions and 510 deletions
+29 -2
View File
@@ -17,6 +17,16 @@ class ModuleResultAggregation(StrEnum):
ORDERED_LIST_MERGE = "ordered_list_merge"
class ModuleResultShape(StrEnum):
"""描述模块 provider 返回值的基础 Python 形状。"""
ANY = "any"
LIST = "list"
STRING = "string"
MAPPING = "mapping"
BOOLEAN = "boolean"
class ModuleExecutionMode(StrEnum):
"""描述 provider 可以采用的执行形态。"""
@@ -45,6 +55,7 @@ class ModuleMethodContract:
version: int = 1
input_contract: str = "legacy_args"
result_contract: str = "Any"
result_shape: ModuleResultShape = ModuleResultShape.ANY
required_parameters: tuple[str, ...] = ()
execution: ModuleExecutionMode = ModuleExecutionMode.SYNC_OR_ASYNC
timeout_policy: str = "caller_budget"
@@ -74,11 +85,11 @@ _METHOD_CONTRACTS = {
"media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"),
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "library_id", "start_index", "limit")),
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "item_id")),
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path")),
"upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path", "new_name")),
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("fileitem", "recursion")),
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("fileitem", "recursion")),
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
"get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem",)),
@@ -455,6 +466,22 @@ def diagnose_module_callable(method: str, callback: Callable[..., Any]) -> tuple
return tuple(f"missing-parameter:{name}" for name in missing)
def diagnose_module_result(method: str, result: Any) -> tuple[str, ...]:
"""诊断显式模块结果的基础形状,兼容阶段只告警而不改写返回值。"""
shape = get_module_method_contract(method).result_shape
if shape is ModuleResultShape.ANY or result is None:
return ()
matches = {
ModuleResultShape.LIST: isinstance(result, list),
ModuleResultShape.STRING: isinstance(result, str),
ModuleResultShape.MAPPING: isinstance(result, dict),
ModuleResultShape.BOOLEAN: isinstance(result, bool),
}
if matches.get(shape, True):
return ()
return (f"unexpected-result:{shape.value}:{type(result).__name__}",)
def list_explicit_module_contracts() -> dict[str, ModuleMethodContract]:
"""返回显式方法清单的副本,供架构基线和 SDK 文档使用。"""
return dict(_METHOD_CONTRACTS)
@@ -12,6 +12,7 @@ from app.runtime.log import logger
from app.runtime.observability import observe_duration, record_metric
from app.runtime.extensions.module.contracts import (
diagnose_module_callable,
diagnose_module_result,
get_module_method_contract,
is_explicit_module_method,
)
@@ -134,8 +135,10 @@ class ModuleInvocationDispatcher:
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
if self.is_valid_empty(result):
result = func(*args, **kwargs)
self._diagnose_result(method, result, "plugin")
elif isinstance(result, list):
temp = func(*args, **kwargs)
self._diagnose_result(method, temp, "plugin")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -187,8 +190,10 @@ class ModuleInvocationDispatcher:
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
if self.is_valid_empty(result):
result = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, result, "plugin")
elif isinstance(result, list):
temp = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, temp, "plugin")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -238,10 +243,13 @@ class ModuleInvocationDispatcher:
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
if self.is_valid_empty(result):
result = func(*args, **kwargs)
self._diagnose_result(method, result, "system")
elif ObjectUtils.check_signature(func, result):
result = func(result)
self._diagnose_result(method, result, "system")
elif isinstance(result, list):
temp = func(*args, **kwargs)
self._diagnose_result(method, temp, "system")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -291,10 +299,13 @@ class ModuleInvocationDispatcher:
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
if self.is_valid_empty(result):
result = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, result, "system")
elif ObjectUtils.check_signature(func, result):
result = await self._async_call(func, result)
self._diagnose_result(method, result, "system")
elif isinstance(result, list):
temp = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, temp, "system")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -360,6 +371,24 @@ class ModuleInvocationDispatcher:
", ".join(problems),
)
@staticmethod
def _diagnose_result(method: str, result: Any, provider_type: str) -> None:
"""记录 provider 结果形状偏差,保持旧插件返回值原样继续执行。"""
problems = diagnose_module_result(method, result)
if problems:
record_metric(
"module.contract.result_mismatch",
method=method,
provider_type=provider_type,
problem=problems[0],
)
logger.warning(
"模块方法 %s%s provider 返回值与契约不一致:%s;当前仅诊断",
method,
provider_type,
", ".join(problems),
)
async def _async_call(
self,
func: Callable[..., Any],
+132
View File
@@ -0,0 +1,132 @@
"""进程内后台任务登记与生命周期收口。"""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from dataclasses import dataclass
from functools import partial
from typing import Any, Callable
@dataclass(frozen=True, slots=True)
class TaskRecord:
"""记录一个后台任务的所有者,便于关停阶段按责任域收口。"""
owner: str
task: asyncio.Task[Any]
cancel_on_shutdown: bool
class TaskRegistry:
"""管理由宿主创建的进程内后台任务,并提供统一取消与等待入口。"""
def __init__(self) -> None:
"""初始化空任务登记表。"""
self._records: dict[asyncio.Task[Any], TaskRecord] = {}
self._accepting = True
@property
def records(self) -> tuple[TaskRecord, ...]:
"""返回当前仍未完成的任务快照。"""
return tuple(
record for record in self._records.values() if not record.task.done()
)
def create(
self,
coroutine: Coroutine[Any, Any, Any],
*,
owner: str,
cancel_on_shutdown: bool = True,
) -> asyncio.Task[Any]:
"""创建并登记后台任务,任务完成后自动从登记表移除。"""
if not self._accepting:
coroutine.close()
raise RuntimeError("后台任务登记器正在关闭,不能再创建新任务")
task = asyncio.create_task(coroutine, name=owner)
self.register(
task,
owner=owner,
cancel_on_shutdown=cancel_on_shutdown,
)
return task
def create_sync(
self,
function: Callable[..., Any],
*args: Any,
owner: str,
**kwargs: Any,
) -> asyncio.Task[Any]:
"""在线程池执行同步后台函数并登记其异步生命周期。"""
return self.create(
asyncio.to_thread(partial(function, *args, **kwargs)),
owner=owner,
cancel_on_shutdown=False,
)
def register(
self,
task: asyncio.Task[Any],
*,
owner: str,
cancel_on_shutdown: bool = True,
) -> asyncio.Task[Any]:
"""登记已有任务并绑定责任域。"""
if not self._accepting:
task.cancel()
raise RuntimeError("后台任务登记器正在关闭,不能再登记新任务")
task.set_name(owner)
self._records[task] = TaskRecord(
owner=owner,
task=task,
cancel_on_shutdown=cancel_on_shutdown,
)
task.add_done_callback(self._discard)
return task
def _discard(self, task: asyncio.Task[Any]) -> None:
"""移除已结束任务,并把未处理异常交给事件循环统一报告。"""
record = self._records.pop(task, None)
if task.cancelled():
return
exception = task.exception()
if exception is not None:
task.get_loop().call_exception_handler(
{
"message": "MoviePilot 后台任务执行失败",
"exception": exception,
"task": task,
"owner": record.owner if record else task.get_name(),
}
)
async def shutdown(self, *, timeout_seconds: float = 10.0) -> None:
"""取消并等待全部登记任务,超时后放弃等待但不影响其他关闭步骤。"""
self._accepting = False
records = self.records
tasks = [record.task for record in records]
for record in records:
if record.cancel_on_shutdown:
record.task.cancel()
if tasks:
_, pending = await asyncio.wait(tasks, timeout=timeout_seconds)
for task in pending:
task.cancel()
self._records.clear()
_default_registry = TaskRegistry()
_runtime_registry: TaskRegistry | None = None
def configure_task_registry(registry: TaskRegistry | None) -> None:
"""由启动组合根发布当前 lifespan 的任务登记器。"""
global _runtime_registry
_runtime_registry = registry
def get_task_registry() -> TaskRegistry:
"""返回当前宿主任务登记器,未启动完整 lifespan 时保留测试兼容回退。"""
return _runtime_registry or _default_registry