refactor: finish transactional runtime migration

This commit is contained in:
jxxghp
2026-08-22 15:18:16 +08:00
parent fe2e6809f7
commit be18cace1f
59 changed files with 1006 additions and 568 deletions
+22 -1
View File
@@ -6,6 +6,7 @@
import sys
import pytest
from sqlalchemy.orm import Session
# 必须早于首个牵入 app.runtime.config 的 importapp.db / app.chain.* 都会牵入):引擎本身已惰性,
# import app.db 不再连库,但 settings 在 import 期就把 CONFIG_DIR 读进字段并建好配置目录,之后
@@ -47,7 +48,11 @@ def configure_plugin_system_services():
get_async_db,
get_db,
)
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.db.uow import (
SqlAlchemyAsyncUnitOfWork,
SqlAlchemyUnitOfWork,
configure_transaction_runners,
)
from app.db.oper.systemconfig import SystemConfigOper
configure_token_codec(create_access_token, decode_access_token)
@@ -113,6 +118,22 @@ def configure_plugin_system_services():
from app.db.oper.passkey import PassKeyOper
from app.startup.subscription import TransactionalSubscribeWriter
from app.startup.workflow import TransactionalWorkflowExecutionService
from app.startup.transaction import TransactionalWriteRunner
def compatibility_sync_session() -> Session:
"""动态读取可被存量隔离数据库用例替换的 ScopedSession。"""
from app.db import decorators
return decorators.ScopedSession()
transaction_runner = TransactionalWriteRunner(
sync_session=compatibility_sync_session,
async_session=async_session_scope,
)
configure_transaction_runners(
sync=transaction_runner.sync,
async_=transaction_runner.async_,
)
configure_workflow_legacy_writer(
TransactionalWorkflowExecutionService(SessionFactory)
+26 -22
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6376,
"edge_sha256": "11152e1c89a0d5f07d5460e8a21a8399b9280b8e3027f66621d839817ac3db21",
"edge_count": 6379,
"edge_sha256": "1c619d72157004590838a85c497330b8ca462d3bc1eb490fd5e7bac97e6190b8",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -1506,21 +1506,18 @@
"app.api.context -> app.startup.context",
"app.api.dependencies.agent -> app.api",
"app.api.dependencies.agent -> app.api.context",
"app.api.dependencies.agent -> app.api.data",
"app.api.dependencies.agent -> app.api.dependencies",
"app.api.dependencies.agent -> app.api.dependencies.data",
"app.api.dependencies.agent -> app.application",
"app.api.dependencies.agent -> app.application.messaging",
"app.api.dependencies.agent -> app.application.messaging.chat",
"app.api.dependencies.agent -> app.application.messaging.message",
"app.api.dependencies.agent -> app.startup",
"app.api.dependencies.agent -> app.startup.context",
"app.api.dependencies.auth -> app.adapters",
"app.api.dependencies.auth -> app.adapters.web",
"app.api.dependencies.auth -> app.adapters.web.security",
"app.api.dependencies.auth -> app.adapters.web.security.access",
"app.api.dependencies.auth -> app.api",
"app.api.dependencies.auth -> app.api.data",
"app.api.dependencies.auth -> app.api.dependencies",
"app.api.dependencies.auth -> app.api.dependencies.data",
"app.api.dependencies.auth -> app.api.context",
"app.api.dependencies.auth -> app.application",
"app.api.dependencies.auth -> app.application.security",
"app.api.dependencies.auth -> app.application.security.auth",
@@ -1528,12 +1525,12 @@
"app.api.dependencies.auth -> app.application.security.user",
"app.api.dependencies.auth -> app.schemas",
"app.api.dependencies.auth -> app.schemas.token",
"app.api.dependencies.auth -> app.startup",
"app.api.dependencies.auth -> app.startup.context",
"app.api.dependencies.data -> app.api",
"app.api.dependencies.data -> app.api.data",
"app.api.dependencies.history -> app.api",
"app.api.dependencies.history -> app.api.data",
"app.api.dependencies.history -> app.api.dependencies",
"app.api.dependencies.history -> app.api.dependencies.data",
"app.api.dependencies.history -> app.api.context",
"app.api.dependencies.history -> app.application",
"app.api.dependencies.history -> app.application.dashboard",
"app.api.dependencies.history -> app.application.history",
@@ -1546,6 +1543,8 @@
"app.api.dependencies.history -> app.schemas",
"app.api.dependencies.history -> app.schemas.types",
"app.api.dependencies.history -> app.schemas.workflow",
"app.api.dependencies.history -> app.startup",
"app.api.dependencies.history -> app.startup.context",
"app.api.dependencies.plugin -> app.application",
"app.api.dependencies.plugin -> app.application.commands",
"app.api.dependencies.plugin -> app.application.plugin",
@@ -1559,9 +1558,7 @@
"app.api.dependencies.plugin -> app.schemas.event",
"app.api.dependencies.plugin -> app.schemas.types",
"app.api.dependencies.site -> app.api",
"app.api.dependencies.site -> app.api.data",
"app.api.dependencies.site -> app.api.dependencies",
"app.api.dependencies.site -> app.api.dependencies.data",
"app.api.dependencies.site -> app.api.context",
"app.api.dependencies.site -> app.application",
"app.api.dependencies.site -> app.application.site",
"app.api.dependencies.site -> app.application.site.mutation",
@@ -1574,14 +1571,13 @@
"app.api.dependencies.site -> app.runtime.events",
"app.api.dependencies.site -> app.schemas",
"app.api.dependencies.site -> app.schemas.types",
"app.api.dependencies.site -> app.startup",
"app.api.dependencies.site -> app.startup.context",
"app.api.dependencies.subscription -> app.adapters",
"app.api.dependencies.subscription -> app.adapters.external",
"app.api.dependencies.subscription -> app.adapters.external.server",
"app.api.dependencies.subscription -> app.api",
"app.api.dependencies.subscription -> app.api.context",
"app.api.dependencies.subscription -> app.api.data",
"app.api.dependencies.subscription -> app.api.dependencies",
"app.api.dependencies.subscription -> app.api.dependencies.data",
"app.api.dependencies.subscription -> app.application",
"app.api.dependencies.subscription -> app.application.outbox",
"app.api.dependencies.subscription -> app.application.scheduling",
@@ -1597,18 +1593,20 @@
"app.api.dependencies.subscription -> app.runtime.log",
"app.api.dependencies.subscription -> app.schemas",
"app.api.dependencies.subscription -> app.schemas.types",
"app.api.dependencies.subscription -> app.startup",
"app.api.dependencies.subscription -> app.startup.context",
"app.api.dependencies.workflow -> app.adapters",
"app.api.dependencies.workflow -> app.adapters.external",
"app.api.dependencies.workflow -> app.adapters.external.server",
"app.api.dependencies.workflow -> app.api",
"app.api.dependencies.workflow -> app.api.data",
"app.api.dependencies.workflow -> app.api.dependencies",
"app.api.dependencies.workflow -> app.api.dependencies.data",
"app.api.dependencies.workflow -> app.api.context",
"app.api.dependencies.workflow -> app.application",
"app.api.dependencies.workflow -> app.application.scheduling",
"app.api.dependencies.workflow -> app.application.workflow",
"app.api.dependencies.workflow -> app.runtime",
"app.api.dependencies.workflow -> app.runtime.config",
"app.api.dependencies.workflow -> app.startup",
"app.api.dependencies.workflow -> app.startup.context",
"app.api.dependencies.workflow -> app.workflow",
"app.api.deps -> app.api",
"app.api.deps -> app.api.dependencies",
@@ -3422,6 +3420,7 @@
"app.command -> app.schemas.types",
"app.db.base -> app.db",
"app.db.base -> app.db.decorators",
"app.db.base -> app.db.uow",
"app.db.base -> app.runtime",
"app.db.base -> app.runtime.config",
"app.db.decorators -> app.db",
@@ -3445,6 +3444,7 @@
"app.db.maintenance -> app.db.models.message",
"app.db.maintenance -> app.db.models.siteuserdata",
"app.db.maintenance -> app.db.models.transferhistory",
"app.db.maintenance -> app.db.uow",
"app.db.models -> app.db",
"app.db.models -> app.db.models._identity",
"app.db.models._identity -> app.runtime",
@@ -3464,7 +3464,6 @@
"app.db.models.agenttaskrun -> app.db.models.agenttask",
"app.db.models.downloadfailure -> app.db",
"app.db.models.downloadfailure -> app.db.base",
"app.db.models.downloadfailure -> app.db.decorators",
"app.db.models.downloadfailure -> app.db.models",
"app.db.models.downloadfailure -> app.db.models._constraints",
"app.db.models.downloadhistory -> app.db",
@@ -6114,6 +6113,7 @@
"app.startup.modules_initializer -> app.runtime.extensions.plugin_manager",
"app.startup.modules_initializer -> app.runtime.extensions.service_config",
"app.startup.modules_initializer -> app.runtime.log",
"app.startup.modules_initializer -> app.runtime.observability",
"app.startup.modules_initializer -> app.runtime.state",
"app.startup.modules_initializer -> app.runtime.thread",
"app.startup.modules_initializer -> app.scheduler",
@@ -6129,6 +6129,7 @@
"app.startup.modules_initializer -> app.startup.managed_resources_initializer",
"app.startup.modules_initializer -> app.startup.outbox",
"app.startup.modules_initializer -> app.startup.subscription",
"app.startup.modules_initializer -> app.startup.transaction",
"app.startup.modules_initializer -> app.startup.workflow",
"app.startup.monitor_initializer -> app.monitor",
"app.startup.outbox -> app.application",
@@ -6208,6 +6209,8 @@
"app.startup.subscription -> app.schemas.types",
"app.startup.subscription -> app.startup",
"app.startup.subscription -> app.startup.outbox",
"app.startup.transaction -> app.db",
"app.startup.transaction -> app.db.uow",
"app.startup.transfer_initializer -> app.chain",
"app.startup.transfer_initializer -> app.chain.transfer",
"app.startup.workflow -> app.application",
@@ -6393,7 +6396,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 790,
"module_count": 791,
"modules": [
"app",
"app.adapters",
@@ -7161,6 +7164,7 @@
"app.startup.routers_initializer",
"app.startup.scheduler_initializer",
"app.startup.subscription",
"app.startup.transaction",
"app.startup.transfer_initializer",
"app.startup.workflow",
"app.startup.workflow_initializer",
+3 -228
View File
@@ -2,11 +2,11 @@
"model_decorators": {
"by_kind": {
"async_db_query": 49,
"async_db_update": 12,
"async_db_update": 0,
"db_query": 74,
"db_update": 33
"db_update": 0
},
"count": 168,
"count": 123,
"methods": [
{
"decorator": "async_db_query",
@@ -28,11 +28,6 @@
"file": "app/db/models/agentchat.py",
"method": "AgentChat.list_by_page"
},
{
"decorator": "db_update",
"file": "app/db/models/agenttask.py",
"method": "AgentTask.add_task"
},
{
"decorator": "db_query",
"file": "app/db/models/agenttask.py",
@@ -43,56 +38,16 @@
"file": "app/db/models/agenttask.py",
"method": "AgentTask.list_for_user"
},
{
"decorator": "db_update",
"file": "app/db/models/agenttask.py",
"method": "AgentTask.update_task"
},
{
"decorator": "db_update",
"file": "app/db/models/agenttaskrun.py",
"method": "AgentTaskRun.begin_run"
},
{
"decorator": "db_update",
"file": "app/db/models/agenttaskrun.py",
"method": "AgentTaskRun.delete_task_and_runs"
},
{
"decorator": "db_update",
"file": "app/db/models/agenttaskrun.py",
"method": "AgentTaskRun.finish_run"
},
{
"decorator": "db_query",
"file": "app/db/models/agenttaskrun.py",
"method": "AgentTaskRun.get_by_run_id"
},
{
"decorator": "db_update",
"file": "app/db/models/agenttaskrun.py",
"method": "AgentTaskRun.interrupt_task"
},
{
"decorator": "db_query",
"file": "app/db/models/agenttaskrun.py",
"method": "AgentTaskRun.list_for_task"
},
{
"decorator": "db_update",
"file": "app/db/models/downloadfailure.py",
"method": "DownloadFailure.delete_expired"
},
{
"decorator": "db_update",
"file": "app/db/models/downloadhistory.py",
"method": "DownloadFiles.delete_by_fullpath"
},
{
"decorator": "db_update",
"file": "app/db/models/downloadhistory.py",
"method": "DownloadFiles.delete_orphans"
},
{
"decorator": "db_query",
"file": "app/db/models/downloadhistory.py",
@@ -128,11 +83,6 @@
"file": "app/db/models/downloadhistory.py",
"method": "DownloadHistory.async_list_by_title"
},
{
"decorator": "db_update",
"file": "app/db/models/downloadhistory.py",
"method": "DownloadHistory.delete_before"
},
{
"decorator": "db_query",
"file": "app/db/models/downloadhistory.py",
@@ -193,21 +143,6 @@
"file": "app/db/models/mediaserver.py",
"method": "MediaServerItem.async_get_by_itemid"
},
{
"decorator": "db_update",
"file": "app/db/models/mediaserver.py",
"method": "MediaServerItem.delete_excluded_servers"
},
{
"decorator": "db_update",
"file": "app/db/models/mediaserver.py",
"method": "MediaServerItem.delete_stale"
},
{
"decorator": "db_update",
"file": "app/db/models/mediaserver.py",
"method": "MediaServerItem.empty"
},
{
"decorator": "db_query",
"file": "app/db/models/mediaserver.py",
@@ -238,16 +173,6 @@
"file": "app/db/models/message.py",
"method": "Message.async_list_sent_by_page"
},
{
"decorator": "db_update",
"file": "app/db/models/message.py",
"method": "Message.create_and_to_dict"
},
{
"decorator": "db_update",
"file": "app/db/models/message.py",
"method": "Message.delete_before"
},
{
"decorator": "db_query",
"file": "app/db/models/message.py",
@@ -258,11 +183,6 @@
"file": "app/db/models/message.py",
"method": "Message.list_by_page"
},
{
"decorator": "async_db_update",
"file": "app/db/models/passkey.py",
"method": "PassKey.async_delete_by_id"
},
{
"decorator": "async_db_query",
"file": "app/db/models/passkey.py",
@@ -278,16 +198,6 @@
"file": "app/db/models/passkey.py",
"method": "PassKey.async_get_by_user_id"
},
{
"decorator": "async_db_update",
"file": "app/db/models/passkey.py",
"method": "PassKey.async_update_last_used"
},
{
"decorator": "db_update",
"file": "app/db/models/passkey.py",
"method": "PassKey.delete_by_id"
},
{
"decorator": "db_query",
"file": "app/db/models/passkey.py",
@@ -303,11 +213,6 @@
"file": "app/db/models/passkey.py",
"method": "PassKey.get_by_user_id"
},
{
"decorator": "db_update",
"file": "app/db/models/passkey.py",
"method": "PassKey.update_last_used"
},
{
"decorator": "async_db_query",
"file": "app/db/models/plugindata.py",
@@ -323,16 +228,6 @@
"file": "app/db/models/plugindata.py",
"method": "PluginData.async_get_plugin_data_by_plugin_id"
},
{
"decorator": "db_update",
"file": "app/db/models/plugindata.py",
"method": "PluginData.del_plugin_data"
},
{
"decorator": "db_update",
"file": "app/db/models/plugindata.py",
"method": "PluginData.del_plugin_data_by_key"
},
{
"decorator": "db_query",
"file": "app/db/models/plugindata.py",
@@ -368,11 +263,6 @@
"file": "app/db/models/site.py",
"method": "Site.async_list_order_by_pri"
},
{
"decorator": "async_db_update",
"file": "app/db/models/site.py",
"method": "Site.async_reset"
},
{
"decorator": "db_query",
"file": "app/db/models/site.py",
@@ -393,11 +283,6 @@
"file": "app/db/models/site.py",
"method": "Site.list_order_by_pri"
},
{
"decorator": "db_update",
"file": "app/db/models/site.py",
"method": "Site.reset"
},
{
"decorator": "async_db_query",
"file": "app/db/models/siteicon.py",
@@ -418,11 +303,6 @@
"file": "app/db/models/sitestatistic.py",
"method": "SiteStatistic.get_by_domain"
},
{
"decorator": "db_update",
"file": "app/db/models/sitestatistic.py",
"method": "SiteStatistic.reset"
},
{
"decorator": "async_db_query",
"file": "app/db/models/siteuserdata.py",
@@ -433,11 +313,6 @@
"file": "app/db/models/siteuserdata.py",
"method": "SiteUserData.async_get_latest"
},
{
"decorator": "db_update",
"file": "app/db/models/siteuserdata.py",
"method": "SiteUserData.delete_before"
},
{
"decorator": "db_query",
"file": "app/db/models/siteuserdata.py",
@@ -568,11 +443,6 @@
"file": "app/db/models/systemconfig.py",
"method": "SystemConfig.async_get_by_key"
},
{
"decorator": "db_update",
"file": "app/db/models/systemconfig.py",
"method": "SystemConfig.delete_by_key"
},
{
"decorator": "db_query",
"file": "app/db/models/systemconfig.py",
@@ -613,11 +483,6 @@
"file": "app/db/models/transferhistory.py",
"method": "TransferHistory.count_by_title"
},
{
"decorator": "db_update",
"file": "app/db/models/transferhistory.py",
"method": "TransferHistory.delete_before"
},
{
"decorator": "db_query",
"file": "app/db/models/transferhistory.py",
@@ -683,51 +548,16 @@
"file": "app/db/models/transferhistory.py",
"method": "TransferHistory.monthly_media_statistics"
},
{
"decorator": "db_update",
"file": "app/db/models/transferhistory.py",
"method": "TransferHistory.replace_by_src"
},
{
"decorator": "db_query",
"file": "app/db/models/transferhistory.py",
"method": "TransferHistory.statistic"
},
{
"decorator": "db_update",
"file": "app/db/models/transferhistory.py",
"method": "TransferHistory.update_download_hash"
},
{
"decorator": "db_update",
"file": "app/db/models/transferpending.py",
"method": "TransferPending.clear"
},
{
"decorator": "db_update",
"file": "app/db/models/transferpending.py",
"method": "TransferPending.discard"
},
{
"decorator": "db_query",
"file": "app/db/models/transferpending.py",
"method": "TransferPending.list_all"
},
{
"decorator": "db_update",
"file": "app/db/models/transferpending.py",
"method": "TransferPending.register"
},
{
"decorator": "async_db_update",
"file": "app/db/models/user.py",
"method": "User.async_delete_by_id"
},
{
"decorator": "async_db_update",
"file": "app/db/models/user.py",
"method": "User.async_delete_by_name"
},
{
"decorator": "async_db_query",
"file": "app/db/models/user.py",
@@ -738,21 +568,6 @@
"file": "app/db/models/user.py",
"method": "User.async_get_by_name"
},
{
"decorator": "async_db_update",
"file": "app/db/models/user.py",
"method": "User.async_update_otp_by_name"
},
{
"decorator": "db_update",
"file": "app/db/models/user.py",
"method": "User.delete_by_id"
},
{
"decorator": "db_update",
"file": "app/db/models/user.py",
"method": "User.delete_by_name"
},
{
"decorator": "db_query",
"file": "app/db/models/user.py",
@@ -763,26 +578,11 @@
"file": "app/db/models/user.py",
"method": "User.get_by_name"
},
{
"decorator": "db_update",
"file": "app/db/models/user.py",
"method": "User.update_otp_by_name"
},
{
"decorator": "db_update",
"file": "app/db/models/userconfig.py",
"method": "UserConfig.delete_by_key"
},
{
"decorator": "db_query",
"file": "app/db/models/userconfig.py",
"method": "UserConfig.get_by_key"
},
{
"decorator": "async_db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.async_fail"
},
{
"decorator": "async_db_query",
"file": "app/db/models/workflow.py",
@@ -803,31 +603,6 @@
"file": "app/db/models/workflow.py",
"method": "Workflow.async_get_timer_triggered_workflows"
},
{
"decorator": "async_db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.async_reset"
},
{
"decorator": "async_db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.async_start"
},
{
"decorator": "async_db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.async_success"
},
{
"decorator": "async_db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.async_update_current_action"
},
{
"decorator": "async_db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.async_update_state"
},
{
"decorator": "db_query",
"file": "app/db/models/workflow.py",
+9 -7
View File
@@ -7,12 +7,16 @@ import pytest
from sqlalchemy import event
from sqlalchemy.exc import IntegrityError
from app.agent import AgentManager
from app.agent.orchestrator import AgentManager
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
from app.db import Engine, SessionFactory
from app.db.engine import get_engine
from app.db.oper.agenttask import AgentTaskOper
from app.db.models.agenttask import AgentTask
from app.db.models.agenttaskrun import AgentTaskRun
from app.db.session import SessionFactory
Engine = get_engine()
def _add_task(prefix: str, *, trigger_type: str = "cron") -> AgentTask:
@@ -135,17 +139,15 @@ def test_begin_run_rolls_back_task_claim_when_run_insert_fails() -> None:
first_task = _add_task("run-rollback-first")
second_task = _add_task("run-rollback-second")
run_id = uuid4().hex
assert AgentTaskRun.begin_run(
None,
assert AgentTaskOper().begin_run(
task_id=first_task.id,
run_id=run_id,
trigger_source="scheduled",
started_at="2026-08-13 20:00:00",
) == run_id
).run_id == run_id
with pytest.raises(IntegrityError):
AgentTaskRun.begin_run(
None,
AgentTaskOper().begin_run(
task_id=second_task.id,
run_id=run_id,
trigger_source="manual",
+5 -3
View File
@@ -120,13 +120,15 @@ def test_runtime_contract_baseline_excludes_diagnostic_line_numbers():
def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
"""事务 fixture 必须冻结存量 Model 自动提交,并保持 Oper 自提交为零"""
"""事务 fixture 必须保持 Model 写装饰器归零,并冻结剩余查询债务"""
baseline_path = BASELINE_ROOT / "transaction-debt-baseline.json"
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 1
assert baseline["model_decorators"]["count"] == 168
assert sum(baseline["model_decorators"]["by_kind"].values()) == 168
assert baseline["model_decorators"]["count"] == 123
assert sum(baseline["model_decorators"]["by_kind"].values()) == 123
assert baseline["model_decorators"]["by_kind"]["db_update"] == 0
assert baseline["model_decorators"]["by_kind"]["async_db_update"] == 0
assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
assert baseline["model_session_factories"] == {"count": 0, "calls": []}
assert baseline["oper_transaction_calls"] == {"count": 0, "calls": []}
+17
View File
@@ -18,11 +18,25 @@ class FakeCleanupRepository:
self.failing_table = failing_table
self.calls: list[str] = []
self._message_results = iter((2, 1, 0))
self.commits = 0
self.rollbacks = 0
def session(self):
"""返回无需真实数据库的上下文。"""
return nullcontext(object())
def unit_of_work(self, db):
"""返回记录提交和回滚次数的测试事务边界。"""
return self
def commit(self) -> None:
"""记录一个成功清理批次。"""
self.commits += 1
def rollback(self) -> None:
"""记录一个失败清理批次。"""
self.rollbacks += 1
def _delete(self, name: str) -> int:
"""记录删除调用并按配置模拟结果或异常。"""
self.calls.append(name)
@@ -87,6 +101,8 @@ def test_cleanup_service_owns_batching_report_and_progress() -> None:
assert report["tables"]["message"]["deleted"] == 3
assert report["tables"]["message"]["batches"] == 2
assert report["total_deleted"] == 3
assert repository.commits == 2
assert repository.rollbacks == 0
assert repository.calls == [
"message",
"message",
@@ -113,6 +129,7 @@ def test_cleanup_service_finishes_other_tables_before_raising_partial_failure()
service.execute(batch_size=2)
assert repository.calls[-1] == "downloadfailure"
assert repository.rollbacks == 1
def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
+7 -5
View File
@@ -13,6 +13,7 @@ from app.db.models.passkey import PassKey
from app.db.models.systemconfig import SystemConfig
from app.db.models.user import User
from app.db.models.userconfig import UserConfig
from app.db.oper.passkey import PassKeyOper
from app.db.oper.user import UserOper
@@ -171,12 +172,12 @@ def test_user_async_mutations_match_sync_behaviour(db):
db.add(User(name="mp-test-async-otp", hashed_password="x", is_otp=False))
oper = UserOper()
assert asyncio.run(User.async_update_otp_by_name(
assert asyncio.run(oper.async_update_otp_by_name(
name="mp-test-async-otp", otp=True, secret="S2")) is True
assert asyncio.run(User.async_update_otp_by_name(
assert asyncio.run(oper.async_update_otp_by_name(
name="mp-test-nobody", otp=True, secret="S2")) is False
assert asyncio.run(User().async_delete_by_name(name="mp-test-async-otp")) is True
assert asyncio.run(oper.async_delete_by_name(name="mp-test-async-otp")) is True
assert User.get_by_name(db.session, "mp-test-async-otp") is None
asyncio.run(oper.async_delete(async_id_user.id))
@@ -253,8 +254,9 @@ def test_passkey_async_delete_enforces_the_same_ownership_rule(db):
"""
victim = db.add(_passkey(9006, "cred-async-victim"))
assert asyncio.run(PassKey.async_delete_by_id(passkey_id=victim.id, user_id=9999)) is False
assert asyncio.run(PassKey.async_delete_by_id(passkey_id=victim.id, user_id=9006)) is True
oper = PassKeyOper()
assert asyncio.run(oper.async_delete_by_id(passkey_id=victim.id, user_id=9999)) is False
assert asyncio.run(oper.async_delete_by_id(passkey_id=victim.id, user_id=9006)) is True
assert PassKey.get_by_id(db.session, victim.id) is None
+1
View File
@@ -258,6 +258,7 @@ def test_message_oper_listing_entry_points(db):
oper = MessageOper(db=db.session)
oper.add(title="分页消息", text="正文", source="op-msg-2",
reg_time="2026-08-13 10:00:00")
db.session.commit()
assert [m.title for m in oper.list_by_page(page=1, count=1)] == ["分页消息"]
assert [m.title for m in asyncio.run(oper.async_list_by_page(page=1, count=1))] == \
+14 -2
View File
@@ -10,6 +10,7 @@ import asyncio
import pytest
from app.db.models.workflow import Workflow
from app.db.session import async_session_scope
@pytest.fixture(autouse=True)
@@ -26,6 +27,18 @@ def _flow(name: str, trigger_type: str = "timer", state: str = "W",
actions=[], flows=[], context={}, execution_state={})
async def _stage_async_action(workflow_id: int, action_id: str) -> None:
"""用独占异步会话提交一次模型级暂存,模拟 Application UoW 边界。"""
async with async_session_scope() as session:
await Workflow.async_update_current_action(
session,
wid=workflow_id,
action_id=action_id,
context={},
)
await session.commit()
# --------------------------------------------------------------------------- #
# 列表查询
# --------------------------------------------------------------------------- #
@@ -230,8 +243,7 @@ def test_update_current_action_matches_async_twin(db):
Workflow.update_current_action(db.session, sync_flow.id, action, {})
# 同步 Model 方法只暂存 SQL;由测试持有的事务边界先提交,避免与异步会话争锁。
db.session.commit()
asyncio.run(Workflow.async_update_current_action(
wid=async_flow.id, action_id=action, context={}))
asyncio.run(_stage_async_action(async_flow.id, action))
assert Workflow.get_by_name(db.session, "wf-sync-action").current_action == \
Workflow.get_by_name(db.session, "wf-async-action").current_action
+85 -19
View File
@@ -1,6 +1,8 @@
"""类型化 HostRuntime 与 FastAPI AppState 注入测试。"""
import ast
from dataclasses import FrozenInstanceError
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -11,13 +13,18 @@ from app.api.context import (
get_agent_chat_repository,
get_agent_chat_transaction,
)
from app.api.data import (
ApiDataPorts,
configure_api_data_runtime,
get_api_data_ports,
)
from app.startup import lifecycle
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
from app.startup.context import (
AgentChatRuntime,
AuthenticationRuntime,
HistoryRuntime,
HostRuntime,
MessagingRuntime,
PersistenceRuntime,
SiteRuntime,
SubscriptionRuntime,
WorkflowRuntime,
)
from app.application.configuration import (
ApiRuntimeConfig,
ChainRuntimeConfig,
@@ -26,6 +33,9 @@ from app.application.configuration import (
)
PROJECT_ROOT = Path(__file__).parents[1]
class _Repository:
"""记录绑定会话的 Agent 会话仓储替身。"""
@@ -48,6 +58,20 @@ class _UnitOfWork:
"""模拟回滚。"""
class _SyncUnitOfWork:
"""记录绑定会话的同步事务替身。"""
def __init__(self, session: object) -> None:
"""保存与仓储相同的请求会话。"""
self.session = session
def commit(self) -> None:
"""模拟提交。"""
def rollback(self) -> None:
"""模拟回滚。"""
class _Outbox:
"""记录绑定会话的异步 outbox 替身。"""
@@ -73,19 +97,31 @@ def _runtime() -> HostRuntime:
if False:
yield object()
compatibility = ApiDataPorts(
sync_session=sync_session,
async_session=async_session,
repositories={},
standalone={},
unit_of_work={},
)
return HostRuntime(
agent_chat=AgentChatRuntime(
async_session=async_session,
repository=_Repository,
transaction=_UnitOfWork,
),
persistence=PersistenceRuntime(
sync_session=sync_session,
async_session=async_session,
sync_transaction=_SyncUnitOfWork,
async_transaction=_UnitOfWork,
),
authentication=AuthenticationRuntime(
user_repository=_Repository,
standalone_user=lambda: _Repository(object()),
system_config=lambda: _Repository(object()),
passkey=lambda: _Repository(object()),
),
messaging=MessagingRuntime(repository=_Repository),
history=HistoryRuntime(
download_repository=_Repository,
transfer_repository=_Repository,
media_server_repository=_Repository,
),
site=SiteRuntime(repository=_Repository),
subscription=SubscriptionRuntime(
async_session=async_session,
repository=_Repository,
@@ -93,6 +129,10 @@ def _runtime() -> HostRuntime:
transaction=_UnitOfWork,
outbox=_Outbox,
),
workflow=WorkflowRuntime(
repository=_Repository,
system_config=lambda: _Repository(object()),
),
configuration=RuntimeConfiguration(
api=lambda: ApiRuntimeConfig(False, 60, False, True),
scheduler=lambda: SchedulerRuntimeConfig(
@@ -101,18 +141,20 @@ def _runtime() -> HostRuntime:
),
chain=lambda: ChainRuntimeConfig(media_extensions=(".mkv",)),
),
compatibility_api_data=compatibility,
)
def test_host_runtime_is_frozen_slotted_and_reuses_compatibility_facade() -> None:
"""运行时不可动态扩字段,旧 Facade 必须指向同一个端口实例"""
def test_host_runtime_is_frozen_slotted_and_covers_all_api_domains() -> None:
"""运行时不可动态扩字段,且全部正式 API 领域都有命名能力"""
runtime = _runtime()
configure_api_data_runtime(runtime.compatibility_api_data)
assert not hasattr(runtime, "__dict__")
assert get_api_data_ports() is runtime.compatibility_api_data
assert runtime.authentication.user_repository is _Repository
assert runtime.messaging.repository is _Repository
assert runtime.history.download_repository is _Repository
assert runtime.site.repository is _Repository
assert runtime.subscription.repository is _Repository
assert runtime.workflow.repository is _Repository
with pytest.raises(FrozenInstanceError):
runtime.agent_chat = runtime.agent_chat
@@ -137,6 +179,30 @@ def test_fastapi_dependencies_use_fake_runtime_without_real_services() -> None:
assert response.json() == {"same_session": True}
def test_official_api_dependencies_do_not_use_string_data_locator() -> None:
"""正式业务依赖只能读取 HostRuntime 命名领域,禁止回退字符串注册表。"""
dependency_root = PROJECT_ROOT / "app" / "api" / "dependencies"
official_modules = {
"agent.py",
"auth.py",
"history.py",
"site.py",
"subscription.py",
"workflow.py",
}
for filename in official_modules:
tree = ast.parse(
(dependency_root / filename).read_text(encoding="utf-8")
)
imported_modules = {
node.module
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module
}
assert "app.api.data" not in imported_modules
assert "app.api.dependencies.data" not in imported_modules
@pytest.mark.asyncio
async def test_lifecycle_component_attaches_init_modules_result(monkeypatch) -> None:
"""模块组件把 init_modules 的构建结果发布到当前 AppState。"""
+6
View File
@@ -62,11 +62,13 @@ def test_dispatcher_retries_then_dead_letters_with_stable_key() -> None:
ClaimedOutboxMessage(1, "subscribe.added:42:v1", "subscribe.added", {}, 1, 2),
]
handler = MagicMock(side_effect=RuntimeError("temporary"))
failure_observer = MagicMock()
dispatcher = OutboxDispatcher(
repository,
{"subscribe.added": handler},
max_attempts=2,
clock=lambda: now,
failure_observer=failure_observer,
)
assert dispatcher.dispatch_one() is True
@@ -77,6 +79,10 @@ def test_dispatcher_retries_then_dead_letters_with_stable_key() -> None:
"subscribe.added:42:v1",
"subscribe.added:42:v1",
]
assert [call.args[0] for call in failure_observer.call_args_list] == [
False,
True,
]
def test_dispatcher_marks_success_and_closes_owned_resource() -> None:
+39
View File
@@ -0,0 +1,39 @@
"""用户应用服务的请求级事务边界测试。"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.application.security.user import UserService
@pytest.mark.asyncio
async def test_user_service_commits_staged_mutation() -> None:
"""正式用户写用例必须在仓储暂存成功后提交请求 UoW。"""
repository = MagicMock()
repository.async_create = AsyncMock(return_value={"id": 7})
unit_of_work = MagicMock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
service = UserService(repository, unit_of_work)
assert await service.create({"name": "demo"}) == {"id": 7}
unit_of_work.commit.assert_awaited_once_with()
unit_of_work.rollback.assert_not_awaited()
@pytest.mark.asyncio
async def test_user_service_rolls_back_failed_mutation() -> None:
"""用户仓储写入失败时不得提交部分事务。"""
repository = MagicMock()
repository.async_delete = AsyncMock(side_effect=RuntimeError("write failed"))
unit_of_work = MagicMock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
service = UserService(repository, unit_of_work)
with pytest.raises(RuntimeError, match="write failed"):
await service.delete(7)
unit_of_work.rollback.assert_awaited_once_with()
unit_of_work.commit.assert_not_awaited()