fix(agent): close async chat persistence lifecycle

This commit is contained in:
InfinityPacer
2026-08-23 11:44:18 +08:00
parent 6f5ee96152
commit 0ba4a7e5e3
14 changed files with 448 additions and 72 deletions
+2 -1
View File
@@ -263,8 +263,9 @@ def configure_plugin_system_services():
)
configure_agent_chat_persistence(
AgentChatPersistenceService(
repository=AgentChatOper,
repository=lambda session: AgentChatOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
)
)
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
+1
View File
@@ -6097,6 +6097,7 @@
"app.startup.modules_initializer -> app.application.history",
"app.startup.modules_initializer -> app.application.image",
"app.startup.modules_initializer -> app.application.messaging",
"app.startup.modules_initializer -> app.application.messaging.agent",
"app.startup.modules_initializer -> app.application.messaging.chat",
"app.startup.modules_initializer -> app.application.messaging.message",
"app.startup.modules_initializer -> app.application.module",
+186 -6
View File
@@ -4,16 +4,24 @@ from __future__ import annotations
import asyncio
import threading
from types import SimpleNamespace
from unittest.mock import AsyncMock, call, patch
from uuid import uuid4
import pytest
from sqlalchemy import delete, select
from app.application.database import DatabaseWorkerOverloadedError
from app.application.database import (
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
)
from app.application.messaging.chat import AgentChatPersistenceService, AgentChatService
from app.api.endpoints.agent import save_agent_chat_display
from app.db.models.agentchat import AgentChat
from app.db.oper.agentchat import AgentChatOper
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import run_sync_transaction
from app.schemas.agent import AgentChatDisplaySaveRequest
from app.db.worker import DatabaseWorker
@@ -65,8 +73,9 @@ async def test_agent_chat_persistence_runs_sync_repository_inside_worker() -> No
executor = _Executor()
repository = _Repository()
service = AgentChatPersistenceService(
repository=lambda: repository,
repository=lambda _session: repository,
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
)
caller_thread_id = threading.get_ident()
@@ -111,8 +120,9 @@ async def test_agent_chat_persistence_propagates_worker_failure() -> None:
raise RuntimeError("worker failed")
service = AgentChatPersistenceService(
repository=_Repository,
repository=lambda _session: _Repository(),
async_executor=FailingExecutor(),
sync_transaction=lambda operation: operation(object()),
)
with pytest.raises(RuntimeError, match="worker failed"):
@@ -123,6 +133,83 @@ async def test_agent_chat_persistence_propagates_worker_failure() -> None:
)
@pytest.mark.asyncio
async def test_agent_chat_persistence_pending_metric_uses_deltas() -> None:
"""pending 是 UpDownCounter,准入和释放必须分别记录增减量。"""
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=_Executor(),
sync_transaction=lambda operation: operation(object()),
)
with patch("app.application.messaging.chat.record_metric") as record_metric:
await service.async_save_agent_messages(
session_id="metric-session",
user_id="1",
messages=[],
)
record_metric.assert_has_calls(
[
call("agent.chat.persistence.pending", 1),
call("agent.chat.persistence.pending", -1),
]
)
@pytest.mark.asyncio
async def test_authoritative_display_save_propagates_worker_overload() -> None:
"""权威 PUT 保存不能把 worker 背压吞成成功或普通业务失败。"""
repository = AsyncMock()
repository.async_get.return_value = None
service = AgentChatService(repository=repository)
class OverloadedPersistence:
async def async_save_display_messages(self, **_kwargs):
raise DatabaseWorkerOverloadedError("busy")
with pytest.raises(DatabaseWorkerOverloadedError, match="busy"):
await save_agent_chat_display(
session_id="overloaded-session",
payload=AgentChatDisplaySaveRequest(messages=[]),
current_user=SimpleNamespace(id=1, name="admin", is_superuser=True),
service=service,
persistence=OverloadedPersistence(),
)
@pytest.mark.asyncio
async def test_agent_chat_persistence_rolls_back_compound_write(monkeypatch) -> None:
"""复合写入中途失败时,创建或更新不能留下半成品。"""
worker = DatabaseWorker(max_workers=1, capacity=4)
await worker.start()
session_id = f"worker-rollback-{uuid4().hex}"
persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=worker,
sync_transaction=run_sync_transaction,
)
original = AgentChatOper.save_display_messages
def fail_after_stage(self, *args, **kwargs):
original(self, *args, **kwargs)
raise RuntimeError("display snapshot failed")
monkeypatch.setattr(AgentChatOper, "save_display_messages", fail_after_stage)
try:
with pytest.raises(RuntimeError, match="display snapshot failed"):
await persistence.async_append_display_messages(
session_id=session_id,
user_id="rollback-user",
messages=[{"role": "user", "content": "not committed"}],
)
async with async_session_scope() as session:
result = await session.execute(
select(AgentChat).where(AgentChat.session_id == session_id)
)
assert result.scalars().first() is None
finally:
await worker.shutdown()
@pytest.mark.asyncio
async def test_agent_chat_persistence_bounds_session_waiters_and_releases_cancelled() -> None:
"""同会话锁等待受总量限制,取消等待不会遗留 admission。"""
@@ -139,9 +226,11 @@ async def test_agent_chat_persistence_bounds_session_waiters_and_releases_cancel
executor = BlockingExecutor()
service = AgentChatPersistenceService(
repository=_Repository,
repository=lambda _session: _Repository(),
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
capacity=2,
session_capacity=2,
)
first = asyncio.create_task(
service.async_save_agent_messages(
@@ -177,6 +266,95 @@ async def test_agent_chat_persistence_bounds_session_waiters_and_releases_cancel
assert service._pending_writes == 0
@pytest.mark.asyncio
async def test_agent_chat_persistence_session_admission_is_fair() -> None:
"""热点会话的锁等待不能占满全局容量并拒绝其他会话。"""
class BlockingExecutor:
def __init__(self) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run(self, operation):
self.started.set()
await self.release.wait()
return operation()
executor = BlockingExecutor()
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
capacity=4,
session_capacity=2,
)
first = asyncio.create_task(
service.async_save_agent_messages(
session_id="hot-session", user_id="1", messages=[]
)
)
await executor.started.wait()
second = asyncio.create_task(
service.async_save_agent_messages(
session_id="hot-session", user_id="1", messages=[]
)
)
await asyncio.sleep(0)
with pytest.raises(DatabaseWorkerOverloadedError):
await service.async_save_agent_messages(
session_id="hot-session", user_id="1", messages=[]
)
other = asyncio.create_task(
service.async_save_agent_messages(
session_id="other-session", user_id="1", messages=[]
)
)
await asyncio.sleep(0)
assert not other.done()
executor.release.set()
await first
await second
await other
@pytest.mark.asyncio
async def test_agent_chat_persistence_shutdown_drains_active_writes() -> None:
"""关闭持久化端口时拒绝新写入并等待现有会话写入收口。"""
class BlockingExecutor:
def __init__(self) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run(self, operation):
self.started.set()
await self.release.wait()
return operation()
executor = BlockingExecutor()
service = AgentChatPersistenceService(
repository=lambda _session: _Repository(),
async_executor=executor,
sync_transaction=lambda operation: operation(object()),
)
write = asyncio.create_task(
service.async_save_agent_messages(
session_id="shutdown-session", user_id="1", messages=[]
)
)
await executor.started.wait()
shutdown = asyncio.create_task(service.shutdown())
await asyncio.sleep(0)
assert not shutdown.done()
with pytest.raises(DatabaseWorkerClosedError):
await service.async_save_agent_messages(
session_id="new-session", user_id="1", messages=[]
)
executor.release.set()
await write
await shutdown
@pytest.mark.asyncio
async def test_agent_chat_persistence_uses_real_worker_and_sqlite_transaction() -> None:
"""真实 AgentChat Oper 经 worker 写入后可被 native async 查询恢复。"""
@@ -184,8 +362,9 @@ async def test_agent_chat_persistence_uses_real_worker_and_sqlite_transaction()
await worker.start()
session_id = f"worker-{uuid4().hex}"
persistence = AgentChatPersistenceService(
repository=AgentChatOper,
repository=lambda session: AgentChatOper(session),
async_executor=worker,
sync_transaction=run_sync_transaction,
)
query = AgentChatService(repository=AgentChatOper())
@@ -221,8 +400,9 @@ async def test_agent_chat_persistence_serializes_same_session_writes() -> None:
session_id = f"worker-race-{uuid4().hex}"
existing_session_id = f"worker-race-existing-{uuid4().hex}"
persistence = AgentChatPersistenceService(
repository=AgentChatOper,
repository=lambda session: AgentChatOper(session),
async_executor=worker,
sync_transaction=run_sync_transaction,
)
async def append(content: str) -> None:
+26
View File
@@ -4,6 +4,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import app.agent.orchestrator as agent_module
from app.application.messaging.agent import (
create_web_agent_background_task,
shutdown_web_agent_background_tasks,
)
from app.agent.orchestrator import (
AGENT_SESSION_QUEUE_MAX_SIZE,
AgentManager,
@@ -14,6 +18,28 @@ from app.agent.memory import MemoryManager
from app.startup import agent_initializer, modules_initializer
@pytest.mark.anyio
async def test_web_agent_background_tasks_are_cancelled_and_drained() -> None:
"""Web Agent 任务关闭后不得继续占用循环或提交晚到的快照。"""
started = asyncio.Event()
finished = asyncio.Event()
async def blocked_task() -> None:
started.set()
try:
await asyncio.Event().wait()
finally:
finished.set()
task = create_web_agent_background_task(blocked_task())
await started.wait()
await shutdown_web_agent_background_tasks()
assert task.done()
assert task.cancelled()
assert finished.is_set()
@pytest.mark.anyio
async def test_agent_entrypoint_initializes_on_calling_loop(monkeypatch) -> None:
"""Agent 启动入口必须在应用主循环完成初始化。"""
+12 -2
View File
@@ -13,6 +13,7 @@ from app.api.context import (
get_agent_chat_repository,
get_agent_chat_transaction,
)
from app.api.dependencies.agent import get_agent_chat_persistence
from app.startup import lifecycle
from app.startup.context import (
AgentChatRuntime,
@@ -59,6 +60,10 @@ class _UnitOfWork:
"""模拟回滚。"""
class _AgentChatPersistence:
"""提供 AgentChat 运行时所需的最小写端口。"""
class _SyncUnitOfWork:
"""记录绑定会话的同步事务替身。"""
@@ -119,6 +124,7 @@ def _runtime() -> HostRuntime:
async_session=async_session,
repository=_Repository,
transaction=_UnitOfWork,
persistence=_AgentChatPersistence(),
),
persistence=PersistenceRuntime(
sync_session=sync_session,
@@ -186,15 +192,19 @@ def test_fastapi_dependencies_use_fake_runtime_without_real_services() -> None:
async def probe(
repository=Depends(get_agent_chat_repository),
unit_of_work=Depends(get_agent_chat_transaction),
persistence=Depends(get_agent_chat_persistence),
) -> dict[str, bool]:
"""返回两个类型化能力是否绑定同一请求会话。"""
return {"same_session": repository.session is unit_of_work.session}
return {
"same_session": repository.session is unit_of_work.session,
"has_persistence": persistence is app.state.host_runtime.agent_chat.persistence,
}
with TestClient(app) as client:
response = client.get("/probe")
assert response.status_code == 200
assert response.json() == {"same_session": True}
assert response.json() == {"same_session": True, "has_persistence": True}
def test_official_api_dependencies_do_not_use_string_data_locator() -> None:
+29
View File
@@ -570,6 +570,35 @@ def test_stop_modules_continues_after_internal_owner_failures(monkeypatch):
_assert_completed_once(dependency)
def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
"""关闭时必须先收口 Web Agent 后台任务,再关闭会话持久化端口。"""
order = []
monkeypatch.setattr(modules_initializer, "stop_agent", AsyncMock())
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
monkeypatch.setattr(
modules_initializer,
"shutdown_web_agent_background_tasks",
AsyncMock(side_effect=lambda: order.append("web-agent")),
)
persistence = MagicMock()
persistence.shutdown = AsyncMock(side_effect=lambda: order.append("persistence"))
monkeypatch.setattr(
modules_initializer,
"get_configured_agent_chat_persistence",
MagicMock(return_value=persistence),
)
monkeypatch.setattr(
modules_initializer,
"stop_database_worker",
AsyncMock(side_effect=lambda: order.append("database")),
)
monkeypatch.setattr(modules_initializer, "_database_worker", object())
asyncio.run(modules_initializer.stop_modules())
assert order == ["web-agent", "persistence", "database"]
def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
"""替换 stop_modules 的资源所有者,避免测试启动真实后台服务"""
dependencies = {}