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 sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_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.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
@@ -107,8 +107,9 @@ class SubscribeHistory(Base):
) )
@classmethod @classmethod
@db_query @legacy_db_query
def list_by_type(cls, db: Session, mtype: str, page: int = 1, count: int = 30): def list_by_type(cls, db: Session, mtype: str, page: int = 1, count: int = 30):
"""按媒体类型分页查询订阅历史,并保留旧插件无 Session 调用。"""
return list(db.execute( return list(db.execute(
select(cls).where( select(cls).where(
cls.type == mtype cls.type == mtype
@@ -118,8 +119,9 @@ class SubscribeHistory(Base):
).scalars().all()) ).scalars().all())
@classmethod @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): async def async_list_by_type(cls, db: AsyncSession, mtype: str, page: int = 1, count: int = 30):
"""异步按媒体类型分页查询订阅历史,并保留旧插件无 Session 调用。"""
result = await db.execute( result = await db.execute(
select(cls).filter( select(cls).filter(
cls.type == mtype cls.type == mtype
@@ -130,7 +132,7 @@ class SubscribeHistory(Base):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
@async_db_query @legacy_async_db_query
async def async_list_by_type_and_username( async def async_list_by_type_and_username(
cls, cls,
db: AsyncSession, db: AsyncSession,
@@ -175,7 +177,7 @@ class SubscribeHistory(Base):
return condition return condition
@classmethod @classmethod
@db_query @legacy_db_query
def exists( def exists(
cls, db: Session, media_source: MediaSource, media_id: str, cls, db: Session, media_source: MediaSource, media_id: str,
season: Optional[int] = None, season: Optional[int] = None,
@@ -195,7 +197,7 @@ class SubscribeHistory(Base):
return db.execute(statement).scalars().first() return db.execute(statement).scalars().first()
@classmethod @classmethod
@async_db_query @legacy_async_db_query
async def async_exists( async def async_exists(
cls, db: AsyncSession, media_source: MediaSource, media_id: str, cls, db: AsyncSession, media_source: MediaSource, media_id: str,
season: Optional[int] = None, season: Optional[int] = None,
+3 -1
View File
@@ -653,4 +653,6 @@ class SubscribeOper(DbOper):
"season": season, "season": season,
"episode_group": episode_group, "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 typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import DbOper from app.db.base import DbOper
from app.db.models.subscribehistory import SubscribeHistory from app.db.models.subscribehistory import SubscribeHistory
@@ -18,11 +21,13 @@ class SubscribeHistoryOper(DbOper):
""" """
异步按媒体类型分页查询订阅历史。 异步按媒体类型分页查询订阅历史。
""" """
return await SubscribeHistory.async_list_by_type( return await self._execute_async_query(
self._db, lambda session: SubscribeHistory.async_list_by_type(
mtype=mtype, session,
page=page, mtype=mtype,
count=count, page=page,
count=count,
)
) )
async def async_list_by_type_and_username( async def async_list_by_type_and_username(
@@ -33,17 +38,26 @@ class SubscribeHistoryOper(DbOper):
count: int = 30, count: int = 30,
) -> List[SubscribeHistory]: ) -> List[SubscribeHistory]:
"""异步按媒体类型和用户分页查询订阅历史。""" """异步按媒体类型和用户分页查询订阅历史。"""
return await SubscribeHistory.async_list_by_type_and_username( return await self._execute_async_query(
self._db, lambda session: SubscribeHistory.async_list_by_type_and_username(
mtype=mtype, session,
username=username, mtype=mtype,
page=page, username=username,
count=count, page=page,
count=count,
)
) )
async def async_get(self, history_id: int) -> Optional[SubscribeHistory]: async def async_get(self, history_id: int) -> Optional[SubscribeHistory]:
"""异步按 ID 查询订阅历史。""" """异步按 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: async def async_delete(self, history_id: int) -> None:
"""异步删除订阅历史。""" """异步删除订阅历史。"""
+2 -2
View File
@@ -378,9 +378,9 @@ flowchart LR
成功后执行。订阅新增样板由 `startup/subscription.py` 创建独占 Session 成功后执行。订阅新增样板由 `startup/subscription.py` 创建独占 Session
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()` `application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。 只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
`transaction-debt-baseline.json` 当前冻结 5 个正式只读查询装饰器;原有同步/异步写装饰器 `transaction-debt-baseline.json` 当前要求正式只读查询装饰器保持为 0;原有同步/异步写装饰器
已全部移除,`db_update``async_db_update` 必须持续保持为 0。下载/整理历史的旧插件 Model 已全部移除,`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 的提交权不会被底层抢走。 `create/update/delete/truncate` 兼容包装器;AST 门禁保证显式 Session 的提交权不会被底层抢走。
- 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application - 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application
Command/Service 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从 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/恢复表。 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 是兼容要求,不应删除;宿主高频能力则应逐族补齐可执行的输入校验、结果校验、超时和错误语义。 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。 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:中长期可演进性债务 ### P2:中长期可演进性债务
@@ -1211,6 +1211,12 @@ host/plugin 架构基线和 Pylint 均通过。
不创建额外会话,旧插件无 Session 的位置与关键字调用仍保持兼容;专项与架构测试 `99 passed` 不创建额外会话,旧插件无 Session 的位置与关键字调用仍保持兼容;专项与架构测试 `99 passed`
四分片全量测试 `5549 passed, 3 skipped`host/plugin 架构基线和 Pylint 均通过。 四分片全量测试 `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:异步阻塞检测 #### ARCH-272:异步阻塞检测
**目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。 **目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。
@@ -1388,7 +1394,7 @@ rollback:
| 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope | | 基线写入行为 | 默认命令可能覆盖 fixture | 所有默认/check 命令保证工作树不变;write 必须显式 scope |
| 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 | | 全功能 worker | 配置允许 >1,控制面会复制 | 启动期明确拒绝 >1;文档与配置一致 |
| 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 | | 健康接口 | 认证 `/system/ping` 为主 | 分离公开 live 与受限/安全 ready;失败原因可诊断 |
| Model 事务装饰器 | 当前 5 个且全部只读;写装饰器 0 | 查询债务只降不增;写事务不回退到 Model/Base 隐式提交 | | Model 事务装饰器 | 正式查询/写装饰器均为 0 | 持续保持为 0;兼容外壳不得被宿主新增调用 |
| 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW | | 新写用例事务 | 宿主写 Oper 已脱离 Base 隐式提交 | 100% 由入口/Application 边界拥有 Session/UoW |
| 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 | | 高频 Module 契约 | 212 个宿主能力显式登记 | 新观察到的宿主方法必须同步登记完整契约 |
| Event payload | 53 类型全部登记 typed payload 与可靠性 | 新事件必须同步登记,不回退裸 dict | | 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 ### Transaction ownership ratchet
- `tests/fixtures/architecture/transaction-debt-baseline.json` records the - `tests/fixtures/architecture/transaction-debt-baseline.json` records the
existing Model transaction decorators. The current 5 decorators are query-only existing Model transaction decorators. All formal query and write decorators
migration debt: they may decrease but must never increase or move to a new are now zero and must remain zero; compatibility-only `legacy_*` shells must
Model method. Both `db_update` and `async_db_update` must remain at zero. not be counted as new transaction ownership.
- `legacy_db_query` / `legacy_async_db_query` are compatibility-only shells for - `legacy_db_query` / `legacy_async_db_query` are compatibility-only shells for
existing plugin-facing Model methods. Host Oper code must pass an explicit existing plugin-facing Model methods. Host Oper code must pass an explicit
Session through `_execute_sync_query` / `_execute_async_query`; new Model Session through `_execute_sync_query` / `_execute_async_query`; new Model
+4 -30
View File
@@ -1,39 +1,13 @@
{ {
"model_decorators": { "model_decorators": {
"by_kind": { "by_kind": {
"async_db_query": 3, "async_db_query": 0,
"async_db_update": 0, "async_db_update": 0,
"db_query": 2, "db_query": 0,
"db_update": 0 "db_update": 0
}, },
"count": 5, "count": 0,
"methods": [ "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"
}
]
}, },
"model_session_factories": { "model_session_factories": {
"calls": [], "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")) baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 1 assert baseline["schema_version"] == 1
assert baseline["model_decorators"]["count"] == 5 assert baseline["model_decorators"]["count"] == 0
assert sum(baseline["model_decorators"]["by_kind"].values()) == 5 assert sum(baseline["model_decorators"]["by_kind"].values()) == 0
assert baseline["model_decorators"]["by_kind"]["db_update"] == 0 assert baseline["model_decorators"]["by_kind"]["db_update"] == 0
assert baseline["model_decorators"]["by_kind"]["async_db_update"] == 0 assert baseline["model_decorators"]["by_kind"]["async_db_update"] == 0
assert baseline["model_transaction_calls"] == {"count": 0, "calls": []} assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
+76
View File
@@ -10,9 +10,11 @@ import time as _time
import pytest import pytest
from app.db import decorators
from app.db.models import subscribe as subscribe_module from app.db.models import subscribe as subscribe_module
from app.db.models.subscribe import Subscribe from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory from app.db.models.subscribehistory import SubscribeHistory
from app.db.session import SessionFactory, async_session_scope
from app.schemas.types import MediaSource, MediaType from app.schemas.types import MediaSource, MediaType
TMDB = str(MediaSource.TMDB) TMDB = str(MediaSource.TMDB)
@@ -71,6 +73,80 @@ def test_exists_matches_async_twin(db):
assert sync_found.id == async_found.id 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, "", " "]) @pytest.mark.parametrize("media_id", [None, "", " "])
def test_exists_rejects_blank_media_id(db, media_id): def test_exists_rejects_blank_media_id(db, media_id):
""" """