From 113ca6da92ba7f00fb0032fd149df74b8f40e3ff Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 1 Sep 2026 12:49:14 +0800 Subject: [PATCH] test(subscribe): establish governance replay baseline --- ...iepilot-subscription-governance-handoff.md | 871 ++++++++++++++++++ ...iepilot-subscription-governance-roadmap.md | 340 +++++++ .../subscription_governance_match_replay.json | 20 + tests/test_subscription_governance_replay.py | 178 ++++ 4 files changed, 1409 insertions(+) create mode 100644 docs/refactor/moviepilot-subscription-governance-handoff.md create mode 100644 docs/refactor/moviepilot-subscription-governance-roadmap.md create mode 100644 tests/fixtures/subscription_governance_match_replay.json create mode 100644 tests/test_subscription_governance_replay.py diff --git a/docs/refactor/moviepilot-subscription-governance-handoff.md b/docs/refactor/moviepilot-subscription-governance-handoff.md new file mode 100644 index 000000000..7885ee184 --- /dev/null +++ b/docs/refactor/moviepilot-subscription-governance-handoff.md @@ -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 才能确定。 diff --git a/docs/refactor/moviepilot-subscription-governance-roadmap.md b/docs/refactor/moviepilot-subscription-governance-roadmap.md new file mode 100644 index 000000000..d46ab1876 --- /dev/null +++ b/docs/refactor/moviepilot-subscription-governance-roadmap.md @@ -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. 后续只剩最低优先级的重复订阅产品规则或无生产证据的推测项。 diff --git a/tests/fixtures/subscription_governance_match_replay.json b/tests/fixtures/subscription_governance_match_replay.json new file mode 100644 index 000000000..5d1f9f0fb --- /dev/null +++ b/tests/fixtures/subscription_governance_match_replay.json @@ -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 + } +] diff --git a/tests/test_subscription_governance_replay.py b/tests/test_subscription_governance_replay.py new file mode 100644 index 000000000..e9123f6ee --- /dev/null +++ b/tests/test_subscription_governance_replay.py @@ -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"]