refactor: migrate high-risk write transactions

This commit is contained in:
jxxghp
2026-08-21 20:46:57 +08:00
parent 7de72e0e83
commit b598b516d5
12 changed files with 312 additions and 9 deletions
+4 -1
View File
@@ -307,7 +307,10 @@ def get_agent_chat_service(
db: AsyncSession = Depends(get_async_db),
) -> AgentChatService:
"""组装 Agent 会话历史查询和删除服务。"""
return AgentChatService(repository=_repository("agent_chat", db))
return AgentChatService(
repository=_repository("agent_chat", db),
unit_of_work=_transaction("async", db),
)
def get_mediaserver_query_service(
+39 -3
View File
@@ -45,6 +45,14 @@ class AsyncAgentChatRepository(Protocol):
"""删除指定服务端会话。"""
...
async def async_stage_delete(
self,
session_id: str,
user_id: Optional[str] = None,
) -> bool:
"""暂存删除指定服务端会话,不提交调用方事务。"""
...
def get(self, session_id: str, user_id: Optional[str] = None) -> Optional[Any]:
"""同步读取服务端会话。"""
...
@@ -83,12 +91,29 @@ class AgentChatRecord:
messages: list[dict]
class AsyncUnitOfWork(Protocol):
"""Agent 会话异步写用例所需的最小事务端口。"""
async def commit(self) -> None:
"""提交当前请求事务。"""
...
async def rollback(self) -> None:
"""回滚当前请求事务。"""
...
class AgentChatService:
"""统一执行 Agent 会话查询、访问控制和删除。"""
def __init__(self, repository: AsyncAgentChatRepository) -> None:
"""保存异步会话持久化端口。"""
def __init__(
self,
repository: AsyncAgentChatRepository,
unit_of_work: Optional[AsyncUnitOfWork] = None,
) -> None:
"""保存会话持久化端口和可选请求级事务。"""
self._repository = repository
self._unit_of_work = unit_of_work
async def list(
self,
@@ -137,7 +162,18 @@ class AgentChatService:
record = await self.get_accessible(session_id, principal)
if record is None:
return False
return await self._repository.async_delete(session_id=session_id)
if self._unit_of_work is None:
return await self._repository.async_delete(session_id=session_id)
try:
deleted = await self._repository.async_stage_delete(
session_id=session_id
)
if deleted:
await self._unit_of_work.commit()
return deleted
except Exception:
await self._unit_of_work.rollback()
raise
def get_sync(self, session_id: str) -> Optional[AgentChatRecord]:
"""同步读取会话投影,供同步 Agent 编排路径使用。"""
+45
View File
@@ -0,0 +1,45 @@
"""插件持久化数据写用例。"""
from typing import Protocol
class PluginDataMutationRepository(Protocol):
"""插件数据删除命令所需的无提交仓储端口。"""
def stage_delete(self, plugin_id: str) -> None:
"""暂存目标插件的全部持久化数据删除。"""
...
class UnitOfWork(Protocol):
"""插件数据同步写用例所需的事务端口。"""
def commit(self) -> None:
"""提交当前逻辑操作。"""
...
def rollback(self) -> None:
"""回滚当前逻辑操作。"""
...
class DeletePluginDataCommand:
"""在一个显式事务中删除目标插件的全部持久化数据。"""
def __init__(
self,
repository: PluginDataMutationRepository,
unit_of_work: UnitOfWork,
) -> None:
"""保存无提交仓储和事务所有者。"""
self._repository = repository
self._unit_of_work = unit_of_work
def execute(self, plugin_id: str) -> None:
"""暂存并提交删除;任一步失败时回滚并传播原异常。"""
try:
self._repository.stage_delete(plugin_id)
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
+15
View File
@@ -314,6 +314,21 @@ class AgentChatOper(DbOper):
await AgentChat.async_delete(self._db, chat.id)
return True
async def async_stage_delete(
self,
session_id: str,
user_id: Optional[str] = None,
) -> bool:
"""暂存 Agent 会话删除并 flush,不提交请求级事务。"""
if not isinstance(self._db, AsyncSession):
raise RuntimeError("Agent 会话暂存删除需要调用方提供 AsyncSession")
chat = await self.async_get(session_id=session_id, user_id=user_id)
if not chat:
return False
await self._db.delete(chat)
await self._db.flush()
return True
@staticmethod
def to_summary(chat: AgentChat) -> dict[str, Any]:
"""
+12
View File
@@ -1,5 +1,8 @@
from typing import Any, Optional
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.plugindata import PluginData
@@ -82,6 +85,15 @@ class PluginDataOper(DbOper):
else:
PluginData.del_plugin_data(self._db, plugin_id)
def stage_delete(self, plugin_id: str) -> None:
"""暂存目标插件全部数据删除并 flush,不提交调用方事务。"""
if not isinstance(self._db, Session):
raise RuntimeError("插件数据暂存删除需要调用方提供 Session")
self._db.execute(
delete(PluginData).where(PluginData.plugin_id == plugin_id)
)
self._db.flush()
def truncate(self):
"""
清空插件数据
+16 -1
View File
@@ -18,6 +18,7 @@ from app.runtime.extensions.plugin_manager import (
)
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
from app.application.plugin.catalog import PluginCatalogService
from app.application.plugin.data import DeletePluginDataCommand
from app.adapters.external.plugin.client import PluginMarketClient
from app.runtime.extensions.plugin.storage import (
PluginStorage,
@@ -41,6 +42,8 @@ from app.adapters.system.plugin.package import PluginPackageManager
from app.adapters.system.host import SystemUtils
from app.db.oper.plugindata import PluginDataOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import SessionFactory
from app.db.uow import SqlAlchemyUnitOfWork
from app.runtime.log import logger
from app.foundation.version import compare_version
from app.schemas.plugin import PluginRuntimeStatus
@@ -52,6 +55,18 @@ async def _async_write_plugin_config(key, value):
return await SystemConfigOper().async_set(key, value)
def _delete_plugin_data(plugin_id: str) -> None:
"""用独占同步会话执行插件重置的数据删除事务。"""
session = SessionFactory()
try:
DeletePluginDataCommand(
repository=PluginDataOper(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
).execute(plugin_id)
finally:
session.close()
def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None:
"""在执行旧插件顶层代码前准备其静态导入所需的宿主资源。"""
for capability_id in scan_plugin_resource_imports(plugin_id, plugin_dir):
@@ -98,7 +113,7 @@ def configure_plugin_services() -> None:
write=lambda key, value: SystemConfigOper().set(key, value),
async_write=_async_write_plugin_config,
delete=lambda key: SystemConfigOper().delete(key),
delete_data=lambda plugin_id: PluginDataOper().del_data(plugin_id),
delete_data=_delete_plugin_data,
))
+3
View File
@@ -367,6 +367,9 @@ flowchart LR
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
`transaction-debt-baseline.json` 将存量 178 个 Model 事务装饰器冻结为只降不增低水位。
- 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application
Command/Service 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从
`startup/plugins_initializer.py` 创建独占会话,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。
- 每次表结构变更必须新增 `database/versions/` 下的 Alembic 迁移。
- 运行期业务配置使用 `SystemConfigKey` 枚举 + `SystemConfigOper`,禁止裸字符串键;
用户级配置使用 `UserConfigOper`
@@ -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(ARCH-201203)、阶段 1ARCH-210212)与 ARCH-220221 已完成,后续任务按 ID 独立提交和回滚
> 实施进度:阶段 0(ARCH-201203)、阶段 1ARCH-210212)与阶段 2ARCH-220222已完成,后续任务按 ID 独立提交和回滚
## 1. 结论先行
@@ -399,6 +399,20 @@ flowchart TB
每个切片沿用 ARCH-221,不允许批量移动全部 Model 方法。查询方法可在写边界稳定后再迁移。
**实施记录(2026-08-21**
| 风险域 | 规范事务入口 | 结果 |
| --- | --- | --- |
| 站点配置 | `SiteMutationCommand` + Async UoW | create/update/priorities/delete/reset 均先 stage 再 commit |
| 下载/整理历史 | `DownloadHistoryMutationCommand``TransferHistoryMutationCommand` + Sync UoW | 多表删除与文件副作用顺序已有聚焦回归 |
| 工作流 | `WorkflowMutationCommand``WorkflowDefinitionCommand` + Sync/Async UoW | 定义写入提交后才刷新 timer/event |
| Agent chat | `AgentChatService` + 请求级 Async UoW | API 会话删除改为 `async_stage_delete()`;失败回滚、缺失不提交 |
| 插件数据重置 | `DeletePluginDataCommand` + 独占 Sync Session/UoW | `PluginDataOper.stage_delete()` 只 DELETE/flush;重置链由 startup 装配 |
旧插件与宿主存量代码直接构造 `PluginDataOper``AgentChatOper` 的行为继续保留;新 API 和插件
重置链不得回退到这些自动提交兼容方法。五类矩阵聚焦测试共 57 项通过,事务 ratchet 仍为
178 且没有新增或搬移 Model 装饰器。
### 阶段 3:类型化运行时装配,减少全局服务定位
#### ARCH-230:建立类型化 HostRuntime / AppState
+4
View File
@@ -104,6 +104,10 @@ Oper classes accept and return persistence values. Turning a `MediaInfo` or
UoW and post-commit callback, and `SubscribeOper.stage_add()` only queries,
adds, and flushes. Preserve `SubscribeOper.add()` only for legacy SDK callers;
new host code must not use that auto-commit compatibility path.
- The same rule applies to `SiteMutationCommand`, history/workflow commands,
`AgentChatService.delete()`, and `DeletePluginDataCommand`: bind the repository
and UoW to one request/operation Session. Legacy plugin-facing Oper methods may
remain temporarily, but a new endpoint or startup workflow must call `stage_*`.
Run `./.venv/bin/python scripts/architecture/baseline.py --check-host` after
persistence changes. A deliberate debt reduction may refresh the low-water mark
+7 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6092,
"edge_sha256": "1935d43d8d3c0c3e3687109f0b81a4e56cbc4bc2ed4763a706e2b0d7c119f378",
"edge_count": 6095,
"edge_sha256": "06c09c175ac007c7ef891e2a25f5036c8bc3993a817837c03824bb591168ea73",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -5890,12 +5890,15 @@
"app.startup.plugins_initializer -> app.application",
"app.startup.plugins_initializer -> app.application.plugin",
"app.startup.plugins_initializer -> app.application.plugin.catalog",
"app.startup.plugins_initializer -> app.application.plugin.data",
"app.startup.plugins_initializer -> app.application.plugin.routes",
"app.startup.plugins_initializer -> app.application.site",
"app.startup.plugins_initializer -> app.db",
"app.startup.plugins_initializer -> app.db.oper",
"app.startup.plugins_initializer -> app.db.oper.plugindata",
"app.startup.plugins_initializer -> app.db.oper.systemconfig",
"app.startup.plugins_initializer -> app.db.session",
"app.startup.plugins_initializer -> app.db.uow",
"app.startup.plugins_initializer -> app.foundation",
"app.startup.plugins_initializer -> app.foundation.version",
"app.startup.plugins_initializer -> app.runtime",
@@ -6109,7 +6112,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 757,
"module_count": 758,
"modules": [
"app",
"app.adapters",
@@ -6379,6 +6382,7 @@
"app.application.plugin",
"app.application.plugin.catalog",
"app.application.plugin.config",
"app.application.plugin.data",
"app.application.plugin.folders",
"app.application.plugin.install",
"app.application.plugin.routes",
+92
View File
@@ -0,0 +1,92 @@
"""Agent 会话删除的请求级事务边界测试。"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.messaging.chat import AgentChatService
def _principal() -> SimpleNamespace:
"""构造拥有目标会话的普通用户。"""
return SimpleNamespace(id=1, name="alice", is_superuser=False)
def _chat() -> SimpleNamespace:
"""构造应用服务投影所需的最小会话记录。"""
return SimpleNamespace(
id=9,
session_id="session-9",
client_session_id=None,
title="事务会话",
channel=None,
source=None,
user_id="1",
username="alice",
original_chat_id=None,
message_count=0,
created_at=None,
updated_at=None,
display_messages=[],
)
@pytest.mark.asyncio
async def test_delete_stages_then_commits_once() -> None:
"""有权限的会话删除只能由 Service 在暂存成功后提交一次。"""
calls: list[str] = []
repository = Mock()
repository.async_get = AsyncMock(return_value=_chat())
repository.async_stage_delete = AsyncMock(
side_effect=lambda **_kwargs: calls.append("stage") or True
)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock(
side_effect=lambda: calls.append("commit")
)
unit_of_work.rollback = AsyncMock()
service = AgentChatService(repository, unit_of_work)
assert await service.delete("session-9", _principal()) is True
assert calls == ["stage", "commit"]
unit_of_work.rollback.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_rolls_back_flush_failure() -> None:
"""暂存删除失败必须回滚并传播原异常。"""
error = RuntimeError("flush failed")
repository = Mock()
repository.async_get = AsyncMock(return_value=_chat())
repository.async_stage_delete = AsyncMock(side_effect=error)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
service = AgentChatService(repository, unit_of_work)
with pytest.raises(RuntimeError) as raised:
await service.delete("session-9", _principal())
assert raised.value is error
unit_of_work.commit.assert_not_awaited()
unit_of_work.rollback.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_delete_missing_chat_does_not_open_write_transaction() -> None:
"""会话不存在时保持旧 False 返回,且不执行 stage 或 commit。"""
repository = Mock()
repository.async_get = AsyncMock(return_value=None)
repository.async_stage_delete = AsyncMock()
unit_of_work = Mock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
service = AgentChatService(repository, unit_of_work)
assert await service.delete("missing", _principal()) is False
repository.async_stage_delete.assert_not_awaited()
unit_of_work.commit.assert_not_awaited()
unit_of_work.rollback.assert_not_awaited()
+60
View File
@@ -0,0 +1,60 @@
"""插件持久化数据删除的事务所有权测试。"""
from unittest.mock import Mock
import pytest
from app.application.plugin.data import DeletePluginDataCommand
from app.db.models.plugindata import PluginData
from app.db.oper.plugindata import PluginDataOper
def test_delete_plugin_data_stages_then_commits() -> None:
"""插件重置必须在仓储暂存完成后由 Application Command 提交。"""
calls: list[str] = []
repository = Mock()
repository.stage_delete.side_effect = lambda _plugin_id: calls.append("stage")
unit_of_work = Mock()
unit_of_work.commit.side_effect = lambda: calls.append("commit")
command = DeletePluginDataCommand(repository, unit_of_work)
command.execute("Demo")
assert calls == ["stage", "commit"]
repository.stage_delete.assert_called_once_with("Demo")
unit_of_work.rollback.assert_not_called()
def test_delete_plugin_data_rolls_back_commit_failure() -> None:
"""插件数据删除提交失败必须回滚并保留原异常。"""
error = RuntimeError("commit failed")
repository = Mock()
unit_of_work = Mock()
unit_of_work.commit.side_effect = error
command = DeletePluginDataCommand(repository, unit_of_work)
with pytest.raises(RuntimeError) as raised:
command.execute("Demo")
assert raised.value is error
unit_of_work.rollback.assert_called_once_with()
def test_plugin_data_oper_stage_delete_does_not_commit(db, monkeypatch) -> None:
"""Oper 只暂存目标插件删除,其他插件数据与提交权均不受影响。"""
db.add(
PluginData(plugin_id="Target", key="one", value=1),
PluginData(plugin_id="Other", key="two", value=2),
)
commit = Mock(wraps=db.session.commit)
monkeypatch.setattr(db.session, "commit", commit)
oper = PluginDataOper(db.session)
oper.stage_delete("Target")
remaining = PluginData.get_plugin_data(db.session, "Other")
deleted = PluginData.get_plugin_data(db.session, "Target")
assert [item.key for item in remaining] == ["two"]
assert deleted == []
commit.assert_not_called()
db.session.rollback()