test(subscribe): establish governance replay baseline

This commit is contained in:
jxxghp
2026-09-01 12:49:14 +08:00
parent eecde8a58d
commit 113ca6da92
4 changed files with 1409 additions and 0 deletions

View File

@@ -0,0 +1,871 @@
# MoviePilot 订阅执行治理上游 Handoff
> 日期:2026-09-01
> 目标版本:MoviePilot V3
> 生产参考:MoviePilot V2 `2.15.5` 的一次只读样本
> 关联路线图:`docs/refactor/moviepilot-subscription-governance-roadmap.md`
> 交接目的:由上游完成订阅执行链路的整体治理,而不是继续修补单个慢点
> 仓库接管说明(2026-09-01):本文已纳入 MoviePilot `v3` 仓库。文中
> `d923037f9` 是原交接环境的参考提交,不存在于当前仓库历史;对应正确性合同必须在
> `SUB-GOV-001A` 中基于当前源码重新建立和验证,不能将该提交视为当前分支已交付证据。
## 1. 结论先行
当前订阅问题需要按一个完整执行域治理,不能再把它理解为“给 `match()` 加缓存”或“把循环改成并发”。
需要同时解决四类问题:
1. 日常 RSS/Spider 刷新后,完整候选缓存被全部订阅重复扫描,核心复杂度接近 `O(S * C)`;
2. 每条订阅在确认本轮是否存在相关候选前,就执行新鲜媒体识别和媒体库缺失查询;
3. 24 小时兜底搜索在与日常 Match 共用的长锁中逐订阅等待并访问站点;
4. 日常 Match、手工搜索和兜底搜索缺少统一的 single-flight、下载提交幂等、取消和恢复合同。
建议的最终形态是:
```text
站点候选获取
-> 本轮候选批次与 retry/pending 账本
-> 无损候选路由
-> 受影响订阅准备
-> 本轮新鲜事实租约
-> 串行、短临界区、可幂等的下载提交
-> 独立完成对账
兜底搜索
-> 可恢复搜索队列
-> 每站点容量预算
-> 复用同一准备/提交/完成合同
```
生产发布不采用 shadow A/B,也不在生产同时运行旧、新两套 Match:
- 上线前通过固定数据快照、可重放测试和目标规模压测证明新链路;
- 上线后只有新链路产生计算、下载和状态写入;
- 不保留运行时旧链路双跑或对照开关;
- 回滚以镜像/版本回滚和兼容数据库迁移为基础。
## 2. 目标与验收规模
这次治理的目标不是小样本快几秒,而是让以下规模成为正常使用场景:
- `100–200` 条活跃订阅;
- `10–20` 个启用站点;
- 单轮 `1000+` 条 RSS/Spider 候选;
- 兜底搜索开启后,不阻塞日常 RSS/Spider Match;
- 手工刷新、日常 Match 和兜底搜索重叠时,不重复下载、不错误完成;
- PT 站点请求频率和峰值压力不高于治理前基线。
最终验收必须同时看:
- 正确性;
- 用户等待时间;
- 本地计算复杂度;
- TMDB/媒体服务器请求数量;
- PT 站点请求压力;
- 下载幂等;
- 取消、重启和异常恢复。
任何只优化其中一项、但把代价转移到其他项的方案都不算完成。
## 3. 证据边界
### 3.1 生产 V2 样本
生产实例运行 MoviePilot V2 `2.15.5`。对 11 轮连续日志做过一次只读统计:
| 指标 | 观察值 |
| --- | ---: |
| 每轮活跃订阅 | 约 12 条 |
| 每轮候选站点域 | 约 5 个 |
| 每轮候选 | 约 500 条 |
| Match 中位数 | `17.752s` |
| Match 范围 | `11.049–29.200s` |
| 单订阅相邻日志跨度中位数 | 约 `0.4s` |
| 每轮 TMDB 查询日志 | `12–63` 次 |
该证据只能说明:在订阅数量很少时,日常 Match 已经有明显耗时和波动;它不能直接外推 V3 在
`100–200` 条订阅下的绝对耗时,也不能替代 V3 的可重放验证。
取证期间未修改配置、未触发额外刷新或搜索、未重启服务。
### 3.2 V3 源码快照
本 handoff 的源码定位基于 2026-09-01 的 V3 快照。上游持续变化不影响本文的业务约束;实际开始集成时,
再将符号和文件位置映射到最新 V3,不需要在设计和实现阶段持续追逐上游提交。
当前关键位置:
| 位置 | 当前职责 |
| --- | --- |
| `app/chain/subscribe/refresh.py::SubscribeRefreshOwner.refresh` | 串联站点刷新和日常 Match |
| `app/chain/torrents.py::TorrentsChain.refresh` | 逐站点获取 RSS/Spider、识别、合并并返回完整缓存 |
| `app/chain/subscribe/match.py::SubscribeMatchOwner._execute_match` | 持锁、预处理完整缓存、逐订阅识别/查缺/全量扫描/下载/完成 |
| `app/chain/subscribe/search.py::SubscribeSearchOwner._execute_search` | 持同一把锁执行新增、手工或兜底搜索 |
| `app/chain/subscribe/search.py::_wait_before_scheduled_search` | R/P 兜底搜索逐订阅随机等待 60–300 秒 |
| `app/chain/subscribe/facade.py::SubscribeChain._rlock` | Match 和 Search 共用的类级锁,超时上限 2 小时 |
| `app/chain/subscribe/completion.py::finish_subscribe_or_not` | 缺集、下载事实和完成状态收敛 |
| `app/chain/subscribe/refresh.py::__refresh_total_episode_before_completion` | 完成前按新鲜季集事实修正总集数 |
V3 当前已经提供 `TorrentsChain.get_subscribe_cache_candidates()`,可以按媒体身份和季筛选缓存候选,
并对标题兜底候选做显式低置信标记;但日常 `SubscribeMatchOwner._execute_match()` 尚未调用它,仍然逐订阅
遍历传入的完整候选集合。该能力可以复用,但不能直接把它当成治理已经完成。
## 4. 当前执行路径
### 4.1 日常 RSS/Spider Match
```text
Scheduler / 手工刷新
|
v
SubscribeRefreshOwner.refresh()
|
v
TorrentsChain.refresh()
- 逐站点读取 RSS,或 Spider 每站点最多两页
- 与本地完整缓存去重
- 新候选做媒体识别
- 新候选追加到完整缓存
- 返回当前站点范围内的完整缓存
|
v
SubscribeMatchOwner._execute_match()
- 获取 Match/Search 共用类级锁
- 对完整缓存做候选预处理/失败重识别
- 读取所有 R/P 订阅
- 每条订阅重新取得 cache=False 的媒体事实
- 每条订阅查询媒体库和下载事实
- 每条订阅扫描完整候选集合
- 过滤、择优、下载、写进度、判断完成
- 释放类级锁
```
### 4.2 兜底搜索
```text
subscribe_search 定时任务
|
v
SubscribeSearchOwner._execute_search(state=R/P)
- 获取与 Match 共用的类级锁
- 逐订阅读取
- 每条订阅先随机等待 60–300 秒
- 识别目标和查询缺失
- 对多个站点发起搜索
- 过滤、下载、写进度、判断完成
- 全部完成后释放类级锁
```
如果有 100 条需要兜底搜索的订阅,仅随机等待的期望总时间约为 5 小时;200 条约为 10 小时。
实际站点请求、超时、解析和下载还会继续增加总时长。在这段时间里,日常 Match 会等待同一把锁。
## 5. 问题清单
### P0:完成正确性不能被性能优化破坏
日常 Match 的媒体识别使用 `cache=False`,并非无意义的缓存遗漏。电视剧在完成前会依据本次新鲜季集事实
修正 `total_episode` 和 `lack_episode`。
典型边界:
1. 本地订阅仍记录 12 集并且 `lack_episode=0`;
2. TMDB 已将该季扩展为 13 集;
3. 如果使用旧缓存,系统会继续认为 12 集完整并完成/删除订阅;
4. 如果使用新鲜事实,系统应更新为 13 集并恢复 1 集缺口。
本地提交 `d923037f9 test(subscribe): guard fresh match metadata` 已固定以下合同:
- 日常 Match 继续请求 `cache=False`;
- 12 集事实不产生错误变化;
- 13 集新鲜事实会把 `total_episode` 更新为 13、`lack_episode` 更新为 1。
因此不能把 `cache=True` 作为治理方案。正确的优化方向是“同一轮只取得一次足够新鲜的事实并安全复用”,
而不是跨轮复用最长可达数十小时的旧详情。
### P0:下载提交缺少跨入口统一幂等合同
当前 Match 和 Search 依赖同一把全局锁降低重叠概率,但这不是可扩展的幂等设计。锁一旦拆分,以下入口可能
同时为同一订阅、同一 torrent 或同一剧集范围生成下载:
- RSS Match;
- Spider Match;
- 新增订阅搜索;
- 24 小时兜底搜索;
- 手工刷新或手工搜索;
- 插件触发的订阅处理。
现有下载历史和订阅 `note` 能参与缺失判断,但不能替代“提交瞬间的原子防重”。尤其是下载器已经接受任务、
本地写回失败时,系统无法安全判断是否应该自动重试。
### P1:完整缓存和本轮新增候选没有执行语义上的区分
`TorrentsChain.refresh()` 会识别新种子、追加到缓存,再把完整缓存返回给 Match。这样可以保留旧候选重试能力,
但也使历史候选在每轮反复进入预处理和全订阅扫描。
不能简单改成“只处理本轮 delta”,因为以下候选仍需要后续重试:
- 媒体识别临时失败;
- TMDB 或媒体服务器临时失败;
- 下载器暂时不可用;
- 新订阅创建时需要回看仍有效的缓存候选;
- 自定义识别词只能在具体订阅上下文中应用;
- 下载器接受结果不确定,需要对账而不是直接丢弃。
因此需要的是:本轮 delta 优先 + 明确的 pending/retry 账本,而不是完整缓存永远重扫,也不是只处理新候选。
### P1:核心循环接近 `O(S * C)`
每条订阅都会遍历按站点分组的全部候选,再执行站点、身份、季、剧集、过滤规则和洗版优先级判断。
当 `S=200`、`C=1000` 时,仅第一层组合就是 20 万次;若候选包含复杂识别、标题兜底、自定义词和优先级规则,
成本会继续放大。
已有 `get_subscribe_cache_candidates()` 证明媒体身份/类型/季可以做安全的轻量前置过滤,但当前接口仍然是
“每条订阅重新遍历完整缓存”,并没有建立一次构建、多订阅复用的反向索引。
### P1:在候选相关性确认前执行每订阅外部事实查询
当前 Match 对每条订阅先执行:
- 新鲜媒体识别;
- 媒体库缺失查询;
- 下载历史和洗版进度计算;
之后才扫描候选。即使本轮没有任何候选可能命中该订阅,也会支付主要外部成本。
候选门禁可以跳过“本轮资源匹配准备”,但不能跳过“订阅完成对账”。这两个业务目标必须拆开:
- 资源匹配:由候选到达触发,只处理受影响订阅;
- 完成对账:按独立调度或媒体事实变化触发,不依赖本轮是否有候选。
### P1:Match 和兜底搜索共享长锁
`SubscribeChain._rlock` 同时保护 Match 和 Search,锁范围包括:
- TMDB/媒体识别;
- 媒体服务器查询;
- PT 站点搜索;
- 60–300 秒随机等待;
- 种子获取和下载器调用;
- 订阅状态写入。
该锁把“防止同一订阅重复提交”扩大成了“整个订阅域一次只能执行一个长任务”。结果是:
- 慢站点阻塞所有日常 Match;
- 一条订阅异常拖延整批;
- 手工操作无法合理获得优先级;
- 取消只能在少数循环边界生效;
- 直接增加 worker 没有意义,因为全局锁仍然串行。
### P1:兜底搜索节流单位错误
当前随机等待按“订阅”执行,但真正需要保护的是“站点请求容量”。
订阅级等待的问题:
- 多站点请求仍可能在等待结束后同时突发;
- 订阅多时等待线性累积;
- 等待期间占用业务锁;
- 无法针对单个站点的 `429/403`、登录失效或超时单独冷却;
- 无法在进程重启后恢复公平的处理位置。
### P2:取消、停止和恢复粒度不足
现有停止检查主要位于订阅或候选循环边界,`time.sleep()` 和正在进行的同步网络调用不可中断。进程重启后,
系统也没有明确的批次游标、候选终态和不确定下载提交状态用于恢复。
### P2:用户状态只有任务进度,没有完整业务状态
当前可以报告“正在匹配第 N 个订阅”,但缺少稳定的业务状态合同,例如:
- queued;
- matching;
- waiting_site_budget;
- preparing_download;
- submitted;
- retryable;
- failed;
- cancelling/cancelled;
- reconcile_required;
- completed。
因此后台失败、锁等待、下载器不确定状态和订阅完成容易被用户理解成操作成功、卡住或订阅消失。
## 6. 必须保持的不变量
### 6.1 正确性
1. 日常 Match 和完成对账必须获得足够新鲜的季集事实;
2. 12→13 集等更新不能因跨轮缓存而错误完成订阅;
3. 候选索引不能漏掉当前完整逻辑能够命中的资源;
4. 自定义识别词、标题兜底、动态插件规则和身份不确定候选必须保守处理;
5. 候选门禁只能缩小资源匹配范围,不能隐式跳过完成对账;
6. 下载前必须重读订阅、缺集、下载历史和洗版优先级;
7. 失败、取消、停机和锁等待失败不能写为成功或完成;
8. 单条订阅失败不能阻止其他订阅继续,但批次必须暴露聚合失败。
### 6.2 站点压力
1. RSS/Spider 默认请求频率不得增加;
2. RSS/Spider 不通过站点并发化换性能;
3. 兜底搜索同一站点最多 1 个在途请求,除非未来有明确站点能力证据;
4. 同一站点请求间隔、冷却和重试必须统一受预算控制;
5. `429/403`、登录失效和连续超时必须降低该站点压力;
6. 手工搜索可以提高队列优先级,但不能绕过站点预算。
### 6.3 下载与状态
1. 同一订阅、同一 torrent、同一季集覆盖在入口重叠时至多提交一次;
2. 下载器已接受但本地写回失败必须进入“不确定/待对账”,不能盲目重试;
3. 提交成功前不得提前更新订阅已下载事实;
4. 完成、删除、通知和历史写入必须由一条 canonical 路径执行;
5. 数据库迁移应优先保持向后兼容,以支持版本级回滚。
## 7. 明确否决的方案
以下方案不应再次作为主方案提交:
### 7.1 把日常 Match 改为 `cache=True`
原因:可能使用旧季集事实错误完成或删除追更订阅。
### 7.2 直接回写调用方传入的完整候选缓存
原因:可能覆盖未参与本轮处理的站点域、音乐缓存、RSS/Spider 不同缓存文件或其他消费者状态。
### 7.3 只处理本轮新增候选
原因:会丢失临时识别失败、外部服务失败、新建订阅回看和下载器不确定状态的重试机会。
### 7.4 跨刷新周期轮流处理订阅
原因:只是把耗时隐藏到多轮,可能错过短生命周期候选,并显著增加用户完成延迟。
### 7.5 直接把全部订阅或全部站点开成 2/4/N worker
原因:会放大 TMDB、媒体服务器、PT 站点和下载器并发,且在没有幂等合同前引入重复下载和完成竞态。
### 7.6 只删除全局锁
原因:全局锁虽然粒度错误,但当前仍承担部分防重作用;没有订阅级 single-flight 和提交幂等时直接删除会暴露竞态。
### 7.7 把逐订阅随机等待改成整批只等待一次
原因:可能让大量实例在同一时间集中访问站点,降低总耗时但增加 PT 风控压力。
### 7.8 生产 shadow A/B 或旧新双跑
原因:生产上线即采用新方案,不在同一轮同时运行旧、新 Match,也不维护两套结果和采集闭环。双跑还会增加
实现复杂度、观测责任和误触发副作用的风险。
正确性对照应在上线前的隔离可重放环境中完成;生产只运行一条新链路。
### 7.9 单独上线统计日志或日志聚合器
原因:它不解决用户问题,也没有必要先引入一套长期采集系统。实现所需的最小运行指标应直接成为新链路的
业务结果和运维摘要,而不是另建文本日志聚合器。
## 8. 推荐目标架构
### 8.1 阶段一:候选获取与批次边界
每次 RSS/Spider 刷新产生一个明确的 `CandidateBatch`:
```text
CandidateBatch
- batch_id
- source: rss | spider | fallback_search | manual_search
- started_at / finished_at
- sites
- fresh_candidates
- retry_candidates
- counters / terminal_status
```
要求:
- RSS/Spider 仍按当前站点策略获取,不增加请求;
- 当前轮新增候选与历史完整缓存分开表达;
- 当前轮优先处理 `fresh_candidates`;
- 只有 retryable、不确定或明确需要回看的候选进入持久 retry/pending 账本;
- 已终态拒绝、已成功提交、已过期候选不再每轮重扫;
- 不要求把每轮所有候选都写入数据库,避免用持久化开销替代扫描开销。
建议候选身份至少包含:
- 稳定 torrent/resource key;
- 来源、站点、首次/最后出现时间;
- 影视/音乐类型;
- 识别出的 `media_source + media_id`;
- 季和集范围;
- 识别置信来源;
- 当前终态和下一次允许重试时间。
### 8.2 阶段二:一次构建的无损候选索引
每个批次只构建一次索引,不再由每条订阅重新遍历完整候选:
```text
CandidateIndex
- by_site[site_id]
- by_media[(media_source, media_id)]
- by_media_season[(media_source, media_id, season)]
- unknown_identity[]
- custom_word_fallback[]
- music[]
```
路由规则:
1. 有明确媒体身份的候选,通过 `media_source + media_id` 直接定位受影响订阅;
2. 类型或季明确冲突时可以安全排除;
3. 身份缺失、标题兜底、自定义识别词、动态插件规则进入保守集合;
4. 保守集合只允许缩小到“可能相关”,不能提前执行最终业务匹配;
5. 最终季集、过滤规则、洗版优先级和自定义词仍复用 canonical 逻辑。
复杂度目标从 `O(S * C)` 收敛为近似:
```text
O(C) 构建索引
+ O(S) 构建订阅路由
+ O(M) 对真实可能匹配的订阅/候选对执行完整规则
+ O(F) 处理保守 fallback
```
其中 `M` 应远小于 `S * C`,`F` 必须可度量且不能静默吞掉候选。
### 8.3 阶段三:资源匹配与完成对账分离
资源匹配入口只处理本批次受影响订阅:
- 没有候选关联的订阅,不执行本轮资源匹配准备;
- 有候选关联的订阅,才取得新鲜媒体事实、媒体库缺失事实并执行完整规则;
- 完成前仍重读当前订阅和缺集事实。
完成对账成为独立任务:
- 订阅元数据更新后;
- 媒体库同步后;
- 下载完成/整理后;
- 低频定时校验;
- 用户显式刷新。
完成对账不扫描 PT 候选,但必须继续保护新鲜季集语义。
### 8.4 阶段四:本轮新鲜事实租约
在同一个批次内,相同事实只获取一次:
```text
FreshFactKey
- media_source
- media_id
- media_type
- season
- episode_group
```
首次读取仍按当前 `cache=False` 语义取得新鲜事实,随后只在本轮内复用。批次结束即释放,不跨刷新周期保留。
需要区分:
- 稳定元数据:标题、年份、别名、图片等;
- 动态季集事实:当前季集数、分集信息;
- 媒体服务器存在事实;
- 订阅业务判断:缺集、洗版优先级、下载历史。
可以合并相同媒体的外部事实查询,但不能把不同订阅的范围、过滤规则、洗版模式或最终状态判断直接共用。
### 8.5 阶段五:准备与提交分离
准备阶段允许纯计算或有界外部读取:
- 候选完整匹配;
- 新鲜媒体事实;
- 媒体库缺失事实;
- 规则过滤;
- 下载计划。
准备阶段不得:
- 提交下载;
- 标记已下载;
- 完成或删除订阅;
- 发送完成通知。
提交阶段保持短临界区:
1. 按订阅或逻辑媒体目标取得 single-flight/提交锁;
2. 重读订阅当前状态;
3. 重读下载历史、媒体库缺失和洗版优先级;
4. 计算幂等键并检查终态;
5. 提交下载;
6. 根据下载器明确结果写入成功、失败或不确定状态;
7. 更新订阅进度并执行 canonical 完成判断;
8. 释放锁。
### 8.6 下载幂等
建议幂等身份至少组合:
```text
subscription logical identity
+ torrent/resource identity
+ target season/episode coverage
+ subscription mode (normal/best-version)
```
不要只使用订阅数据库行 ID:多条订阅记录可能指向同一媒体。首轮可以先保证同一记录不重复提交,跨记录去重
作为后续低优先级能力,但数据模型不要阻止未来扩展。
提交终态建议:
- `planned`;
- `submitting`;
- `accepted`;
- `rejected`;
- `uncertain`;
- `reconciled`。
`uncertain` 必须先查询下载器或下载历史对账,不能自动再次提交。
### 8.7 兜底搜索队列与站点预算
兜底搜索不再在全局锁内逐订阅 `sleep`。建议模型:
```text
SubscriptionSearchQueue
- 订阅任务与优先级
- 可取消等待
- 持久游标/租约
- 重启恢复
SiteBudget
- 每站点最多 1 个在途请求
- next_allowed_at
- 随机请求间隔
- 429/403/timeout/login-invalid 冷却
- 成功后逐步恢复
```
要求:
- 随机化保留在站点预算层,而不是订阅长循环;
- 不同站点可独立推进,但同一站点默认串行;
- 批次启动仍保留全局抖动,避免大量实例整点突发;
- 手工任务可提高队列优先级,但不能突破站点预算;
- 等待不持有订阅提交锁;
- 取消应能终止尚未发出的站点请求和可取消等待;
- 已发出的同步请求若无法中断,结果返回后必须检查任务租约再决定是否提交。
### 8.8 业务状态
后端应把批次和订阅任务状态作为业务结果,而不是只输出日志:
```text
queued
running
waiting_site_budget
matching
preparing
submitting
accepted
retryable
uncertain
failed
cancelling
cancelled
completed
```
状态需包含:
- 当前阶段;
- 已处理/总数;
- 当前订阅或站点;
- 可安全展示的失败原因;
- 是否可取消、可重试或需人工对账;
- 批次聚合结果。
前端展示可以后置,但后端状态合同应与执行治理一起设计,避免后续继续依赖日志推断。
## 9. 并发策略
首轮不要以并发作为主要收益来源。正确顺序是:
1. 消除 `S * C` 全量扫描;
2. 没有关联候选时跳过资源匹配的外部查询;
3. 同轮合并新鲜媒体事实和媒体服务器事实;
4. 缩短提交锁;
5. 把兜底搜索等待移到站点预算;
6. 仍存在可重复等待热点时,再考虑 2/4 个有界 prepare worker。
即使启用 prepare worker:
- 站点请求仍由 SiteBudget 控制;
- 下载提交仍按订阅/幂等键串行;
- 不共享可变 `Context`;
- worker 输入使用不可变快照或副本;
- worker 失败不能取消其他订阅;
- 系统停止和任务取消必须可传播。
## 10. 上线前验证:隔离重放,不做生产 shadow
### 10.1 一次性输入快照
从生产或生产形态实例一次性提取用于测试的输入,不需要常驻日志聚合器:
- 订阅业务字段快照;
- RSS/Spider `Context` 候选快照;
- 站点选择和过滤规则配置;
- 媒体库存在/缺失返回;
- TMDB 季集事实;
- 下载历史与洗版优先级;
- 预期匹配、下载计划、进度更新和完成集合。
不得包含站点 Cookie、token、下载器凭据等运行秘密。是否对标题做隐私脱敏不影响治理设计,但提交给上游的
公开夹具仍应只保留可复现业务字段。
### 10.2 参考结果生成
在隔离测试进程中,用当前 canonical 逻辑对固定快照生成参考结果:
- matched candidate set;
- planned download set;
- missing episode set;
- completion/update/delete set;
- 外部请求计数;
- 候选扫描计数。
然后只运行新链路,对相同快照断言结果。该过程属于离线回归验证,不部署双路径,也不允许任何真实下载、
站点搜索、TMDB 或媒体服务器出站。
### 10.3 正确性场景
至少覆盖:
1. RSS/Spider 无新候选;
2. 有新候选但全部不相关;
3. 电影命中;
4. 电视剧单集、连续集、全集、多季包;
5. 12→13 集季集增长;
6. 手工总集数;
7. 普通订阅和全集/分集洗版;
8. 自定义识别词;
9. 标题兜底和候选身份冲突;
10. 不同媒体源身份;
11. 音乐订阅;
12. 媒体库已存在、部分缺失、不可用;
13. TMDB 失败、空结果和慢响应;
14. 下载器接受、拒绝、超时和结果不确定;
15. 手工、日常和兜底入口重叠;
16. 取消、停机和重启恢复;
17. 插件对完成事件或候选诊断的兼容行为。
### 10.4 规模矩阵
至少验证:
| 档位 | 订阅 | 站点 | 候选 |
| --- | ---: | ---: | ---: |
| 小 | 20 | 5 | 500 |
| 目标一 | 100 | 10 | 1000 |
| 目标二 | 200 | 20 | 2000 |
每档都需要分别测试:
- 无相关候选;
- 1% 订阅受影响;
- 10% 订阅受影响;
- 大量 fallback 候选;
- TMDB/媒体服务器正常、慢和失败。
建议初始性能门槛:
- 无相关候选时,不允许产生逐订阅 TMDB/媒体服务器调用;
- 候选扫描量应从 `S * C` 收敛到候选索引和实际关联集合;
- 目标一的 Match 阶段 p95 不高于 15 秒;
- 目标二的 Match 阶段 p95 不高于 30 秒;
- 无相关候选的 Match 阶段 p95 不高于 5 秒;
- 首个可提交下载计划不应等待整个批次完成;
- 同一输入连续运行不得出现不可解释的线性退化。
绝对时限可以由上游基于参考硬件调整,但不能删除结构性门禁,也不能只用小样本证明“已优化”。
## 11. 生产发布方式
### 11.1 发布前门禁
必须同时满足:
- 新链路全量 focused/集成测试通过;
- 固定快照的匹配、下载计划、缺集和完成集合一致;
- 12→13 集正确性门禁通过;
- 目标规模压测通过;
- 站点请求模型没有增加 RSS/Spider 压力;
- 重叠入口不产生重复下载;
- 取消、重启和下载器不确定状态有可验证结果;
- 数据库迁移可从旧版本升级,并支持旧镜像读取或有明确恢复步骤。
### 11.2 上线行为
上线后:
- 只有新治理链路运行;
- 不调用旧 Match 做结果对照;
- 不为对照重复读取 RSS/Spider、TMDB、媒体服务器或下载器;
- 不维护 baseline/shadow/active 三模式;
- 不需要独立日志聚合器。
建议首个生产窗口由现有运维日志和新链路自身的批次摘要确认:
- 批次总耗时和各阶段耗时;
- 候选总数、delta、retry、终态数;
- 受影响订阅数;
- TMDB/媒体服务器请求数;
- 每站点请求数、在途峰值和冷却;
- 下载计划、accepted、rejected、uncertain;
- 完成、失败、取消和恢复数。
这些是新链路的运行结果,不是一套额外的文本日志采集产品。
### 11.3 回滚条件
出现以下任一情况应停止新版本并回滚:
- 漏匹配或错误匹配;
- 错误完成/删除订阅;
- 重复下载;
- RSS/Spider 请求次数或站点峰值压力增加;
- 同一站点出现连续 `429/403` 且预算未降压;
- 大量 `uncertain` 无法对账;
- 任务取消后仍继续提交下载;
- 数据库状态无法被上一稳定版本读取;
- 目标规模下 Match 持续超过发布门槛并造成任务重叠。
### 11.4 回滚方式
优先采用:
1. 停止调度器继续产生新批次;
2. 等待或取消未提交任务;
3. 对 `submitting/uncertain` 任务先与下载器对账;
4. 备份数据库;
5. 回滚到上一稳定镜像/版本;
6. 若有新增表或字段,保持 additive migration,使旧版本可以忽略;
7. 恢复调度前核对活跃订阅、下载任务和未决幂等记录。
不建议在生产保留一套旧 Match 代码作为运行时 fallback;这会让两套语义长期漂移。回滚单位应是完整版本。
## 12. 推荐实施顺序
上游可以拆成多个可审查提交或 PR,但部署到生产的版本应包含一条完整可用的新链路。
| 阶段 | 交付内容 | 核心验收 |
| --- | --- | --- |
| 1 | 固定当前正确性合同和可重放夹具 | 12→13、匹配/下载/完成集合可重复 |
| 2 | 引入 CandidateBatch、delta 和最小 retry/pending 账本 | 不丢临时失败或新建订阅回看候选 |
| 3 | 建立一次构建的无损 CandidateIndex | 不再 `S * C` 全量扫描,fallback 不漏匹配 |
| 4 | 分离资源匹配与完成对账 | 无候选不做资源准备,完成正确性保持 |
| 5 | 引入本轮 FreshFactLease 与媒体服务器事实合并 | 外部请求按唯一受影响媒体而不是订阅数增长 |
| 6 | 拆分 prepare/commit,建立 single-flight 和提交幂等 | 重叠入口不重复提交,uncertain 可对账 |
| 7 | 兜底搜索移出长锁,建立队列、游标和 SiteBudget | 不阻塞 Match,可取消、恢复、降压 |
| 8 | 后端业务状态和前端展示 | 用户看到真实进度、失败、取消和未决状态 |
| 9 | 条件性有界 prepare worker | 只有前述治理后仍有热点才启用 |
| 10 | 跨订阅记录的同媒体去重 | 最低优先级,不阻断单记录治理 |
每个阶段都必须复用一套 canonical 匹配、缺集、洗版和完成规则,不能为了拆 PR 复制业务实现。
## 13. 测试与检查建议
### 13.1 单元测试
- CandidateBatch 状态转换;
- retry/pending TTL 和重试时间;
- CandidateIndex 类型、身份、季和站点路由;
- unknown/custom/title fallback;
- FreshFactKey 隔离 `season` 和 `episode_group`;
- subscription single-flight;
- 幂等键和提交终态;
- SiteBudget 间隔、冷却和恢复;
- 取消状态机;
- 完成对账的 12→13 集语义。
### 13.2 集成测试
- `refresh -> match -> download -> completion`;
- RSS 与 Spider 各自缓存边界;
- 音乐缓存不被影视候选写回覆盖;
- 日常 Match 与兜底搜索重叠;
- 手工任务插队但不突破 SiteBudget;
- 下载器接受后本地写入失败;
- 进程在 queued/preparing/submitting/uncertain 阶段重启;
- 插件事件与旧公开入口兼容。
### 13.3 性能测试应记录
- 候选索引构建时间;
- 候选访问/比较次数;
- 受影响订阅数和 fallback 数;
- 每阶段 p50/p95/max;
- TMDB/媒体服务器调用数;
- 每站点请求数、峰值在途和最小间隔;
- 下载提交尝试数和重复抑制数;
- event-loop lag、线程/任务遗留和 shutdown 时间。
## 14. 可直接复用的现有基础
1. `TorrentsChain.get_subscribe_cache_candidates()` 已实现候选深拷贝、媒体身份、类型和季的轻量筛选;
2. `Context` 已有 `resource_source`、`match_source`、`candidate_recognized`、
`media_info_is_target` 等候选来源字段;
3. `finish_subscribe_or_not()` 和 `__refresh_total_episode_before_completion()` 已承载 canonical 完成语义;
4. `resolve_subscribe_missing()` 已将“查询缺失但不推进状态”抽成可复用入口;
5. 下载链已有资源 key、失败冷却、下载历史和订阅来源信息,可用于构造幂等与对账合同;
6. Scheduler 已有任务进度回调,可作为批次业务状态的兼容入口;
7. 提交 `d923037f9` 可作为新鲜季集语义测试的移植参考。
这些基础仍需要整合,不能因为局部 helper 已存在就认为执行治理已完成。
## 15. 上游交付完成定义
上游完成交付时,应能提供以下证据:
1. 新架构说明和状态/所有权边界;
2. 候选 delta、retry/pending 和索引的实现;
3. 资源匹配与完成对账分离;
4. 本轮新鲜事实租约;
5. prepare/commit、single-flight、幂等和 uncertain 对账;
6. 兜底搜索队列、游标和每站点预算;
7. 目标规模的可重放性能结果;
8. 正确性、站点压力、重复下载、取消和恢复测试;
9. 单一新链路的生产发布和版本级回滚说明;
10. 后端业务状态,及后续前端展示的稳定契约。
如果最终实现仍然满足以下任一情况,则治理尚未完成:
- 每条订阅扫描全部候选;
- 无相关候选仍逐订阅调用 TMDB/媒体服务器;
- 兜底搜索等待仍持有 Match/提交锁;
- 依赖全局锁防止重复下载;
- 下载器不确定状态会自动重试;
- 100–200 条订阅只能靠跨轮分片才能运行;
- 生产需要旧、新双跑才能建立信心;
- 用户仍只能从文本日志猜测任务是否成功。
## 16. 需要上游优先确认的设计决定
请上游在实现前明确以下决定,并把结果固定在代码合同或设计文档中:
1. CandidateBatch 与 retry/pending 的内存/持久化边界;
2. torrent/resource 稳定身份的 canonical 计算方式;
3. FreshFactLease 的 key 和生命周期;
4. 完成对账的触发来源与最低频率;
5. subscription single-flight 与下载幂等的唯一 owner;
6. 下载器 accepted/uncertain 的可对账接口;
7. SiteBudget 的持久化、冷却和手工任务优先级;
8. 生产回滚时新增状态表/字段的兼容策略;
9. 目标规模压测使用的参考硬件和最终绝对时限;
10. 后端批次/订阅任务状态的 API 契约。
这些是实现必须作出的技术决定,不需要通过生产 shadow A/B 才能确定。

