mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
fix(agent): close async chat persistence lifecycle
This commit is contained in:
+2
-1
@@ -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()))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 启动入口必须在应用主循环完成初始化。"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user