refactor: isolate subscribe history queries

This commit is contained in:
jxxghp
2026-08-23 14:46:39 +08:00
parent 8b955c04d6
commit 5e9933ca8b
9 changed files with 132 additions and 58 deletions
+8 -6
View File
@@ -5,7 +5,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
from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
@@ -107,8 +107,9 @@ class SubscribeHistory(Base):
)
@classmethod
@db_query
@legacy_db_query
def list_by_type(cls, db: Session, mtype: str, page: int = 1, count: int = 30):
"""按媒体类型分页查询订阅历史,并保留旧插件无 Session 调用。"""
return list(db.execute(
select(cls).where(
cls.type == mtype
@@ -118,8 +119,9 @@ class SubscribeHistory(Base):
).scalars().all())
@classmethod
@async_db_query
@legacy_async_db_query
async def async_list_by_type(cls, db: AsyncSession, mtype: str, page: int = 1, count: int = 30):
"""异步按媒体类型分页查询订阅历史,并保留旧插件无 Session 调用。"""
result = await db.execute(
select(cls).filter(
cls.type == mtype
@@ -130,7 +132,7 @@ class SubscribeHistory(Base):
return list(result.scalars().all())
@classmethod
@async_db_query
@legacy_async_db_query
async def async_list_by_type_and_username(
cls,
db: AsyncSession,
@@ -175,7 +177,7 @@ class SubscribeHistory(Base):
return condition
@classmethod
@db_query
@legacy_db_query
def exists(
cls, db: Session, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
@@ -195,7 +197,7 @@ class SubscribeHistory(Base):
return db.execute(statement).scalars().first()
@classmethod
@async_db_query
@legacy_async_db_query
async def async_exists(
cls, db: AsyncSession, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
+3 -1
View File
@@ -653,4 +653,6 @@ class SubscribeOper(DbOper):
"season": season,
"episode_group": episode_group,
}
return bool(SubscribeHistory.exists(self._db, **identity_params))
return bool(self._execute_sync_query(
lambda session: SubscribeHistory.exists(session, **identity_params)
))
+26 -12
View File
@@ -1,5 +1,8 @@
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import DbOper
from app.db.models.subscribehistory import SubscribeHistory
@@ -18,11 +21,13 @@ class SubscribeHistoryOper(DbOper):
"""
异步按媒体类型分页查询订阅历史。
"""
return await SubscribeHistory.async_list_by_type(
self._db,
mtype=mtype,
page=page,
count=count,
return await self._execute_async_query(
lambda session: SubscribeHistory.async_list_by_type(
session,
mtype=mtype,
page=page,
count=count,
)
)
async def async_list_by_type_and_username(
@@ -33,17 +38,26 @@ class SubscribeHistoryOper(DbOper):
count: int = 30,
) -> List[SubscribeHistory]:
"""异步按媒体类型和用户分页查询订阅历史。"""
return await SubscribeHistory.async_list_by_type_and_username(
self._db,
mtype=mtype,
username=username,
page=page,
count=count,
return await self._execute_async_query(
lambda session: SubscribeHistory.async_list_by_type_and_username(
session,
mtype=mtype,
username=username,
page=page,
count=count,
)
)
async def async_get(self, history_id: int) -> Optional[SubscribeHistory]:
"""异步按 ID 查询订阅历史。"""
return await SubscribeHistory.async_get(self._db, history_id)
async def query(session: AsyncSession) -> Optional[SubscribeHistory]:
"""在调用方异步会话中按主键查询历史。"""
result = await session.execute(
select(SubscribeHistory).where(SubscribeHistory.id == history_id)
)
return result.scalars().first()
return await self._execute_async_query(query)
async def async_delete(self, history_id: int) -> None:
"""异步删除订阅历史。"""
+2 -2
View File
@@ -378,9 +378,9 @@ flowchart LR
成功后执行。订阅新增样板由 `startup/subscription.py` 创建独占 Session
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
`transaction-debt-baseline.json` 当前冻结 5 个正式只读查询装饰器;原有同步/异步写装饰器
`transaction-debt-baseline.json` 当前要求正式只读查询装饰器保持为 0;原有同步/异步写装饰器
已全部移除,`db_update``async_db_update` 必须持续保持为 0。下载/整理历史的旧插件 Model
与工作流、媒体服务器、站点用户数据旧插件 Model 调用由 `legacy_*` 兼容外壳承接,宿主 Oper 必须显式传递 Session。宿主 Oper 也不得调用 Base 保留的
与工作流、媒体服务器、站点用户数据、PassKey、SubscribeHistory 旧插件 Model 调用由 `legacy_*` 兼容外壳承接,宿主 Oper 必须显式传递 Session。宿主 Oper 也不得调用 Base 保留的
`create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。
- 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application
Command/Service 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从
@@ -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` 已降至 `5` 个(`2` 个同步、`3` 个异步)。站点、消息、用户、订阅、下载/整理历史、工作流、MediaServer、SiteUserData、AgentChat、AgentTaskRun、TransferPending、SystemConfigPassKey 的宿主查询已迁到显式 Session 路径;对应旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。剩余正式装饰器仍会隐式创建会话,查询返回的 ORM 对象也可能跨层流转,后续继续迁移 SubscribeHistory
3. **查询侧数据库兼容 ABI 已完成正式装饰器清零** 写事务装饰器正式 `db_query/async_db_query` 均为 `0`。站点、消息、用户、订阅、下载/整理历史、工作流、MediaServer、SiteUserData、AgentChat、AgentTaskRun、TransferPending、SystemConfigPassKey 和 SubscribeHistory 的宿主查询已迁到显式 Session 路径;对应旧插件 Model 调用由独立 `legacy_*` 外壳保留,可同时接受显式 Session 与无 Session 的位置/关键字参数。后续重点转为减少 ORM 对象跨层流转,并保持正式装饰器零回退
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:中长期可演进性债务
@@ -1211,6 +1211,12 @@ host/plugin 架构基线和 Pylint 均通过。
不创建额外会话,旧插件无 Session 的位置与关键字调用仍保持兼容;专项与架构测试 `99 passed`
四分片全量测试 `5549 passed, 3 skipped`host/plugin 架构基线和 Pylint 均通过。
2026-08-23 完成 SubscribeHistory 查询切片:同步/异步分页、owner 筛选和存在性查询均由
`legacy_*` 外壳保留旧插件 ABI`SubscribeHistoryOper``SubscribeOper.exist_history` 统一复用
显式 Session/AsyncSession,且按 ID 查询不再调用 Base 查询包装器。正式查询装饰器由 5 降至 0,
同步/异步写装饰器继续保持 0;专项与架构测试 `176 passed`(另有 11 个子测试),四分片全量
测试 `5551 passed, 3 skipped`host/plugin 架构基线和 Pylint 均通过。
#### ARCH-272:异步阻塞检测
**目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。
@@ -1388,7 +1394,7 @@ rollback:
| 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope |
| 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 |
| 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 |
| Model 事务装饰器 | 当前 5 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 |
| Model 事务装饰器 | 正式查询/写装饰器均为 0 | 持续保持为 0;兼容外壳不得被宿主新增调用 |
| 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW |
| 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 |
| Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict |
+3 -3
View File
@@ -84,9 +84,9 @@ 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 5 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.
existing Model transaction decorators. All formal query and write decorators
are now zero and must remain zero; compatibility-only `legacy_*` shells must
not be counted as new transaction ownership.
- `legacy_db_query` / `legacy_async_db_query` are compatibility-only shells for
existing plugin-facing Model methods. Host Oper code must pass an explicit
Session through `_execute_sync_query` / `_execute_async_query`; new Model
+4 -30
View File
@@ -1,39 +1,13 @@
{
"model_decorators": {
"by_kind": {
"async_db_query": 3,
"async_db_query": 0,
"async_db_update": 0,
"db_query": 2,
"db_query": 0,
"db_update": 0
},
"count": 5,
"methods": [
{
"decorator": "async_db_query",
"file": "app/db/models/subscribehistory.py",
"method": "SubscribeHistory.async_exists"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribehistory.py",
"method": "SubscribeHistory.async_list_by_type"
},
{
"decorator": "async_db_query",
"file": "app/db/models/subscribehistory.py",
"method": "SubscribeHistory.async_list_by_type_and_username"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribehistory.py",
"method": "SubscribeHistory.exists"
},
{
"decorator": "db_query",
"file": "app/db/models/subscribehistory.py",
"method": "SubscribeHistory.list_by_type"
}
]
"count": 0,
"methods": []
},
"model_session_factories": {
"calls": [],
+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"] == 5
assert sum(baseline["model_decorators"]["by_kind"].values()) == 5
assert baseline["model_decorators"]["count"] == 0
assert sum(baseline["model_decorators"]["by_kind"].values()) == 0
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": []}
+76
View File
@@ -10,9 +10,11 @@ import time as _time
import pytest
from app.db import decorators
from app.db.models import subscribe as subscribe_module
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.session import SessionFactory, async_session_scope
from app.schemas.types import MediaSource, MediaType
TMDB = str(MediaSource.TMDB)
@@ -71,6 +73,80 @@ def test_exists_matches_async_twin(db):
assert sync_found.id == async_found.id
def test_history_queries_reuse_explicit_sessions(db, monkeypatch):
"""订阅历史同步/异步查询必须复用调用方会话。"""
row = db.add(_history("显式历史", media_id="8501"))
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (_ for _ in ()).throw(AssertionError("不应创建额外同步会话")),
)
assert SubscribeHistory.list_by_type(
db.session, MediaType.TV.value, page=1, count=10
)[0].id == row.id
assert SubscribeHistory.exists(
db.session, MediaSource.TMDB, "8501", season=1
).id == row.id
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 SubscribeHistory.async_list_by_type(
session, MediaType.TV.value, page=1, count=10
)
assert await SubscribeHistory.async_list_by_type_and_username(
session, MediaType.TV.value, "alice", page=1, count=10
)
assert await SubscribeHistory.async_exists(
session, MediaSource.TMDB, "8501", season=1
) is not None
asyncio.run(check())
def test_history_queries_keep_legacy_keyword_abi(db, monkeypatch):
"""旧插件关键字直调订阅历史查询时仍自动补入兼容会话。"""
row = db.add(_history("关键字历史", media_id="8601"))
opened_sync = []
monkeypatch.setattr(
decorators,
"ScopedSession",
lambda: (opened_sync.append(True) or SessionFactory()),
)
assert SubscribeHistory.list_by_type(
mtype=MediaType.TV.value, page=1, count=10
)
assert SubscribeHistory.exists(
media_source=MediaSource.TMDB, media_id="8601", season=1
).id == row.id
assert opened_sync == [True, True]
opened_async = []
original_scope = async_session_scope
def tracked_scope():
"""记录旧异步 ABI 创建的兼容会话作用域。"""
opened_async.append(True)
return original_scope()
monkeypatch.setattr(decorators, "async_session_scope", tracked_scope)
assert asyncio.run(SubscribeHistory.async_list_by_type(
mtype=MediaType.TV.value, page=1, count=10
))
assert asyncio.run(SubscribeHistory.async_list_by_type_and_username(
mtype=MediaType.TV.value, username="alice", page=1, count=10
))
assert asyncio.run(SubscribeHistory.async_exists(
media_source=MediaSource.TMDB, media_id="8601", season=1
)) is not None
assert opened_async == [True, True, True]
@pytest.mark.parametrize("media_id", [None, "", " "])
def test_exists_rejects_blank_media_id(db, media_id):
"""