View File

@@ -0,0 +1,340 @@
# MoviePilot 订阅执行治理
> 状态:`active(2026-09-01 已由 MoviePilot v3 接管)`
> 当前叶:`SUB-GOV-001B`
> 迁移来源:`V3-RDY-009A`、`V3-RDY-009A1`、`V3-RDY-009A1B`、
> `V3-RDY-009A1C`、`V3-RDY-009A1D`
> 适用范围:V3 订阅刷新、匹配、兜底搜索、下载提交与用户状态闭环
本 Goal 从 V3 发布准备总 roadmap 中独立出来。总 roadmap 只保留状态和链接,本文件负责订阅域的
事实、方案、依赖、上线前验证、叶子交付与停止条件。
面向上游的完整问题与实施交接见
`docs/refactor/moviepilot-subscription-governance-handoff.md`。生产发布不采用 shadow A/B 或旧新双跑:
上线前使用固定快照隔离重放和目标规模压测,上线后只运行新治理链路,异常时按完整版本回滚。
## 1. 目标
在不增加 PT 站点压力、不牺牲订阅完成正确性、不制造重复下载的前提下,解决以下问题:
1. 日常 `refresh -> match` 在订阅较多时线性变慢,即使还没有进入 24 小时兜底搜索也会影响用户;
2. 24 小时兜底搜索在全局锁内逐订阅等待和访问站点,放大日常 Match 的锁竞争;
3. RSS、爬虫 Match、手工搜索和兜底搜索缺少统一的订阅级 single-flight、提交幂等与取消终态;
4. 用户看不到排队、匹配、搜索、下载提交、失败和取消的业务状态,容易把后台失败理解为操作成功或订阅消失。
最终用户价值不是单纯缩短某个函数耗时,而是:日常资源到达后尽快完成匹配,兜底搜索不会阻塞日常
Match,同一资源不会因链路重叠重复提交,慢站点或 TMDB 故障不会拖死整轮,用户能够看到真实进度和失败。
目标常规规模明确为:`100–200` 条活跃订阅、`10–20` 个启用站点、单轮 `1000+` RSS/Spider 候选。
达到该规模时不得依赖跨刷新周期漏处理候选来缩短表面耗时,最终验收仍以正确性、站点压力和用户完成时间
共同决定,而不是只满足单个吞吐数字。
## 2. 边界
本 Goal 只按单媒体服务器场景设计和验收,不扩建多媒体服务器并发、聚合或 provider 分片。
本 Goal 不做以下事情:
- 不把 `match()` 简单改成 `async def`,也不以删除锁作为完成标准;
- 不通过跨刷新周期轮流处理订阅来隐藏耗时,因为这会漏掉短生命周期候选;
- 不把所有订阅或站点请求直接并发化;
- 不把 TMDB 详情长期缓存当成性能修复;
- 不在当前叶处理多条订阅记录指向同一媒体的产品规则;
- 不重构通用 Scheduler、TaskRegistry 或媒体服务器框架。
同步实现可以继续位于受控 worker 内,但网络、数据库和文件 I/O 不得阻塞主事件循环。订阅完成、下载事实、
洗版优先级和缺集口径继续只有一套 canonical 业务实现。
## 3. 已确认事实
### 3.1 日常路径
当前日常流程为:
```text
Scheduler / 手工刷新
|
v
TorrentsChain.refresh()
RSS: 每站点读取一次 RSS
Spider: 每站点最多抓取两页
对新种子做媒体识别并写入本地缓存
|
v
SubscribeChain.match()
获取 search/match 共用类级锁
读取所有 R/P 订阅
每条订阅重新识别目标媒体
每条订阅查询媒体库缺失情况
每条订阅扫描本轮返回的完整候选缓存
匹配后下载、完成判定和状态写回
|
v
释放类级锁
```
生产 V2 `2.15.5` 的少量订阅样本显示:
| 指标 | 中位数 | 解释 |
| --- | ---: | --- |
| 单轮日常 Match | 约 `9.931s` | 不含 24 小时兜底搜索 |
| 单条订阅 | 约 `395ms` | 当前样本订阅数量较少 |
| 媒体识别与媒体库检查 | 约 `357ms` | 是当前单条订阅主要耗时 |
| 扫描约 600 条候选 | 约 `37ms` | 当前样本不是主要热点,但复杂度仍为订阅数乘候选数 |
这些数据来自生产只读日志,只能证明当前样本的热点分布,不能直接外推大量订阅的收益比例。
2026-08-24 对 NAS 上 V2 `2.15.5` 的 11 轮连续生产日志再次做了只读聚合:每轮约 12 条订阅、5 个
候选域、500 条候选,Match 中位数约 `17.752s`,范围约 `11.049–29.200s`;单订阅相邻日志跨度的
中位数约 `0.4s`,每轮 TMDB 查询日志为 `12–63` 次。该样本进一步确认日常 Match 已有明显波动,
并且候选重识别会放大外部请求;日志计数只作为 001A 基线,不替代后续同 revision 固定快照隔离重放。
### 3.2 调度与站点访问
| 来源 | 默认调度 | 单轮站点访问模型 | 与订阅数量关系 |
| --- | --- | --- | --- |
| RSS 刷新 | 默认每 30 分钟 | 每个启用站点读取一次 RSS | 不随订阅数线性增加 |
| Spider 刷新 | 每天随机 32 次 | 每个启用站点最多抓取两页 | 不随订阅数线性增加 |
| 日常 Match | 紧随 RSS/Spider 刷新 | 候选匹配主要在本地;命中后可能获取种子并提交下载 | 计算和 TMDB/媒体库查询随订阅数增加 |
| 新增订阅搜索 | 每 5 分钟检查 | 仅处理新建订阅,调用站点搜索 | 随新增订阅增加 |
| R/P 兜底搜索 | 可配置,默认间隔 24 小时且默认关闭 | 每条订阅搜索多个站点;不同关键词间再等待 1–10 秒 | 随订阅、站点和关键词近似相乘 |
站点搜索当前会并发访问不同站点,同一站点按页顺序请求。60–300 秒等待位于兜底搜索的逐订阅循环内,
并且整个搜索持有与 Match 共用的类级锁。该等待既是跨用户错峰手段,也是线性累计耗时和锁竞争来源;
不能只删除等待,也不能继续让它占用业务锁。
### 3.3 TMDB 缓存语义
日常 Match 的 `cache=False` 会同时绕过媒体识别映射缓存和 TMDB HTTP 响应缓存。它不是单纯的性能疏漏:
Match 会在完成前用新鲜季集数据修正总集数。若直接切为最长可达 24/72 小时的普通缓存,可能把仍在更新的
12/13 集剧集错误判定完成并删除订阅。
因此当前未提交候选中的 `cache=True` 不可交付,必须撤销。可优化的是“本轮怎样取得一次可信的新鲜事实”,
而不是“是否长期复用旧详情”。
### 3.4 当前结构性问题
1. 资源刷新只把新种子追加到缓存,但随后把完整缓存交给 Match;旧候选会在每轮被重复扫描;
2. Match 先为所有订阅执行 TMDB 和媒体库检查,之后才知道本轮候选是否可能命中;
3. 订阅数 `S` 与候选数 `C` 的核心循环近似 `O(S * C)`;
4. 候选获取、资源匹配、完成对账、下载提交和状态写回被包在同一个长锁生命周期;
5. 兜底搜索在锁内执行随机等待和站点网络 I/O;
6. 不同入口缺少统一订阅级 single-flight,提交前也没有明确的跨入口幂等合同;
7. 停止只能在订阅或关键词边界观察,不能可靠中断随机等待和正在执行的同步网络调用。
## 4. 不变量
任何方案必须同时保持:
1. RSS 和 Spider 的默认站点请求频率不得增加;
2. 兜底搜索的峰值每站点并发不得高于基线,出现 `429/403` 或超时时应降低压力而不是重试放大;
3. 每轮需要完成判定时必须使用足够新鲜的季集事实,不能因缓存错误完成或删除订阅;
4. 下载前必须重新读取订阅、缺集、下载历史和洗版优先级,准备阶段的快照不能直接作为提交依据;
5. 同一订阅、同一 torrent、同一季集覆盖在入口重叠时至多提交一次;
6. 失败、取消、停机和锁等待失败不能写入成功或完成状态;
7. 一条订阅失败不得阻止批次中其他订阅继续,但最终批次必须暴露聚合失败;
8. 手工刷新可以优先,但不能绕过站点预算和下载幂等;
9. 音乐订阅在缺少独立准备/提交合同前保持串行;
10. 所有优化都必须能独立关闭或回滚到稳定 V3 行为。
## 5. 方案评估
### 5.1 方案矩阵
| 方案 | 预期收益 | 站点压力 | 正确性/维护风险 | 决策 |
| --- | --- | --- | --- | --- |
| 保持全量串行,只增加日志 | 无直接性能收益 | 不变 | 用户问题继续存在 | 仅作为基线 |
| Match 全部改用 `cache=True` | 当前样本可能减少约 0.2–0.35s/订阅 | 不变 | 可能错误完成正在更新的剧集 | 拒绝 |
| 全部订阅直接开 2/4 worker | 可缩短 TMDB/媒体库等待 | Match 本身不直接增加 PT 请求,但可能放大 TMDB、媒体服务器和下载并发 | 线程安全、完成竞态、重复下载 | 不作为首选,只在前置治理后做隔离压测 |
| 候选建立媒体身份/站点/季索引 | 降低 `S * C` 本地扫描 | 不变 | 自定义词和标题兜底不能被粗索引误删 | 建议,先作为无损前置门禁 |
| 仅匹配本轮新增候选 | 显著减少重复扫描 | 不变 | 临时失败、后建订阅和旧候选重试可能丢失 | 不能单独采用,需 pending/replay 合同 |
| 新增候选优先,旧候选按有界重试账本回放 | 降低常态扫描并保留失败恢复 | 不变 | 需要候选身份、终态和 TTL | 推荐目标方案 |
| 先候选门禁,再查 TMDB/媒体库 | 没有相关候选时跳过主要热点 | 不变 | 完成对账不能随候选一起跳过 | 推荐,但必须拆出独立完成对账 |
| 单轮新鲜事实租约 | 同媒体在一轮只请求一次,不跨轮复用旧详情 | 不变 | 同媒体不同 episode group/季必须进入 key | 推荐 |
| 稳定元数据复用 + 轻量季集查询 | 可能替代每条完整详情请求 | 不变 | TMDB 返回合同、地区词和 episode group 需验证 | 优先探针,收益成立再实现 |
| 单媒体服务器单轮请求合并 | 重复目标只查一次媒体库 | 不变 | 不同订阅范围、洗版规则不能共用最终缺集结果 | 可做事实层合并,不缓存业务判定 |
| 同轮有界 2/4 worker 准备 | 隐藏独立 TMDB/媒体库等待 | 不变 | 共享缓存和同步客户端线程安全;提交顺序变化 | 候选门禁后仍慢时再启用 |
| 跨刷新周期分片订阅 | 每轮变快 | 不变 | 候选可能在轮到订阅前过期,延迟不可控 | 拒绝作为默认方案 |
| 按候选分片,同轮处理完 | 可复用候选索引,适合候选远少于订阅 | 不变 | 标题兜底和自定义词需保守回退 | 推荐的内部执行形态 |
| 兜底搜索批次只随机等待一次 | 去掉按订阅线性累计 | 峰值可能集中 | 大量实例在相近时间同时访问站点 | 不能单独采用 |
| 兜底搜索持久游标 + 每站点预算 | 不占长锁,按容量持续推进 | 可明确限制且具备退避 | 需要批次身份、恢复和公平性 | 推荐 |
| RSS/Spider 站点并发刷新 | 缩短站点获取阶段 | 提高瞬时站点压力 | PT 站点承载和风控不确定 | 默认不做 |
| 全事件驱动替代定时刷新 | 长期可减少无效工作 | 取决于上游能力 | 改动大,历史插件/站点无统一事件源 | 远期,不进入首轮 |
### 5.2 推荐架构
推荐把现有长函数拆成三个业务阶段,但不要求一次 PR 完成重构:
```text
RSS / Spider / 兜底搜索
|
v
候选账本与本轮 delta
- torrent 身份、来源、站点、首次/最后出现时间
- pending / matched / rejected / retryable / expired
|
v
无损候选索引与受影响订阅集合
- 媒体 ID 可确定时直接反向定位
- 标题兜底、自定义识别词和旧缓存进入保守集合
|
v
准备阶段(可取消,可选 2/4 有界 worker)
- 本轮新鲜 TMDB 事实租约
- 单媒体服务器事实查询
- 规则匹配和下载计划
|
v
串行提交阶段
- 重读订阅和下载事实
- 幂等校验
- 提交下载
- 写入进度、优先级和完成状态
```
完成对账必须是独立路径:候选门禁可以决定“本轮是否值得做资源匹配”,但不能决定“订阅是否已经完成”。
建议将完成对账按较低频率或在媒体库/元数据变化事件后执行;它仍使用新鲜季集事实,但不扫描站点候选。
### 5.3 站点压力治理
RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收益。兜底搜索从“订阅级随机休眠”改为“站点级
容量预算”:
- 每个站点最多 1 个在途搜索;
- 同一站点请求之间保留随机间隔,具体区间通过离线压测和受控生产运行指标确定,不在设计阶段拍定;
- 批次启动保留全局随机抖动,避免大量 MoviePilot 实例整点同时启动;
- `429/403`、登录失效和连续超时触发站点级冷却,不让其他订阅立即重试同一站点;
- 手工搜索可以提高队列优先级,但不能绕过站点并发和冷却;
- 站点预算由订阅域消费,首轮不扩建成全应用通用限流平台。
该设计让等待不再持有 Match/提交锁,同时比“每条订阅固定睡眠”更直接地约束真实外部请求。
### 5.4 下载防重与锁边界
锁应按事实所有权拆分,而不是按入口拆分:
1. 候选获取不持有订阅提交锁;
2. TMDB、媒体服务器和站点网络请求不持有提交锁;
3. 同一订阅的准备任务使用 single-flight 合并;
4. 下载提交使用短临界区,并在临界区内重读当前订阅、下载历史、媒体库缺失和洗版优先级;
5. 幂等键至少包含订阅逻辑身份、torrent 身份和计划覆盖的季集范围;
6. 下载器接受但本地写回失败属于“不确定”终态,不能自动重试造成重复任务,必须先对账下载器。
同媒体的多条重复订阅需要更高一层的跨记录幂等,保留在最低优先级叶,不阻断单条订阅治理。
## 6. 交付 Goal 树
| ID | 交付边界 | 依赖 | 状态 |
| --- | --- | --- | --- |
| `SUB-GOV-000` | 固定历史行为和生产日志;保留 `V3-RDY-009A1A` 已交付的批量请求正确性 | 无 | `completed` |
| `SUB-GOV-001A` | 建立日常 Match 正确性和可重放夹具;撤销 `cache=True` 错误候选,只保留经验证不改变语义的失败识别状态回写 | 000 | `completed(2026-09-01)` |
| `SUB-GOV-001B` | 区分完整缓存与本轮 delta,建立无损候选索引;先证明命中集合与基线完全一致 | 001A | `in_progress` |
| `SUB-GOV-001C` | 将资源匹配与完成对账拆开;只有受候选影响的订阅进入匹配,完成对账独立保持新鲜语义 | 001B | `pending` |
| `SUB-GOV-001D` | 引入单轮新鲜事实租约,评估稳定元数据复用、轻量季集查询和单媒体服务器事实合并 | 001C | `pending` |
| `SUB-GOV-001E` | 若 001B–D 后仍有可重复等待热点,再通过隔离压测评估 2/4 worker 的有界准备;提交保持串行 | 001D | `conditional` |
| `SUB-GOV-002A` | 将 24 小时兜底搜索移出 Match/提交长锁,建立批次、订阅 single-flight、可取消等待和恢复游标 | 001C | `pending` |
| `SUB-GOV-002B` | 为兜底搜索建立每站点并发、间隔和错误冷却预算;RSS/Spider 保持基线压力 | 002A | `pending` |
| `SUB-GOV-002C` | 联合运行日常 Match、手工搜索和兜底搜索,验证公平性、锁等待、站点压力和失败恢复 | 001D, 002B | `pending` |
| `SUB-GOV-003A` | 建立跨入口的订阅级下载幂等、下载器不确定终态和取消补偿 | 001C, 002A | `pending` |
| `SUB-GOV-003B` | 后端提供订阅及批次业务状态,前端展示排队、匹配、搜索、提交、完成、失败和取消 | 002A, 003A | `pending` |
| `SUB-GOV-004` | 多条订阅记录指向同一媒体时的跨记录季集去重和产品规则 | 003A | `pending(最低优先级)` |
当前只激活 `SUB-GOV-001B`。001B–D 是日常 Match 主线;002A–C 是 24 小时兜底搜索主线;003A/B
只有在前两条链路的身份和终态稳定后实施。每个叶子完成验收并更新本表后,才激活下一个满足依赖的叶子。
### 6.1 SUB-GOV-001A 验收证据
- 基线:MoviePilot `v3` 的 `f5c73dcab`;原 handoff 提交 `d923037f9` 未进入当前仓库历史;
- 固定输入:`tests/fixtures/subscription_governance_match_replay.json`;
- 重放入口:`tests/test_subscription_governance_replay.py` 从真实 `SubscribeChain.match()` 进入;
- 合同:日常 Match 明确传入 `cache=False`;12 集事实保持完成,12→13 集时写入
`total_episode=13`、`lack_episode=1` 且不完成订阅;
- 当前 `match()` 没有把失败识别次数写回 RSS/Spider 缓存,因此本叶未引入未经验证的缓存覆盖;
- 验证:上述重放、现有订阅 Chain 与候选筛选共 `118 passed`,固定 JSON、文档链接和
`git diff --check` 通过。
## 7. 上线前验证与验收
### 7.1 场景
每个行为叶必须使用同一 revision、同一固定数据快照:先由当前 canonical 逻辑生成参考集合,再在隔离环境中
只运行新链路并比较结果。该流程不进入生产,也不产生真实外部请求和下载副作用。
1. RSS,无新候选;
2. RSS,少量新候选且不命中;
3. RSS,新候选命中电影、追更剧集、全集和分集洗版;
4. Spider,同样三档候选;
5. 24 小时兜底搜索无结果、部分结果、站点超时和 `429/403`;
6. 日常 Match 与兜底搜索重叠;
7. 手工刷新或手工搜索与后台任务重叠;
8. 下载器已接受但本地写回失败;
9. 停机、取消和进程重启后的恢复;
10. 单媒体服务器可用、慢响应和不可用。
订阅规模使用真实形态的小/中/大档,不以固定的 1/10/101 档位作为治理要求;候选数量与站点数量也要
独立缩放,避免只测订阅数量。
### 7.2 指标
| 维度 | 必测指标 |
| --- | --- |
| 用户耗时 | 刷新总耗时、首个匹配/首个下载时间、批次完成 p50/p95 |
| 本地计算 | 候选预处理、每订阅匹配、完成对账、提交耗时 |
| TMDB | 请求数、缓存/租约命中、错误率、p50/p95 |
| 媒体服务器 | 请求数、p50/p95、超时和合并命中 |
| PT 站点 | 按站点请求数、峰值在途、最小间隔、429/403/超时、重试数 |
| 正确性 | 命中集合、下载集合、缺集集合、完成/删除集合与基线一致 |
| 幂等 | 重复提交数、下载器不确定终态、恢复后重复任务数 |
| 生命周期 | 取消延迟、event-loop lag、遗留 task/worker/锁、shutdown 上限 |
### 7.3 门禁
- 日常 Match 的站点请求数和峰值不得增加;
- 兜底搜索的每站点峰值在途不得超过基线,分布式错峰不能退化为整点突发;
- 所有正确性集合必须与基线一致,新增完成对账用例必须覆盖 12/13 集更新;
- 重叠入口不得产生重复下载;
- 候选在无外部错误时不得出现可重复性能退化;
- `100–200` 条订阅、`10–20` 个站点和单轮 `1000+` 候选必须进入最终受控样本矩阵,且日常 Match
不得因 `S * C` 全量扫描或逐订阅重复外部查询而失去可用性;
- 性能收益不足以覆盖维护成本时停止对应叶,保留更小方案;
- 本地开发数据只验证可重放性,最终性能结论必须来自受控样本或生产只读证据。
### 7.4 生产发布边界
- 不采用 shadow A/B、baseline/shadow/active 三模式或旧新双跑;
- 上线后只有新治理链路运行和写入状态;
- 生产运行指标只用于确认新链路健康,不承担旧新结果对照;
- 不单独交付文本日志聚合器;
- 异常时按完整镜像/版本回滚,不长期保留运行时旧链路 fallback。
## 8. 当前代码与 checkpoint 边界
原交接环境形成过一个可复用的正确性 checkpoint:
- `d923037f9 test(subscribe): guard fresh match metadata` 固定日常 Match 的 `cache=False` 和 12→13 集增长语义;
- focused pytest、Pylint 和 `git diff --check` 在该提交形成时通过。
该提交不在当前 MoviePilot `v3` 仓库历史中,只作为 `SUB-GOV-001A` 的测试设计参考。当前分支必须重新
建立等价夹具并产生本仓可验证的提交证据。
其余旧候选仍不构成交付:
- `match()` 改为 `cache=True` 已被完成语义证据否决,必须撤销;
- 失败候选识别次数写回本地缓存可能避免每轮重复识别,但仍需证明不会覆盖音乐缓存、站点过滤结果或
Spider/RSS 对应文件;
- 现有测试只证明了参数或局部调用,没有证明 TMDB 请求减少、订阅完成正确、站点压力不变和下载集合一致;
- 旧 checkpoint `c2d72280e` 与 10/100/500 数据只覆盖兜底搜索等待,不再是 A1B 的交付候选。
候选回写语义、完整缓存与本轮 delta、无损索引和真实性测试仍需由后续实现证明。治理文档现位于
`docs/refactor/` 并属于后端 Git 仓;文档状态与代码提交证据仍分别记录,不能用路线图状态替代行为验收。
## 9. 停止条件
满足以下条件后,本 Goal 完成:
1. 日常 RSS/Spider Match 在目标样本下达到可接受完成时间,且 PT 请求压力不增加;
2. 24 小时兜底搜索不再持有 Match/提交长锁,按站点预算运行并能取消、恢复;
3. 日常、手工和兜底链路联合运行无重复下载和错误完成;
4. 用户可以看到真实业务状态和失败终态;
5. 所有保留的并发和缓存都有可重放验证、版本级回滚路径和明确 owner;
6. 后续只剩最低优先级的重复订阅产品规则或无生产证据的推测项。

