refactor: isolate pending transfer and config queries

This commit is contained in:
jxxghp
2026-08-23 14:29:59 +08:00
parent 8fc9a021b0
commit 2120b8040a
10 changed files with 104 additions and 30 deletions
+5 -3
View File
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column
from app.db.decorators import db_query, async_db_query
from app.db.decorators import legacy_async_db_query, legacy_db_query
class SystemConfig(Base):
@@ -18,13 +18,15 @@ class SystemConfig(Base):
value: Mapped[Optional[Any]] = mapped_column(JSON)
@classmethod
@db_query
@legacy_db_query
def get_by_key(cls, db: Session, key: str):
"""按配置键查询系统配置,并保留旧插件无 Session 调用。"""
return db.execute(select(cls).where(cls.key == key)).scalars().first()
@classmethod
@async_db_query
@legacy_async_db_query
async def async_get_by_key(cls, db: AsyncSession, key: str):
"""异步按配置键查询系统配置,并保留旧插件无 Session 调用。"""
result = await db.execute(select(cls).where(cls.key == key))
return result.scalar_one_or_none()
+2 -2
View File
@@ -4,7 +4,7 @@ from sqlalchemy import Index, String, delete, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import db_query
from app.db.decorators import legacy_db_query
class TransferPending(Base):
@@ -73,7 +73,7 @@ class TransferPending(Base):
)
@classmethod
@db_query
@legacy_db_query
def list_all(cls, db: Session, limit: Optional[int] = 5000) -> List["TransferPending"]:
"""
列出全部待整理登记,供启动回放使用。
+4 -1
View File
@@ -54,9 +54,12 @@ class TransferPendingOper(DbOper):
:param limit: 单次回放上限
:return: (存储, 源文件路径) 列表
"""
items = self._execute_sync_query(
lambda session: TransferPending.list_all(session, limit=limit)
)
return [
(item.storage, item.src_path)
for item in TransferPending.list_all(self._db, limit=limit) or []
for item in items or []
if item and item.storage and item.src_path
]
+1 -1
View File
@@ -378,7 +378,7 @@ flowchart LR
成功后执行。订阅新增样板由 `startup/subscription.py` 创建独占 Session
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
`transaction-debt-baseline.json` 当前冻结 12 个正式只读查询装饰器;原有同步/异步写装饰器
`transaction-debt-baseline.json` 当前冻结 9 个正式只读查询装饰器;原有同步/异步写装饰器
已全部移除,`db_update``async_db_update` 必须持续保持为 0。下载/整理历史的旧插件 Model
与工作流、媒体服务器、站点用户数据旧插件 Model 调用由 `legacy_*` 兼容外壳承接,宿主 Oper 必须显式传递 Session。宿主 Oper 也不得调用 Base 保留的
`create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。
@@ -28,7 +28,7 @@
1. **后台任务的统一所有权已覆盖 API 入口,但仍有更深层任务机制待分级。** `app/runtime/tasks.py` 已建立 lifespan 级 TaskRegistry,启动收尾、插件 Release 刷新、Webhook E0 广播、CookieCloud E1 手工调度、消息入口、Seerr 订阅和 WebAgent 断线后执行/快照保存均不再维护端点模块级任务集合或 Starlette 回调,shutdown 会停止接收、取消并有限等待,且生命周期清单明确登记其顺序。主仓 `app/` 已无裸 FastAPI `BackgroundTasks`;当前仍有约 `50` 个更底层 `create_task`/等价任务创建点,与线程池和 APScheduler 并存,后续需逐项确认 owner、取消、等待、重试、幂等和是否 durable,关键业务副作用优先接入已有 Outbox/恢复表。
2. **动态模块契约仍以 legacy 聚合语义为主。** 当前登记 `212` 个模块方法,其中 `194` 个仍使用 `legacy` aggregation,只有 `14``first_non_empty``4``ordered_list_merge``app/runtime/extensions/module/contracts.py:422-455` 已能登记 family、输入/结果标签和基础签名诊断,但 `193` 个方法没有 required parameters,调度器 `app/runtime/extensions/module/dispatcher.py:109-260` 仍主要依赖运行时反射、返回值形状和短路规则。未知第三方方法保留 legacy fallback 是兼容要求,不应删除;宿主高频能力则应逐族补齐可执行的输入校验、结果校验、超时和错误语义。
3. **查询侧数据库兼容 ABI 仍未完全收口。** 写事务装饰器已降为 `0`,正式 `db_query/async_db_query` 已降至 `12` 个(`5` 个同步、`7` 个异步)。站点、消息、用户、订阅、下载/整理历史、工作流、MediaServer、SiteUserData、AgentChatAgentTaskRun 的宿主 Oper 已迁到显式 Session 路径;对应旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。剩余正式装饰器仍会隐式创建会话,查询返回的 ORM 对象也可能跨层流转,后续继续按 TransferPending、SystemConfig、PassKey 和 SubscribeHistory 等风险切片迁移。
3. **查询侧数据库兼容 ABI 仍未完全收口。** 写事务装饰器已降为 `0`,正式 `db_query/async_db_query` 已降至 `9` 个(`3` 个同步、`6` 个异步)。站点、消息、用户、订阅、下载/整理历史、工作流、MediaServer、SiteUserData、AgentChatAgentTaskRun、TransferPending 和 SystemConfig 的宿主 Oper 已迁到显式 Session 路径;对应旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。剩余正式装饰器仍会隐式创建会话,查询返回的 ORM 对象也可能跨层流转,后续继续按 PassKey 和 SubscribeHistory 等风险切片迁移。
4. **组合根和全局状态仍形成复杂的隐式运行时图。** Singleton 实例、模块级 provider、`configure_*` 注册函数和兼容 Facade 同时存在;它们解决了旧 ABI 和启动顺序问题,但增加测试污染、重复装配、实例身份和初始化顺序风险。`app/startup/lifecycle/__init__.py:161-376` 已有声明式生命周期,`app/startup/modules_initializer.py:505-530` 也有分阶段关闭,但尚未做到所有进程级资源都只通过 typed HostRuntime 访问。后续应以“新代码禁止新增 Service Locator/Singleton 依赖、旧入口有命中观测”为 ratchet。
### P2:中长期可演进性债务
@@ -1200,6 +1200,12 @@ Session 不创建额外会话,无 Session 的位置参数和关键字参数继
不创建额外会话,无 Session 的关键字调用继续由 `legacy_*` 外壳兼容;专项测试 `44 passed`,四分片
全量测试 `5543 passed, 4 skipped`
2026-08-23 完成 TransferPending 与 SystemConfig 查询切片:待整理回放和系统配置的宿主查询统一
复用调用方 Session,正式查询装饰器由 12 降至 9 个(同步 3、异步 6),写装饰器保持 0。旧插件
仍可直接调用对应 Model 方法,显式 Session 不创建额外会话,无 Session 的关键字调用继续由
`legacy_*` 外壳兼容;专项与架构测试 `146 passed`,四分片全量测试 `5547 passed, 3 skipped`
host/plugin 架构基线和 Pylint 均通过。
#### ARCH-272:异步阻塞检测
**目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。
@@ -1377,7 +1383,7 @@ rollback:
| 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope |
| 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 |
| 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 |
| Model 事务装饰器 | 当前 12 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 |
| Model 事务装饰器 | 当前 9 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 |
| 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW |
| 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 |
| Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict |
+1 -1
View File
@@ -84,7 +84,7 @@ Oper classes accept and return persistence values. Turning a `MediaInfo` or
### Transaction ownership ratchet
- `tests/fixtures/architecture/transaction-debt-baseline.json` records the
existing Model transaction decorators. The current 12 decorators are query-only
existing Model transaction decorators. The current 9 decorators are query-only
migration debt: they may decrease but must never increase or move to a new
Model method. Both `db_update` and `async_db_update` must remain at zero.
- `legacy_db_query` / `legacy_async_db_query` are compatibility-only shells for
+3 -18
View File
@@ -1,12 +1,12 @@
{
"model_decorators": {
"by_kind": {
"async_db_query": 7,
"async_db_query": 6,
"async_db_update": 0,
"db_query": 5,
"db_query": 3,
"db_update": 0
},
"count": 12,
"count": 9,
"methods": [
{
"decorator": "async_db_query",
@@ -52,21 +52,6 @@
"decorator": "db_query",
"file": "app/db/models/subscribehistory.py",
"method": "SubscribeHistory.list_by_type"
},
{
"decorator": "async_db_query",
"file": "app/db/models/systemconfig.py",
"method": "SystemConfig.async_get_by_key"
},
{
"decorator": "db_query",
"file": "app/db/models/systemconfig.py",
"method": "SystemConfig.get_by_key"
},
{
"decorator": "db_query",
"file": "app/db/models/transferpending.py",
"method": "TransferPending.list_all"
}
]
},
+2 -2
View File
@@ -126,8 +126,8 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 1
assert baseline["model_decorators"]["count"] == 12
assert sum(baseline["model_decorators"]["by_kind"].values()) == 12
assert baseline["model_decorators"]["count"] == 9
assert sum(baseline["model_decorators"]["by_kind"].values()) == 9
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": []}
+42
View File
@@ -9,12 +9,14 @@ import asyncio
import pytest
from app.db import decorators
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
from app.db.session import SessionFactory, async_session_scope
@pytest.fixture(autouse=True)
@@ -48,6 +50,46 @@ def test_systemconfig_get_by_key_returns_none_when_absent(db):
assert SystemConfig.get_by_key(db.session, "mp-test-missing") is None
def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
"""SystemConfig 显式同步与异步会话不得触发兼容会话。"""
db.add(SystemConfig(key="mp-explicit-config", value=True))
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")),
)
assert SystemConfig.get_by_key(db.session, "mp-explicit-config") is not None
async def check() -> None:
"""验证异步配置查询复用显式 AsyncSession。"""
async with async_session_scope() as session:
monkeypatch.setattr(
decorators,
"async_session_scope",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外异步会话")),
)
assert await SystemConfig.async_get_by_key(
session,
"mp-explicit-config",
) is not None
asyncio.run(check())
def test_systemconfig_model_legacy_query_keeps_keyword_abi(db, monkeypatch):
"""旧插件以关键字直调 SystemConfig 时仍自动补入短会话。"""
db.add(SystemConfig(key="mp-legacy-config", value=True))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert SystemConfig.get_by_key(key="mp-legacy-config") is not None
assert opened == [True]
def test_systemconfig_delete_by_key_removes_only_that_key(db):
"""
按键删除只能删掉那一个键,误删会静默丢失其他配置。
+36
View File
@@ -7,8 +7,10 @@
"""
import pytest
from app.db import decorators
from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper
from app.db.session import SessionFactory
@pytest.fixture(autouse=True)
@@ -150,6 +152,40 @@ def test_oper_returns_plain_tuples_not_orm_instances(db):
assert all(isinstance(item, tuple) for item in listed)
def test_oper_reuses_explicit_query_session(db, monkeypatch):
"""TransferPendingOper 查询必须复用调用方会话。"""
db.add(TransferPending(
storage="local",
src_path="/mnt/explicit.mkv",
created_at="2026-08-13 10:00:00",
))
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")),
)
assert ("local", "/mnt/explicit.mkv") in TransferPendingOper(db.session).list_all()
def test_model_legacy_query_keeps_keyword_abi(db, monkeypatch):
"""旧插件以关键字直调 TransferPending 时仍自动补入短会话。"""
db.add(TransferPending(
storage="local",
src_path="/mnt/legacy.mkv",
created_at="2026-08-13 10:00:00",
))
opened = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened.append(True) or SessionFactory()),
)
assert TransferPending.list_all(limit=1)
assert opened == [True]
def test_oper_drops_rows_with_missing_fields(db):
"""
回放时必须跳过字段残缺的历史遗留行,不能把空存储送进整理链。