refactor(database): isolate configuration transactions

This commit is contained in:
InfinityPacer
2026-08-23 02:29:22 +08:00
parent e99289a07b
commit 3d7fb44dc7
23 changed files with 1071 additions and 153 deletions
+31 -1
View File
@@ -3,6 +3,7 @@
引导与网络守卫均复用 ``app/testing`` 的共享 harness(与插件仓 conftest 同源),
引导逻辑只在 ``app/testing`` 维护一处。
"""
import asyncio
import sys
import pytest
@@ -20,6 +21,14 @@ prepare_backend()
from app.testing.network_guard import block_real_network # noqa: E402,F401
class _TestDatabaseExecutor:
"""让绕过完整 lifespan 的测试仍通过线程执行同步数据库写入。"""
async def run(self, operation):
"""在线程中执行测试事务。"""
return await asyncio.to_thread(operation)
@pytest.fixture(autouse=True)
def configure_plugin_system_services():
"""为绕过完整启动流程的单元测试装配真实插件系统适配器。"""
@@ -61,6 +70,11 @@ def configure_plugin_system_services():
configure_transaction_runners,
)
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.userconfig import UserConfigOper
from app.application.security.userconfig import (
UserConfigurationService,
configure_user_configuration,
)
configure_token_codec(create_access_token, decode_access_token)
configure_runtime_configuration(
@@ -73,7 +87,23 @@ def configure_plugin_system_services():
configure_runtime_settings(RuntimeSettingsService(settings))
configure_runtime_setting_provider(lambda key: getattr(settings, key))
configure_token_runtime_config(lambda: build_token_runtime_config(settings))
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
database_executor = _TestDatabaseExecutor()
system_config = SystemConfigOper()
system_config.load_snapshot()
user_config = UserConfigOper()
user_config.load_snapshot()
configure_system_config(
SystemConfigService(
repository=system_config,
async_executor=database_executor,
)
)
configure_user_configuration(
UserConfigurationService(
repository=user_config,
async_executor=database_executor,
)
)
configure_transfer_retry_config(
lambda: TransferRetryConfig(
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
+4 -1
View File
@@ -138,7 +138,10 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
monkeypatch.setattr(modules_initializer, "start_frontend", start_frontend)
monkeypatch.setattr(modules_initializer, "check_auth", check_auth)
await modules_initializer.init_modules()
try:
await modules_initializer.init_modules()
finally:
await modules_initializer.stop_database_worker()
manager.initialize.assert_awaited_once_with()
start_frontend.assert_called_once_with()
+7 -1
View File
@@ -164,7 +164,13 @@ def test_init_modules_does_not_clear_package_tool_cache(monkeypatch):
monkeypatch.setattr(modules_initializer, "start_frontend", lambda: None)
monkeypatch.setattr(modules_initializer, "check_auth", lambda: None)
asyncio.run(modules_initializer.init_modules())
async def initialize_modules() -> None:
try:
await modules_initializer.init_modules()
finally:
await modules_initializer.stop_database_worker()
asyncio.run(initialize_modules())
assert called is False
init_agent.assert_awaited_once_with()
+104
View File
@@ -0,0 +1,104 @@
"""配置快照启动顺序测试。"""
from unittest.mock import AsyncMock
import pytest
from app.startup import modules_initializer
from app.startup.lifecycle import initialize_modules_component
class _InlineWorker:
"""按提交顺序执行配置加载操作。"""
async def run(self, operation):
"""执行并返回操作结果。"""
return operation()
@pytest.mark.asyncio
async def test_configuration_services_publish_after_both_snapshots_load(
monkeypatch,
) -> None:
"""两个完整快照加载成功前不发布任一配置服务。"""
events = []
class _SystemConfig:
def load_snapshot(self):
events.append("load-system")
class _UserConfig:
def load_snapshot(self):
events.append("load-user")
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
monkeypatch.setattr(
modules_initializer,
"configure_system_config",
lambda _service: events.append("publish-system"),
)
monkeypatch.setattr(
modules_initializer,
"configure_user_configuration",
lambda _service: events.append("publish-user"),
)
await modules_initializer._initialize_configuration_services(_InlineWorker())
assert events == [
"load-system",
"load-user",
"publish-system",
"publish-user",
]
@pytest.mark.asyncio
async def test_configuration_load_failure_does_not_publish_partial_service(
monkeypatch,
) -> None:
"""任一快照加载失败时不发布半套配置服务。"""
published = []
class _SystemConfig:
def load_snapshot(self):
return None
class _UserConfig:
def load_snapshot(self):
raise RuntimeError("load failed")
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
monkeypatch.setattr(
modules_initializer,
"configure_system_config",
lambda service: published.append(service),
)
monkeypatch.setattr(
modules_initializer,
"configure_user_configuration",
lambda service: published.append(service),
)
with pytest.raises(RuntimeError, match="load failed"):
await modules_initializer._initialize_configuration_services(_InlineWorker())
assert published == []
@pytest.mark.asyncio
async def test_modules_startup_failure_stops_database_worker(monkeypatch) -> None:
"""模块启动失败时立即关闭已创建的数据库 worker。"""
monkeypatch.setattr(
"app.startup.lifecycle.init_modules",
AsyncMock(side_effect=RuntimeError("startup failed")),
)
stop_worker = AsyncMock()
monkeypatch.setattr(modules_initializer, "stop_database_worker", stop_worker)
with pytest.raises(RuntimeError, match="startup failed"):
await initialize_modules_component(object())
stop_worker.assert_awaited_once_with()
+36 -6
View File
@@ -2,7 +2,7 @@
import asyncio
from dataclasses import FrozenInstanceError
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
import pytest
@@ -19,6 +19,15 @@ from app.application.configuration import (
get_api_runtime_config_snapshot,
get_transfer_retry_config,
)
from app.application.security.userconfig import UserConfigurationService
class _InlineDatabaseExecutor:
"""同步执行测试操作,并保留异步应用端口的调用形态。"""
async def run(self, operation):
"""执行并返回操作结果。"""
return operation()
class _MutableSettings:
@@ -66,23 +75,44 @@ def test_system_config_service_supports_separate_reader_and_writer() -> None:
"""应用服务可以分别注入只读与写入适配器。"""
reader = MagicMock()
reader.get.return_value = "old"
reader.async_get = AsyncMock(return_value="async-old")
writer = MagicMock()
writer.set.return_value = True
writer.async_set = AsyncMock(return_value=True)
service = SystemConfigService(reader=reader, writer=writer)
service = SystemConfigService(
reader=reader,
writer=writer,
async_executor=_InlineDatabaseExecutor(),
)
assert service.get("key") == "old"
assert service.set("key", "new") is True
assert asyncio.run(service.async_get("key")) == "async-old"
assert asyncio.run(service.async_set("key", "new")) is True
service.delete("key")
reader.get.assert_called_once_with("key")
writer.set.assert_called_once_with("key", "new")
assert writer.set.call_args_list == [
(("key", "new"), {}),
(("key", "new"), {}),
]
writer.delete.assert_called_once_with("key")
def test_user_configuration_service_supports_sync_and_async_writes() -> None:
"""用户配置服务的同步与异步入口执行同一仓储方法。"""
repository = MagicMock()
repository.set.return_value = True
service = UserConfigurationService(
repository,
async_executor=_InlineDatabaseExecutor(),
)
assert service.set("alice", "theme", "dark") is True
assert asyncio.run(service.async_set("alice", "theme", "light")) is True
assert repository.set.call_args_list == [
((), {"username": "alice", "key": "theme", "value": "dark"}),
((), {"username": "alice", "key": "theme", "value": "light"}),
]
def test_transfer_retry_provider_returns_frozen_snapshot_per_call() -> None:
"""配置工厂在每次用例入口创建新快照,旧快照不受 reload 后状态影响。"""
state = {"value": 2}
+140
View File
@@ -0,0 +1,140 @@
"""数据库短事务 worker 的容量、取消与关闭合同测试。"""
import asyncio
import threading
import pytest
from app.db.worker import (
DatabaseWorker,
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
)
@pytest.mark.asyncio
async def test_worker_requires_explicit_start() -> None:
"""构造对象不会隐式创建可执行线程池。"""
worker = DatabaseWorker(max_workers=1, capacity=1)
with pytest.raises(DatabaseWorkerClosedError):
await worker.run(lambda: None)
@pytest.mark.asyncio
async def test_worker_rejects_work_beyond_running_and_queue_capacity() -> None:
"""运行与排队任务达到总容量后立即拒绝新任务。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
started = threading.Event()
release = threading.Event()
def block() -> None:
started.set()
release.wait(1)
running = asyncio.create_task(worker.run(block))
await asyncio.to_thread(started.wait)
queued = asyncio.create_task(worker.run(lambda: None))
await asyncio.sleep(0)
with pytest.raises(DatabaseWorkerOverloadedError):
await worker.run(lambda: None)
assert worker.snapshot().running == 1
assert worker.snapshot().queued == 1
assert worker.snapshot().rejected == 1
release.set()
await asyncio.gather(running, queued)
await worker.shutdown()
@pytest.mark.asyncio
async def test_cancelling_queued_work_prevents_execution() -> None:
"""尚未取得线程的任务取消后不得执行数据库操作。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
started = threading.Event()
release = threading.Event()
queued_executed = threading.Event()
def block() -> None:
started.set()
release.wait(1)
running = asyncio.create_task(worker.run(block))
await asyncio.to_thread(started.wait)
queued = asyncio.create_task(worker.run(queued_executed.set))
await asyncio.sleep(0)
queued.cancel()
with pytest.raises(asyncio.CancelledError):
await queued
release.set()
await running
await worker.shutdown()
assert queued_executed.is_set() is False
assert worker.snapshot().queued == 0
assert worker.snapshot().running == 0
@pytest.mark.asyncio
async def test_cancelling_running_work_waits_for_transaction_terminal_state() -> None:
"""线程内操作开始后,取消结果必须晚于操作的最终状态。"""
worker = DatabaseWorker(max_workers=1, capacity=1)
await worker.start()
started = threading.Event()
release = threading.Event()
completed = threading.Event()
def operation() -> None:
started.set()
release.wait(1)
completed.set()
task = asyncio.create_task(worker.run(operation))
await asyncio.to_thread(started.wait)
task.cancel()
await asyncio.sleep(0.01)
assert task.done() is False
assert completed.is_set() is False
release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert completed.is_set() is True
await worker.shutdown()
@pytest.mark.asyncio
async def test_shutdown_rejects_new_work_and_waits_for_running_work() -> None:
"""关闭期间不接收新任务,并等待已开始的操作结束。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
started = threading.Event()
release = threading.Event()
def operation() -> None:
started.set()
release.wait(1)
running = asyncio.create_task(worker.run(operation))
await asyncio.to_thread(started.wait)
shutdown = asyncio.create_task(worker.shutdown())
await asyncio.sleep(0)
with pytest.raises(DatabaseWorkerClosedError):
await worker.run(lambda: None)
assert shutdown.done() is False
release.set()
await running
await shutdown
assert worker.snapshot().closing is True
assert worker.snapshot().queued == 0
assert worker.snapshot().running == 0
+119 -6
View File
@@ -1,6 +1,7 @@
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import pytest
@@ -16,15 +17,105 @@ def _unique_key() -> str:
def _fresh_oper() -> SystemConfigOper:
"""重置单例并从数据库重新加载配置缓存"""
"""重置单例并显式加载数据库配置快照"""
Singleton._instances.pop((SystemConfigOper, (), frozenset()), None)
return SystemConfigOper()
oper = SystemConfigOper()
oper.load_snapshot()
return oper
def test_constructor_does_not_query_database(monkeypatch):
"""构造配置对象时不打开数据库会话。"""
Singleton._instances.pop((SystemConfigOper, (), frozenset()), None)
monkeypatch.setattr(
SystemConfig,
"list",
lambda _db: pytest.fail("构造阶段不应查询数据库"),
)
oper = SystemConfigOper()
with pytest.raises(RuntimeError, match="快照尚未加载"):
oper.get("key")
def test_load_snapshot_publishes_complete_dictionary(monkeypatch):
"""重新加载期间读取方只会看到完整旧快照或完整新快照。"""
Singleton._instances.pop((SystemConfigOper, (), frozenset()), None)
oper = SystemConfigOper()
values = [SimpleNamespace(key="key", value="old")]
entered = threading.Event()
release = threading.Event()
monkeypatch.setattr(SystemConfig, "list", lambda _db: values)
oper.load_snapshot()
def load_new_snapshot(_db):
entered.set()
release.wait(1)
return [SimpleNamespace(key="key", value="new")]
monkeypatch.setattr(SystemConfig, "list", load_new_snapshot)
thread = threading.Thread(target=oper.load_snapshot)
thread.start()
assert entered.wait(1)
assert oper.get("key") == "old"
release.set()
thread.join(1)
assert thread.is_alive() is False
assert oper.get("key") == "new"
def test_read_does_not_wait_for_slow_write_transaction(monkeypatch):
"""数据库写入期间,内存读取仍返回最近一次已提交值。"""
oper = _fresh_oper()
key = _unique_key()
oper.set(key, "old")
entered = threading.Event()
release = threading.Event()
def slow_write(_operation):
entered.set()
release.wait(1)
return True
monkeypatch.setattr(oper, "_execute_sync_write", slow_write)
thread = threading.Thread(target=lambda: oper.set(key, "new"))
thread.start()
assert entered.wait(1)
assert oper.get(key) == "old"
release.set()
thread.join(1)
assert thread.is_alive() is False
assert oper.get(key) == "new"
def test_failed_write_keeps_committed_snapshot(monkeypatch):
"""事务失败时内存快照保持最近一次已提交值。"""
oper = _fresh_oper()
key = _unique_key()
oper.set(key, "old")
def fail_write(_operation):
raise RuntimeError("write failed")
monkeypatch.setattr(oper, "_execute_sync_write", fail_write)
with pytest.raises(RuntimeError, match="write failed"):
oper.set(key, "new")
assert oper.get(key) == "old"
def test_increment_serializes_concurrent_counter_updates(monkeypatch):
"""并发递增系统计数时不应丢失更新。"""
oper = object.__new__(SystemConfigOper)
oper._rlock = threading.RLock()
oper._snapshot_lock = threading.RLock()
oper._write_lock = threading.RLock()
oper._loaded = True
stored_value = {"value": 0}
monkeypatch.setattr(oper, "get", lambda _key: stored_value["value"])
@@ -51,7 +142,9 @@ def test_increment_serializes_concurrent_counter_updates(monkeypatch):
def test_increment_supports_custom_step(monkeypatch):
"""整数系统计数应支持指定递增步长。"""
oper = object.__new__(SystemConfigOper)
oper._rlock = threading.RLock()
oper._snapshot_lock = threading.RLock()
oper._write_lock = threading.RLock()
oper._loaded = True
stored_value = {"value": 4}
monkeypatch.setattr(oper, "get", lambda _key: stored_value["value"])
@@ -95,7 +188,17 @@ async def test_async_set_persists_falsy_value_on_existing_record():
oper = _fresh_oper()
oper.set(key, True)
assert await oper.async_set(key, False) is True
from app.application.configuration import SystemConfigService
class _InlineDatabaseExecutor:
async def run(self, operation):
return operation()
service = SystemConfigService(
repository=oper,
async_executor=_InlineDatabaseExecutor(),
)
assert await service.async_set(key, False) is True
assert oper.get(key) is False
assert SystemConfig.get_by_key(oper._db, key).value is False
@@ -106,7 +209,17 @@ async def test_async_set_creates_record_for_falsy_value():
key = _unique_key()
oper = _fresh_oper()
assert await oper.async_set(key, 0) is True
from app.application.configuration import SystemConfigService
class _InlineDatabaseExecutor:
async def run(self, operation):
return operation()
service = SystemConfigService(
repository=oper,
async_executor=_InlineDatabaseExecutor(),
)
assert await service.async_set(key, 0) is True
assert oper.get(key) == 0
assert SystemConfig.get_by_key(oper._db, key).value == 0
+54
View File
@@ -0,0 +1,54 @@
"""用户配置 API 的异步应用端口测试。"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.api.endpoints import user as user_endpoint
@pytest.mark.asyncio
async def test_set_config_waits_for_async_configuration_write(monkeypatch) -> None:
"""更新接口等待用户配置事务完成后再返回成功。"""
service = MagicMock()
service.async_set = AsyncMock(return_value=True)
monkeypatch.setattr(
user_endpoint,
"get_configured_user_configuration",
lambda: service,
)
response = await user_endpoint.set_config(
"theme",
"dark",
current_user=SimpleNamespace(name="alice"),
)
assert response.success is True
service.async_set.assert_awaited_once_with(
username="alice",
key="theme",
value="dark",
)
@pytest.mark.asyncio
async def test_get_config_reads_loaded_snapshot(monkeypatch) -> None:
"""查询接口直接读取已加载的用户配置快照。"""
service = MagicMock()
service.get.return_value = "dark"
monkeypatch.setattr(
user_endpoint,
"get_configured_user_configuration",
lambda: service,
)
response = await user_endpoint.get_config(
"theme",
current_user=SimpleNamespace(name="alice"),
)
assert response.success is True
assert response.data == {"value": "dark"}
service.get.assert_called_once_with(username="alice", key="theme")
+93
View File
@@ -0,0 +1,93 @@
"""用户配置快照与异步写入合同测试。"""
import asyncio
import threading
from types import SimpleNamespace
import pytest
from app.application.security.userconfig import UserConfigurationService
from app.db.models.userconfig import UserConfig
from app.db.oper.userconfig import UserConfigOper
from app.foundation.singleton import Singleton
class _ThreadDatabaseExecutor:
"""在线程中执行测试事务。"""
async def run(self, operation):
"""执行并返回操作结果。"""
return await asyncio.to_thread(operation)
def _fresh_oper() -> UserConfigOper:
"""重置单例并显式加载用户配置快照。"""
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
oper = UserConfigOper()
oper.load_snapshot()
return oper
def test_constructor_does_not_query_database(monkeypatch):
"""构造用户配置对象时不打开数据库会话。"""
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
monkeypatch.setattr(
UserConfig,
"list",
lambda _db: pytest.fail("构造阶段不应查询数据库"),
)
oper = UserConfigOper()
with pytest.raises(RuntimeError, match="快照尚未加载"):
oper.get("alice", "theme")
def test_load_snapshot_publishes_complete_dictionary(monkeypatch):
"""重新加载期间读取方不会看到逐项构造的用户配置。"""
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
oper = UserConfigOper()
monkeypatch.setattr(
UserConfig,
"list",
lambda _db: [SimpleNamespace(username="alice", key="theme", value="old")],
)
oper.load_snapshot()
entered = threading.Event()
release = threading.Event()
def load_new_snapshot(_db):
entered.set()
release.wait(1)
return [SimpleNamespace(username="alice", key="theme", value="new")]
monkeypatch.setattr(UserConfig, "list", load_new_snapshot)
thread = threading.Thread(target=oper.load_snapshot)
thread.start()
assert entered.wait(1)
assert oper.get("alice", "theme") == "old"
release.set()
thread.join(1)
assert thread.is_alive() is False
assert oper.get("alice", "theme") == "new"
@pytest.mark.asyncio
async def test_async_write_uses_same_repository_rule() -> None:
"""异步入口提交后同步读取立即看到相同结果。"""
oper = _fresh_oper()
service = UserConfigurationService(
oper,
async_executor=_ThreadDatabaseExecutor(),
)
await service.async_set("async-user", "theme", "dark")
assert service.get("async-user", "theme") == "dark"
assert UserConfig.get_by_key(
oper._db,
username="async-user",
key="theme",
).value == "dark"