View File

@@ -0,0 +1,20 @@
[
{
"name": "stable_12_episodes",
"initial_total_episode": 12,
"initial_lack_episode": 0,
"fresh_total_episode": 12,
"expected_total_episode": 12,
"expected_lack_episode": 0,
"expected_completed": true
},
{
"name": "expanded_to_13_episodes",
"initial_total_episode": 12,
"initial_lack_episode": 0,
"fresh_total_episode": 13,
"expected_total_episode": 13,
"expected_lack_episode": 1,
"expected_completed": false
}
]

View File

@@ -0,0 +1,178 @@
"""订阅执行治理固定快照重放。"""
import json
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.application.subscription.contract import SubscriptionSnapshot
from app.chain.subscribe import SubscribeChain
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import MediaSource, MediaType
_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "subscription_governance_match_replay.json"
class _ReplaySubscriptionRepository:
"""保存单条订阅快照,供 Match 重放读取更新后的事实。"""
def __init__(self, subscribe: SubscriptionSnapshot) -> None:
self.current = subscribe
def list(self, _state: str = None) -> list[SubscriptionSnapshot]:
"""返回本轮唯一订阅。"""
return [self.current]
def get(self, _subscribe_id: int) -> SubscriptionSnapshot:
"""返回当前已更新快照。"""
return self.current
def update(self, update_data: dict) -> SubscriptionSnapshot:
"""应用测试内的订阅字段更新。"""
self.current = replace(self.current, **update_data)
return self.current
class _ReplayTorrentHelper:
"""让无关候选稳定停在身份冲突边界。"""
@staticmethod
def match_torrent(**_kwargs) -> bool:
"""无关候选不得通过标题复核。"""
return False
def filter_torrent(self, **_kwargs) -> bool:
"""候选若意外进入过滤阶段则保持可观察。"""
return True
def _load_replay_cases() -> list[dict]:
"""读取版本化固定输入快照。"""
return json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
def _build_subscribe(case: dict) -> SubscriptionSnapshot:
"""按固定快照构造已下载 12 集的普通电视剧订阅。"""
return SubscriptionSnapshot(
id=101,
name="增长中的剧集",
year="2026",
type=MediaType.TV.value,
media_source=MediaSource.TMDB,
media_id="100",
season=1,
total_episode=case["initial_total_episode"],
start_episode=1,
lack_episode=case["initial_lack_episode"],
note=list(range(1, 13)),
state="R",
sites=[],
best_version=0,
manual_total_episode=0,
)
def _build_unrelated_candidate() -> Context:
"""构造不会命中目标订阅但可触发完整 Match 流程的候选。"""
return Context(
meta_info=MetaInfo(title="无关剧集 S01E01"),
media_info=MediaInfo(
media_source=MediaSource.TMDB,
media_id="999",
type=MediaType.TV,
title="无关剧集",
year="2026",
),
torrent_info=TorrentInfo(
title="无关剧集 S01E01",
site=1,
site_name="ReplaySite",
pri_order=100,
),
resource_source="rss",
match_source=MediaSource.TMDB.value,
candidate_recognized=True,
)
@pytest.mark.parametrize("case", _load_replay_cases(), ids=lambda case: case["name"])
def test_match_replays_fresh_episode_completion_contract(case, monkeypatch):
"""日常 Match 必须用本轮新鲜季集事实决定 12 集完成或 13 集继续订阅。"""
repository = _ReplaySubscriptionRepository(_build_subscribe(case))
recognition_calls = []
completions = []
fresh_media = MediaInfo(
media_source=MediaSource.TMDB,
media_id="100",
type=MediaType.TV,
title="增长中的剧集",
year="2026",
seasons={1: list(range(1, case["fresh_total_episode"] + 1))},
)
class _ReplayMediaChain:
"""记录 Match 的媒体识别参数并返回固定新鲜事实。"""
def recognize_media(self, **kwargs) -> MediaInfo:
"""返回当前场景的新鲜季集快照。"""
recognition_calls.append(kwargs)
return fresh_media
@staticmethod
def recognize_by_meta(*_args, **_kwargs) -> MediaInfo:
"""候选已有明确身份,本场景不应重新识别。"""
raise AssertionError("明确身份候选不应重新识别")
@staticmethod
def supplement_media_info(mediainfo: MediaInfo) -> MediaInfo:
"""保持固定媒体事实,不引入其他来源。"""
return mediainfo
chain = SubscribeChain()
chain.subscription_repository = repository
chain._SubscribeChain__apply_subscribe_update = (
lambda _subscribe, update_data, **_kwargs: repository.update(dict(update_data))
)
chain._SubscribeChain__finish_subscribe = (
lambda subscribe, **_kwargs: completions.append(subscribe)
)
def _resolve_missing(*, subscribe, mediakey, **_kwargs):
"""按当前总集数重放媒体库缺失事实。"""
if subscribe.total_episode == 12:
return True, {}
return False, {
mediakey: {
1: NotExistMediaInfo(
season=1,
episodes=[13],
total_episode=13,
start_episode=1,
)
}
}
chain.resolve_subscribe_missing = _resolve_missing
monkeypatch.setattr("app.chain.subscribe.match.MediaChain", _ReplayMediaChain)
monkeypatch.setattr("app.chain.subscribe.match.TorrentHelper", _ReplayTorrentHelper)
monkeypatch.setattr(
"app.chain.subscribe.match.get_configured_system_config",
lambda: SimpleNamespace(get=lambda _key: []),
)
monkeypatch.setattr(
"app.chain.subscribe.query.get_configured_system_config",
lambda: SimpleNamespace(get=lambda _key: []),
)
chain.match({"replay.example": [_build_unrelated_candidate()]})
assert len(recognition_calls) == 1
assert recognition_calls[0]["cache"] is False
assert repository.current.total_episode == case["expected_total_episode"]
assert repository.current.lack_episode == case["expected_lack_episode"]
assert bool(completions) is case["expected_completed"]