mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
Merge remote-tracking branch 'origin/v3' into v3
# Conflicts: # tests/fixtures/architecture/dependency-baseline.json
This commit is contained in:
@@ -18,14 +18,6 @@ DatabaseProbe = Callable[[], Optional[str]]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class DatabaseWorkerClosedError(RuntimeError):
|
||||
"""数据库执行器尚未启动或已经停止。"""
|
||||
|
||||
|
||||
class DatabaseWorkerOverloadedError(RuntimeError):
|
||||
"""数据库执行器的运行与排队容量已经用尽。"""
|
||||
|
||||
|
||||
class AsyncDatabaseExecutor(Protocol):
|
||||
"""让异步业务调用同步短事务而不阻塞事件循环。"""
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ from weakref import WeakValueDictionary
|
||||
|
||||
from app.application.database import (
|
||||
AsyncDatabaseExecutor,
|
||||
)
|
||||
from app.schemas.agent import AgentChatSessionDetail, AgentChatSessionSummary
|
||||
from app.schemas.exception import (
|
||||
DatabaseWorkerClosedError,
|
||||
DatabaseWorkerOverloadedError,
|
||||
)
|
||||
from app.schemas.agent import AgentChatSessionDetail, AgentChatSessionSummary
|
||||
from app.runtime.observability import record_metric
|
||||
|
||||
|
||||
|
||||
@@ -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.schemas.exception import DatabaseWorkerOverloadedError
|
||||
from app.application.plugin.lifecycle import plugin_lifecycle
|
||||
from app.runtime.log import logger
|
||||
|
||||
|
||||
InstalledPluginsReader = Callable[[], list[str]]
|
||||
@@ -58,6 +61,22 @@ class PluginInstallResult:
|
||||
rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _InstallState:
|
||||
"""记录取消补偿所需的事务阶段。"""
|
||||
|
||||
checkpoint: Any = None
|
||||
stage: str = "package_checkpoint"
|
||||
package_installed: bool = False
|
||||
installed_list_touched: bool = False
|
||||
installed_list_persisted: bool = False
|
||||
runtime_touched: bool = False
|
||||
registrations_touched: bool = False
|
||||
refresh_compensated: bool = False
|
||||
committed: bool = False
|
||||
original_plugins: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class PluginInstallCommand:
|
||||
"""协调插件检查、包事务、持久化、运行态刷新和安装上报。"""
|
||||
|
||||
@@ -96,14 +115,44 @@ 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(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
state=state,
|
||||
)
|
||||
if not repo_url:
|
||||
return PluginInstallResult(
|
||||
@@ -112,8 +161,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 +178,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 +199,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,
|
||||
@@ -154,8 +213,11 @@ class PluginInstallCommand:
|
||||
if plugin_id not in installed_plugins:
|
||||
updated_plugins = [*installed_plugins, plugin_id]
|
||||
try:
|
||||
# 写入方可能在返回前已经提交;取消时按已触碰处理,恢复原清单是幂等的。
|
||||
state.installed_list_touched = True
|
||||
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,
|
||||
@@ -164,11 +226,14 @@ class PluginInstallCommand:
|
||||
stage="installed_list_persistence",
|
||||
message=str(err),
|
||||
package_installed=True,
|
||||
installed_list_persisted=state.installed_list_touched,
|
||||
)
|
||||
if isinstance(err, DatabaseWorkerOverloadedError):
|
||||
raise
|
||||
return result
|
||||
|
||||
state.stage = "runtime_reload"
|
||||
state.runtime_touched = True
|
||||
try:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
except Exception as err:
|
||||
@@ -186,6 +251,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,6 +272,9 @@ class PluginInstallCommand:
|
||||
return result
|
||||
|
||||
checkpoint_cleanup_error = ""
|
||||
state.stage = "checkpoint_commit"
|
||||
# 运行态和注册已完成,后续只清理临时快照,不再把取消当作未提交安装回滚。
|
||||
state.committed = True
|
||||
try:
|
||||
await self._package_committer(checkpoint)
|
||||
except Exception as err:
|
||||
@@ -212,6 +282,7 @@ class PluginInstallCommand:
|
||||
|
||||
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,11 +308,65 @@ 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.refresh_compensated:
|
||||
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_touched,
|
||||
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,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: Optional[str],
|
||||
state: _InstallState,
|
||||
) -> PluginInstallResult:
|
||||
"""刷新已存在插件,不触碰包文件和已安装列表。"""
|
||||
if repo_url:
|
||||
@@ -261,33 +386,31 @@ class PluginInstallCommand:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
failure_stage = "registration_refresh"
|
||||
await self._registration_refresher(plugin_id)
|
||||
except Exception as err:
|
||||
rollback_errors = []
|
||||
runtime_restored = False
|
||||
registrations_restored = False
|
||||
try:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
runtime_restored = True
|
||||
except Exception as rollback_err:
|
||||
rollback_errors.append(f"运行态恢复失败:{rollback_err}")
|
||||
if runtime_restored:
|
||||
except asyncio.CancelledError:
|
||||
cleanup_task = asyncio.create_task(
|
||||
self._restore_refreshed_runtime(plugin_id)
|
||||
)
|
||||
while not cleanup_task.done():
|
||||
try:
|
||||
await self._registration_refresher(plugin_id)
|
||||
registrations_restored = True
|
||||
except Exception as rollback_err:
|
||||
rollback_errors.append(f"路由和服务注册恢复失败:{rollback_err}")
|
||||
await asyncio.shield(cleanup_task)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
rollback = await cleanup_task
|
||||
state.refresh_compensated = True
|
||||
if rollback.errors:
|
||||
logger.error(
|
||||
f"插件 {plugin_id} 取消刷新后的运行态补偿存在错误:"
|
||||
f"{';'.join(rollback.errors)}"
|
||||
)
|
||||
raise
|
||||
except Exception as err:
|
||||
rollback = await self._restore_refreshed_runtime(plugin_id)
|
||||
result = PluginInstallResult(
|
||||
success=False,
|
||||
message=f"刷新插件运行态失败:{err}",
|
||||
refreshed_only=True,
|
||||
failure_stage=failure_stage,
|
||||
rollback=PluginInstallRollback(
|
||||
runtime_attempted=True,
|
||||
runtime_restored=runtime_restored,
|
||||
registrations_attempted=True,
|
||||
registrations_restored=registrations_restored,
|
||||
errors=tuple(rollback_errors),
|
||||
),
|
||||
rollback=rollback,
|
||||
)
|
||||
if isinstance(err, DatabaseWorkerOverloadedError):
|
||||
raise
|
||||
@@ -316,6 +439,33 @@ class PluginInstallCommand:
|
||||
report_error=report_error,
|
||||
)
|
||||
|
||||
async def _restore_refreshed_runtime(
|
||||
self,
|
||||
plugin_id: str,
|
||||
) -> PluginInstallRollback:
|
||||
"""重新加载插件并刷新注册,使中断的运行态切换恢复到完整状态。"""
|
||||
errors = []
|
||||
runtime_restored = False
|
||||
registrations_restored = False
|
||||
try:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
runtime_restored = True
|
||||
except Exception as err:
|
||||
errors.append(f"运行态恢复失败:{err}")
|
||||
if runtime_restored:
|
||||
try:
|
||||
await self._registration_refresher(plugin_id)
|
||||
registrations_restored = True
|
||||
except Exception as err:
|
||||
errors.append(f"路由和服务注册恢复失败:{err}")
|
||||
return PluginInstallRollback(
|
||||
runtime_attempted=True,
|
||||
runtime_restored=runtime_restored,
|
||||
registrations_attempted=True,
|
||||
registrations_restored=registrations_restored,
|
||||
errors=tuple(errors),
|
||||
)
|
||||
|
||||
async def _failure(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""插件安装与启动同步之间共享的生命周期互斥。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@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()
|
||||
Reference in New Issue
Block a user