mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 12:36:55 +08:00
refactor: unify protected task completion
This commit is contained in:
Vendored
+2
-8
@@ -45,6 +45,7 @@ from app.adapters.system.plugin.manifest import (
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.observability import observe_compat_facade
|
||||
from app.runtime.execution import (
|
||||
await_task_to_terminal,
|
||||
run_in_threadpool_to_completion as _await_thread_operation,
|
||||
)
|
||||
from app.runtime.tasks import get_task_registry
|
||||
@@ -1499,15 +1500,8 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
await asyncio.to_thread(created_file.unlink, missing_ok=True)
|
||||
|
||||
cleanup_task = asyncio.create_task(cleanup_created_file())
|
||||
while not cleanup_task.done():
|
||||
try:
|
||||
await asyncio.shield(cleanup_task)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
try:
|
||||
await cleanup_task
|
||||
await await_task_to_terminal(cleanup_task)
|
||||
except Exception as err:
|
||||
logger.warning(f"[UV] 取消后清理运行环境约束文件失败:{err}")
|
||||
raise
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, ContextManager, Optional
|
||||
|
||||
from app.schemas.exception import PersistenceUnavailableError
|
||||
from app.application.plugin.lifecycle import plugin_lifecycle
|
||||
from app.runtime.execution import await_task_to_terminal
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.exception import PluginMutationRejectedError
|
||||
|
||||
@@ -29,17 +30,6 @@ PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
|
||||
PluginMutationAdmission = Callable[[str], ContextManager[None]]
|
||||
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
|
||||
|
||||
|
||||
async def _await_task_to_terminal(task: asyncio.Task[Any]) -> Any:
|
||||
"""忽略调用方的重复取消,直到受保护子任务进入真实终态。"""
|
||||
while not task.done():
|
||||
try:
|
||||
return await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
return task.result()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallRollback:
|
||||
"""描述失败安装中各类可补偿副作用的恢复结果。"""
|
||||
@@ -193,7 +183,7 @@ class PluginInstallCommand:
|
||||
state.checkpoint = checkpoint
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
state.checkpoint = await _await_task_to_terminal(checkpoint_task)
|
||||
state.checkpoint = await await_task_to_terminal(checkpoint_task)
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
@@ -369,7 +359,7 @@ class PluginInstallCommand:
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await _await_task_to_terminal(rollback_task)
|
||||
result = await await_task_to_terminal(rollback_task)
|
||||
except BaseException as err:
|
||||
logger.error(f"插件 {plugin_id} 取消后的补偿失败:{err}")
|
||||
return
|
||||
@@ -410,12 +400,7 @@ class PluginInstallCommand:
|
||||
cleanup_task = asyncio.create_task(
|
||||
self._restore_refreshed_runtime(plugin_id)
|
||||
)
|
||||
while not cleanup_task.done():
|
||||
try:
|
||||
await asyncio.shield(cleanup_task)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
rollback = await cleanup_task
|
||||
rollback = await await_task_to_terminal(cleanup_task)
|
||||
state.refresh_compensated = True
|
||||
if rollback.errors:
|
||||
logger.error(
|
||||
|
||||
@@ -3,12 +3,29 @@ import inspect
|
||||
import time
|
||||
from contextvars import copy_context
|
||||
from functools import partial, wraps
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from app.schemas.exception import ImmediateException
|
||||
from anyio.to_thread import run_sync
|
||||
|
||||
|
||||
TaskResult = TypeVar("TaskResult")
|
||||
|
||||
|
||||
async def await_task_to_terminal(
|
||||
task: asyncio.Future[TaskResult],
|
||||
) -> TaskResult:
|
||||
"""忽略当前调用方的重复取消,直到受保护任务进入真实终态。"""
|
||||
while not task.done():
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except BaseException:
|
||||
break
|
||||
return task.result()
|
||||
|
||||
|
||||
async def run_in_threadpool(
|
||||
func: Callable[..., Any],
|
||||
*args: Any,
|
||||
|
||||
@@ -102,6 +102,8 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同
|
||||
- 市场适配器和插件包适配器统一复用 `runtime.execution.run_in_threadpool_to_completion`;两个模块内的
|
||||
`_await_thread_operation` 私有接缝保留为同一函数别名,不改变 `PluginHelper` 或 `PluginPackageManager`
|
||||
的同步/异步调用合同。
|
||||
- 插件安装快照、取消补偿和临时约束文件清理统一复用 `runtime.execution.await_task_to_terminal`,连续取消
|
||||
不再由 Application 与 Adapter 各自维护近似循环;数据库 worker 的可中断队列等待仍保留独立职责。
|
||||
|
||||
### Transfer pending / 文件整理
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
||||
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
||||
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
|
||||
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义。
|
||||
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待。
|
||||
|
||||
## 当前复核结论(2026-08-24)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
### 长期整改阶段 0:治理门禁恢复(2026-08-23)
|
||||
|
||||
- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `806` 个模块、`6531` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
|
||||
- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `806` 个模块、`6532` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
|
||||
- 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistry;normal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。
|
||||
- 官方插件快照覆盖 `plugins.v3`、`plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。
|
||||
- SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing`、`__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
- 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。
|
||||
- `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。
|
||||
- 依赖图当前为 `806` 个 Python 模块、`6531` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
|
||||
- 依赖图当前为 `806` 个 Python 模块、`6532` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
|
||||
- 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。
|
||||
|
||||
综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。
|
||||
@@ -138,6 +138,8 @@
|
||||
`stop_modules()` 会刷新未到期摘要并等待已启动回调;它属于 E1 观测,不扩大 TaskRegistry 或 Outbox 范围。
|
||||
插件市场与插件包适配器原有两套线程取消 wrapper 也已统一到 `runtime.execution`:连续取消必须等同步
|
||||
文件 worker 到达终态后再传播,避免提前释放 mutation owner;旧模块内私有名称保留为 canonical 别名。
|
||||
插件安装快照、取消补偿与市场临时文件清理原有两套协程终态循环也已合并为
|
||||
`await_task_to_terminal`;数据库 worker 的 interruptible 等待属于队列 owner,未被机械合并。
|
||||
2. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。
|
||||
|
||||
Oper 内部的执行入口也已统一:最后一处 `AgentTaskOper` 直接 transaction runner 调用已迁入
|
||||
|
||||
+3
-2
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6531,
|
||||
"edge_sha256": "86b2ad0b585deba78c5e0e5e7e4b55cd66e05a2d5e988ed28ac7f6a4ae15ff2f",
|
||||
"edge_count": 6532,
|
||||
"edge_sha256": "3fdba2c6862f9ea28ab4f059c04dd6bb819b86fa9d9fb74d09a0cfcf69f6b711",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2704,6 +2704,7 @@
|
||||
"app.application.plugin.install -> app.application.plugin",
|
||||
"app.application.plugin.install -> app.application.plugin.lifecycle",
|
||||
"app.application.plugin.install -> app.runtime",
|
||||
"app.application.plugin.install -> app.runtime.execution",
|
||||
"app.application.plugin.install -> app.runtime.log",
|
||||
"app.application.plugin.install -> app.schemas",
|
||||
"app.application.plugin.install -> app.schemas.exception",
|
||||
|
||||
@@ -8,7 +8,10 @@ from anyio.to_thread import current_default_thread_limiter
|
||||
|
||||
from app.adapters.external import market as market_adapter
|
||||
from app.adapters.system.plugin import package as plugin_package_adapter
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
from app.runtime.execution import (
|
||||
await_task_to_terminal,
|
||||
run_in_threadpool_to_completion,
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_file_adapters_share_runtime_completion_contract() -> None:
|
||||
@@ -20,6 +23,32 @@ def test_plugin_file_adapters_share_runtime_completion_contract() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_task_to_terminal_ignores_repeated_cancellation() -> None:
|
||||
"""调用方连续取消时,受保护任务仍须结束并返回真实结果。"""
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def protected_operation() -> str:
|
||||
"""阻塞到测试释放,用于观察受保护任务的真实终态。"""
|
||||
started.set()
|
||||
await release.wait()
|
||||
return "completed"
|
||||
|
||||
protected_task = asyncio.create_task(protected_operation())
|
||||
waiter = asyncio.create_task(await_task_to_terminal(protected_task))
|
||||
await started.wait()
|
||||
|
||||
waiter.cancel()
|
||||
await asyncio.sleep(0)
|
||||
waiter.cancel()
|
||||
await asyncio.sleep(0)
|
||||
assert waiter.done() is False
|
||||
|
||||
release.set()
|
||||
assert await waiter == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_threadpool_capacity_is_held_until_cancelled_call_finishes() -> None:
|
||||
"""调用方取消后,执行令牌必须由真实同步调用持有到终态。"""
|
||||
|
||||
Reference in New Issue
Block a user