Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	tests/fixtures/architecture/dependency-baseline.json
This commit is contained in:
jxxghp
2026-08-23 14:57:03 +08:00
26 changed files with 1633 additions and 146 deletions
-8
View File
@@ -18,14 +18,6 @@ DatabaseProbe = Callable[[], Optional[str]]
T = TypeVar("T")
class DatabaseWorkerClosedError(RuntimeError):
"""数据库执行器尚未启动或已经停止。"""
class DatabaseWorkerOverloadedError(RuntimeError):
"""数据库执行器的运行与排队容量已经用尽。"""
class AsyncDatabaseExecutor(Protocol):
"""让异步业务调用同步短事务而不阻塞事件循环。"""
+3 -1
View File
@@ -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
+175 -25
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.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,
*,
+71
View File
@@ -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()