perf(subscribe): bound candidate routing work

This commit is contained in:
jxxghp
2026-09-01 17:08:53 +08:00
parent 77e4a80286
commit 74f6bb89b5
3 changed files with 76 additions and 7 deletions
+5 -3
View File
@@ -77,6 +77,7 @@ class CandidateIndex:
self._unknown: set[int] = set()
self._reconcilable: set[int] = set()
self._explicit_identity: dict[int, tuple[str, str]] = {}
self.last_examined_count = 0
for domain, contexts in candidates.items():
for context in contexts:
position = len(self._ordered)
@@ -136,9 +137,10 @@ class CandidateIndex:
)
routed: CandidateGroups = {}
for position, (domain, context) in enumerate(self._ordered):
if position not in positions:
continue
self.last_examined_count = 0
for position in sorted(positions):
domain, context = self._ordered[position]
self.last_examined_count += 1
if domains and domain not in domains:
continue
torrent_info = getattr(context, "torrent_info", None)
@@ -1,7 +1,7 @@
# MoviePilot 订阅执行治理
> 状态:`active2026-09-01 已由 MoviePilot v3 接管)`
> 当前叶:`SUB-GOV-001E(条件评估)`
> 当前叶:`SUB-GOV-002A`
> 迁移来源:`V3-RDY-009A`、`V3-RDY-009A1`、`V3-RDY-009A1B`、
> `V3-RDY-009A1C`、`V3-RDY-009A1D`
> 适用范围:V3 订阅刷新、匹配、兜底搜索、下载提交与用户状态闭环
@@ -231,15 +231,15 @@ RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收
| `SUB-GOV-001B` | 区分完整缓存与本轮 delta,建立无损候选索引;先证明命中集合与基线完全一致 | 001A | `completed2026-09-01` |
| `SUB-GOV-001C` | 将资源匹配与完成对账拆开;只有受候选影响的订阅进入匹配,完成对账独立保持新鲜语义 | 001B | `completed2026-09-01` |
| `SUB-GOV-001D` | 引入单轮新鲜事实租约,评估稳定元数据复用、轻量季集查询和单媒体服务器事实合并 | 001C | `completed2026-09-01` |
| `SUB-GOV-001E` | 若 001B–D 后仍有可重复等待热点,再通过隔离压测评估 2/4 worker 的有界准备;提交保持串行 | 001D | `in_progress(条件评估` |
| `SUB-GOV-002A` | 将 24 小时兜底搜索移出 Match/提交长锁,建立批次、订阅 single-flight、可取消等待和恢复游标 | 001C | `pending` |
| `SUB-GOV-001E` | 若 001B–D 后仍有可重复等待热点,再通过隔离压测评估 2/4 worker 的有界准备;提交保持串行 | 001D | `not_activated2026-09-01` |
| `SUB-GOV-002A` | 将 24 小时兜底搜索移出 Match/提交长锁,建立批次、订阅 single-flight、可取消等待和恢复游标 | 001C | `in_progress` |
| `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-001E` 的条件评估。001BD 是日常 Match 主线;002A–C 是 24 小时兜底搜索主线;003A/B
当前只激活 `SUB-GOV-002A`。001BD 是日常 Match 主线;002A–C 是 24 小时兜底搜索主线;003A/B
只有在前两条链路的身份和终态稳定后实施。每个叶子完成验收并更新本表后,才激活下一个满足依赖的叶子。
### 6.1 SUB-GOV-001A 验收证据
@@ -290,6 +290,15 @@ RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收
- 验证:订阅与候选回放 `118 passed`,事实租约/治理回放/架构组合 `108 passed`,错误级源代码 Pylint 为 0,
Host 架构基线与 `git diff --check` 通过。
### 6.5 SUB-GOV-001E 条件评估
- 修正 `CandidateIndex` 的末端遍历:路由直接按选中位置读取,不再先枚举完整候选后判断位置集合;
- 受控样本固定为 `200` 条订阅、`20` 个站点、`1000` 条明确身份候选、`100` 个唯一媒体;基线组合量为
`200000`,新索引实际检查并路由 `2000` 条,单轮事实租约执行 `100` 次加载并命中 `100` 次;
- 该样本证明本地 `S * C` 热点和同媒体重复识别已消除,但没有提供 001B–D 后相互独立的 provider/媒体服务器
等待仍主导批次的同 revision 证据;因此不激活 2/4 worker,不引入共享客户端线程安全与提交排序风险;
- 后续若受控生产形态证据再次显示准备等待占主导,可从本条件叶重新开启,但不得绕过 SiteBudget 和串行提交。
## 7. 上线前验证与验收
### 7.1 场景
@@ -5,6 +5,7 @@ from unittest.mock import Mock, patch
from app.application.subscription.candidates import CandidateBatch, CandidateIndex
from app.application.subscription.contract import SubscriptionSnapshot
from app.application.subscription.facts import FreshFactLease
from app.chain.torrents import TorrentsChain
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
@@ -179,3 +180,60 @@ def test_candidate_index_custom_words_preserve_complete_candidate_set():
)
assert routed == candidates
def test_candidate_index_target_scale_avoids_subscription_candidate_product():
"""200 条订阅与 1000 候选只检查命中身份位置,并按唯一媒体合并事实读取。"""
media_count = 100
subscription_count = 200
site_count = 20
candidates = {f"site-{site_index}.example": [] for site_index in range(site_count)}
for candidate_index in range(1000):
media_id = str(1000 + candidate_index % media_count)
domain = f"site-{candidate_index % site_count}.example"
candidates[domain].append(
_context(
f"目标剧集 {media_id} S01E{candidate_index + 1:04d}",
media_id=media_id,
meta_media_id=media_id,
enclosure=f"https://{domain}/{candidate_index}",
)
)
subscribes = [
_subscribe(
id=index + 1,
name=f"目标剧集 {index % media_count}",
media_id=str(1000 + index % media_count),
)
for index in range(subscription_count)
]
candidate_index = CandidateIndex(candidates)
examined = 0
routed = 0
for subscribe in subscribes:
groups = candidate_index.route_for_match(subscribe)
examined += candidate_index.last_examined_count
routed += CandidateBatch.count(groups)
lease = FreshFactLease()
fact_loads = []
for subscribe in subscribes:
lease.get_or_load(
subscribe,
lambda subscribe=subscribe: fact_loads.append(subscribe.media_id)
or MediaInfo(
media_source=MediaSource.TMDB,
media_id=subscribe.media_id,
type=MediaType.TV,
season=1,
),
)
assert len(candidates) == site_count
assert CandidateBatch.count(candidates) == 1000
assert examined == routed == 2000
assert examined < subscription_count * CandidateBatch.count(candidates) // 50
assert len(fact_loads) == media_count
assert lease.loads == media_count
assert lease.hits == subscription_count - media_count