fix(plugin): make installation lifecycle cancellable

This commit is contained in:
InfinityPacer
2026-08-23 14:26:42 +08:00
parent b1ad309fc7
commit 0378eabb8f
10 changed files with 932 additions and 82 deletions
+118 -3
View File
@@ -2,11 +2,14 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Optional
from app.application.database import DatabaseWorkerOverloadedError
from app.application.plugin.lifecycle import plugin_lifecycle
from app.runtime.log import logger
InstalledPluginsReader = Callable[[], list[str]]
@@ -58,6 +61,20 @@ class PluginInstallResult:
rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback)
@dataclass
class _InstallState:
"""记录取消补偿所需的事务阶段。"""
checkpoint: Any = None
stage: str = "package_checkpoint"
package_installed: bool = False
installed_list_persisted: bool = False
runtime_touched: bool = False
registrations_touched: bool = False
committed: bool = False
original_plugins: list[str] = field(default_factory=list)
class PluginInstallCommand:
"""协调插件检查、包事务、持久化、运行态刷新和安装上报。"""
@@ -96,9 +113,38 @@ class PluginInstallCommand:
repo_url: Optional[str],
release_version: Optional[str] = None,
force: bool = False,
) -> PluginInstallResult:
"""串行执行同一插件的完整安装生命周期,并保证取消后的补偿。"""
state = _InstallState()
async with plugin_lifecycle.hold(plugin_id):
try:
return await self._execute_locked(
plugin_id=plugin_id,
repo_url=repo_url,
release_version=release_version,
force=force,
state=state,
)
except asyncio.CancelledError:
await self._rollback_cancelled(
plugin_id=plugin_id,
original_plugins=state.original_plugins,
state=state,
)
raise
async def _execute_locked(
self,
*,
plugin_id: str,
repo_url: Optional[str],
release_version: Optional[str],
force: bool,
state: _InstallState,
) -> PluginInstallResult:
"""执行插件安装,并在关键阶段失败时恢复可补偿状态。"""
installed_plugins = list(self._installed_plugins_reader() or [])
state.original_plugins = installed_plugins
refreshed_only = not force and plugin_id in self._plugin_ids_provider()
if refreshed_only:
return await self._refresh_existing(
@@ -112,8 +158,16 @@ class PluginInstallCommand:
failure_stage="validation",
)
checkpoint_task = asyncio.create_task(self._package_checkpointer(plugin_id))
try:
checkpoint = await self._package_checkpointer(plugin_id)
checkpoint = await asyncio.shield(checkpoint_task)
state.checkpoint = checkpoint
except asyncio.CancelledError:
try:
state.checkpoint = await asyncio.shield(checkpoint_task)
except BaseException:
pass
raise
except Exception as err:
return PluginInstallResult(
success=False,
@@ -121,13 +175,15 @@ class PluginInstallCommand:
failure_stage="package_checkpoint",
)
state.stage = "package_install"
try:
state, message = await self._package_installer(
package_installed, message = await self._package_installer(
plugin_id,
repo_url,
release_version,
force,
)
state.package_installed = package_installed
except Exception as err:
result = await self._failure(
plugin_id=plugin_id,
@@ -140,7 +196,7 @@ class PluginInstallCommand:
if isinstance(err, DatabaseWorkerOverloadedError):
raise
return result
if not state:
if not package_installed:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
@@ -156,6 +212,7 @@ class PluginInstallCommand:
try:
await self._installed_plugins_writer(updated_plugins)
installed_list_persisted = True
state.installed_list_persisted = True
except Exception as err:
result = await self._failure(
plugin_id=plugin_id,
@@ -169,6 +226,8 @@ class PluginInstallCommand:
raise
return result
state.stage = "runtime_reload"
state.runtime_touched = True
try:
await self._plugin_reloader(plugin_id)
except Exception as err:
@@ -186,6 +245,8 @@ class PluginInstallCommand:
raise
return result
state.stage = "registration_refresh"
state.registrations_touched = True
try:
await self._registration_refresher(plugin_id)
except Exception as err:
@@ -205,13 +266,16 @@ class PluginInstallCommand:
return result
checkpoint_cleanup_error = ""
state.stage = "checkpoint_commit"
try:
await self._package_committer(checkpoint)
state.committed = True
except Exception as err:
checkpoint_cleanup_error = str(err)
reported = False
report_error = ""
state.stage = "report"
try:
report_result = await self._install_reporter(plugin_id, repo_url)
reported = report_result is not False
@@ -237,6 +301,57 @@ class PluginInstallCommand:
checkpoint_cleanup_error=checkpoint_cleanup_error,
)
async def _rollback_cancelled(
self,
*,
plugin_id: str,
original_plugins: list[str],
state: _InstallState,
) -> None:
"""在保留取消语义的同时完成文件、清单和运行态补偿。"""
if state.committed:
logger.warning(
f"插件 {plugin_id} 在安装提交后被取消,Python 依赖环境可能已经改变"
)
return
if state.checkpoint is None:
logger.warning(
f"插件 {plugin_id} 在创建安装快照前被取消,无法执行文件补偿"
)
return
rollback_task = asyncio.create_task(
self._failure(
plugin_id=plugin_id,
original_plugins=original_plugins,
checkpoint=state.checkpoint,
stage=state.stage,
message="插件安装已取消",
package_installed=state.package_installed,
installed_list_persisted=state.installed_list_persisted,
runtime_touched=state.runtime_touched,
registrations_touched=state.registrations_touched,
)
)
try:
result = await asyncio.shield(rollback_task)
except asyncio.CancelledError:
try:
result = await asyncio.shield(rollback_task)
except BaseException as err:
logger.error(f"插件 {plugin_id} 取消后的补偿未完成:{err}")
return
except Exception as err:
logger.error(f"插件 {plugin_id} 取消后的补偿失败:{err}")
return
if result.rollback.errors:
logger.error(
f"插件 {plugin_id} 取消后的补偿存在错误:{''.join(result.rollback.errors)}"
)
logger.warning(
f"插件 {plugin_id} 安装已取消,插件文件已尝试恢复,Python 依赖环境可能已经改变"
)
async def _refresh_existing(
self,
*,
+88
View File
@@ -0,0 +1,88 @@
"""插件安装与启动同步之间共享的生命周期互斥。"""
from __future__ import annotations
import asyncio
import threading
from contextlib import asynccontextmanager, contextmanager
from typing import Iterator
class PluginLifecycleCoordinator:
"""在事件循环和同步启动线程之间协调插件生命周期操作。"""
def __init__(self) -> None:
self._condition = threading.Condition()
self._active_plugins: set[str] = set()
self._startup_active = False
@staticmethod
def _normalize(plugin_id: str) -> str:
return (plugin_id or "").strip().lower()
def _try_acquire_plugin(self, plugin_id: str) -> bool:
normalized_id = self._normalize(plugin_id)
if not normalized_id:
raise ValueError("插件ID不能为空")
with self._condition:
if self._startup_active or normalized_id in self._active_plugins:
return False
self._active_plugins.add(normalized_id)
return True
def _release_plugin(self, plugin_id: str) -> None:
normalized_id = self._normalize(plugin_id)
with self._condition:
self._active_plugins.discard(normalized_id)
self._condition.notify_all()
def _try_acquire_startup(self) -> bool:
with self._condition:
if self._startup_active or self._active_plugins:
return False
self._startup_active = True
return True
def _release_startup(self) -> None:
with self._condition:
self._startup_active = False
self._condition.notify_all()
@asynccontextmanager
async def hold(self, plugin_id: str):
"""异步持有单个插件的生命周期资格,不在线程池中等待锁。"""
while not self._try_acquire_plugin(plugin_id):
await asyncio.sleep(0.01)
try:
yield
finally:
self._release_plugin(plugin_id)
@contextmanager
def hold_sync(self, plugin_id: str) -> Iterator[None]:
"""同步持有单个插件的生命周期资格。"""
normalized_id = self._normalize(plugin_id)
if not normalized_id:
raise ValueError("插件ID不能为空")
with self._condition:
while self._startup_active or normalized_id in self._active_plugins:
self._condition.wait()
self._active_plugins.add(normalized_id)
try:
yield
finally:
self._release_plugin(normalized_id)
@asynccontextmanager
async def hold_startup(self):
"""异步持有启动同步的全局资格,阻止安装请求穿过启动收口。"""
while not self._try_acquire_startup():
await asyncio.sleep(0.01)
try:
yield
finally:
self._release_startup()
plugin_lifecycle = PluginLifecycleCoordinator()