tasklite-engine 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,620 @@
1
+ """纯任务模型增量发现 —— ``register_discovery``(框架默认适配器)。
2
+
3
+ 架构性质:**扫描核心框架无关**——``DiscoveryHandler`` 只依赖
4
+ ``DiscoveryJob``/``DiscoveryContext`` 两个最小协议,运行时**不 import
5
+ 任何 tasklite 模块**;``register_discovery`` 是框架提供的默认封装
6
+ 函数,只通过 ``DiscoveryHost`` 公开协议把扫描器挂到宿主上(TaskLite
7
+ 是其默认实现)。任何实现该协议的宿主/测试桩均可直接复用。
8
+
9
+ 「已见集合」的实现载体:框架的 wall/failed 集合
10
+ (``ctx.is_completed`` / ``ctx.is_failed`` 的 run 派发时刻快照)——
11
+ **无 cursor、无 seen 持久化、无互斥注入、无 poison 表**。
12
+
13
+ 为什么成立:discovery 的 seen 集合与框架 wall/failed 集合是同一份数据
14
+ (「这个内容处理过了」)——后者由后端 ACID 事务保证完整与持久。
15
+ 并发同 cursor_key 扫描无需互斥(「共享可变 seen 集合可被并发 REPLACE
16
+ 覆盖」的前提在本模型下不存在):wall 由后端事务写入,最坏代价
17
+ 是重复 fetch,正确性不受影响。
18
+
19
+ 与旧 API 的差异(迁移须知):
20
+ - 新增必填 ``process_task_type``:process 子任务的 task_type。整页命中判定
21
+ 需要构造 ``f"{process_task_type}::{content_id}"`` 查询 wall/failed 快照。
22
+ - ``cursor_key_func`` 从必填改为可选:仅作 job_id 命名空间前缀(防止跨分组
23
+ 内容 id 碰撞导致 wall 去重误吞),不再有任何游标语义。
24
+ - ``poison_max_attempts`` 删除:坏 item 由 process 子任务的任务层
25
+ retry/DLQ 天然吸收——子任务进 DLQ 即 ``is_failed`` = 已见,不再重新 spawn。
26
+
27
+ 与旧版相比的有意语义差异:
28
+ - 旧版 seen 在扫描时立即更新;本版 wall 快照在 discovery job 派发时固定。同一内容
29
+ 本次 run 内跨页重复出现时会重复 spawn(框架 wall 去重吸收,仅浪费一次
30
+ spawn 调用)。
31
+ - 旧版含毒 item 的页永不整页命中(整页命中判定不含 poison 检查);本版
32
+ DLQ 内容在 failed 快照中算「已见」,含坏 item 的页可正常整页命中。
33
+ - 旧版达 ``max_pages`` 写 ``partial:<page>`` 游标(观测用);本版仅记日志。
34
+ 下次 run 的 fetch 量与旧版等价——两者都每 run 从第 1 页完整重扫。
35
+
36
+ 回调必须为模块级可 pickle 函数(spawn 进程隔离约束,与普通 handler 一致)。
37
+
38
+ ================================================================================
39
+ 需求契约全文(REQUIREMENTS CONTRACT —— 任何实现/重构必须逐条满足)
40
+ ================================================================================
41
+
42
+ ------------------------------------------------------------------------------
43
+ 数据前提(由业务方保证,框架据此设计)
44
+ ------------------------------------------------------------------------------
45
+ 前提 1. 内容源按**时间倒序**分页:最新内容在第 1 页,越翻越旧。
46
+ 前提 2. 每个内容项有**全局唯一 id**(字符串),用于识别「已见」。
47
+ 前提 3. id **不是单调递增的**(新内容的 id 不一定比旧内容大)——「高水位 /
48
+ max-id 游标」方案**不可用**,必须用「完整已见 id 集合」做成员判定。
49
+ 禁止假设 id 可比大小。
50
+
51
+ ------------------------------------------------------------------------------
52
+ 终止条件(唯一的合法终止方式)—— 核心语义
53
+ ------------------------------------------------------------------------------
54
+ T1. 发现扫描从第 1 页开始逐页推进,直至满足**任一**终止条件:
55
+ a) fetch 返回空页(页尾);
56
+ b) 某一页的**所有**内容 id 都已在「已见集合」中(整页命中)。
57
+ T2. 当且仅当整页命中时,才说明「本页及以后全是旧内容」,扫描结束。
58
+ - 单页**部分**命中不算终止:必须继续翻页(本页前半仍有新内容,
59
+ 后续页仍可能有新内容)。
60
+ T3. 仅「完整扫描」结束(整页命中或空页)才返回成功。中途停止(job 失败/
61
+ 重启、id_func 异常中断)**不得**视为完整——本模型无游标可推进,未完成
62
+ 的扫描必须在下一次运行完整重扫(重不漏由确定性 job_id + wall 去重吸收)。
63
+ **退化路径例外**:max_pages 截断与 id_func 全失败
64
+ 早停两处实现返回**成功**(进 wall)并打醒目日志——语义上「未完整」由
65
+ discovery 默认 rerun="every_run" 保证下一会话重扫;若用户将 rerun 覆盖
66
+ 为 never,截断后增量链静默死亡(漏内容且无失败信号),需自行权衡。
67
+ T4. **liveness 例外(max_pages 硬上限,默认 1000)**:持续更新源无法整页
68
+ 命中时按页数上限停止——这是对完整扫描的显式退化路径,**不算完整扫描**:
69
+ 未扫到的更深页内容仍在源上。退化路径**不得**触发 on_missing 差集
70
+ (已见但本次未扫到 = 源端删除检测)——截断时更深页内容并未删除,误报会
71
+ 触发业务的破坏性动作。宁可跳过未扫页,不可无限翻页直到超时判死。
72
+
73
+ ------------------------------------------------------------------------------
74
+ 已见集合 —— 必须完整,禁止截断
75
+ ------------------------------------------------------------------------------
76
+ S1. 已见集合是「自首次运行以来见过的所有内容 id 的并集」,**必须完整**。
77
+ S2. **禁止**把已见集合截断为「最旧 N 个」「最近 N 个」或任何滑动窗口:
78
+ 截断后窗口外的已见 id 下次被误判为「新内容」→ 重复处理,且破坏
79
+ T2 的整页命中判定。
80
+ S3. 已见集合 = 框架 wall/failed 快照(``ctx.is_completed`` / ``ctx.is_failed``),
81
+ 完整与持久由后端 ACID 事务保证。**无独立 seen 持久化、无 compact 编码**
82
+ ——「百万级 seen」由 wall 表承载(wall 本就是完整历史),语义无损。
83
+ S4. 成员判定 ``f"{process_task_type}::{content_id}" in wall∪failed`` 必须
84
+ 覆盖全部已见 id。
85
+
86
+ ------------------------------------------------------------------------------
87
+ 去重与 job_id —— 必须确定性派生
88
+ ------------------------------------------------------------------------------
89
+ - 同一内容项(同 cursor_key 组 + 内容 id)在**任何运行/重启**中必须生成
90
+ **相同**的 job_id,使框架的 wall/queue 去重能吸收重复 spawn(崩溃重跑、
91
+ 并发重复扫描)。
92
+ - **禁止**用随机后缀(uuid/时间戳)派生 job_id——随机 id 使去重失效,
93
+ 同一内容被重复执行。
94
+ - job_id 派生 = ``sanitize_content_id(prefix + content_id)``:content_id
95
+ 是框架净化后的单射转义形式(干净 id 原样、脏 id UTF-8 字节百分号转义、
96
+ 超长截断 + sha256 指纹,不含 ``::``),**直接**用作子任务 job_id。
97
+ ``cursor_key_func`` 返回值仅作命名空间前缀(防跨组 id 碰撞),无游标
98
+ 语义。派生必须与 ``process_task_type`` 的已见判定一致(同一 content_id)。
99
+
100
+ ------------------------------------------------------------------------------
101
+ 防源端删除/漂移语义(REQ-3)
102
+ ------------------------------------------------------------------------------
103
+ - 内容源删除某项记录后,其后续内容前移补位,页内容漂移。
104
+ - 基于「完整已见集合 + 整页命中终止」,源端删除天然免疫:
105
+ - 已见内容即使换页也仍是「已见」→ 不会被当新内容;
106
+ - 新内容总是「未见」→ 在整页命中之前一定会被扫描到。
107
+ - **禁止**用「历史边界 id 集合」代替完整集合做碰撞检测——边界可能被
108
+ 删除,导致后续内容永远无法触发终止 → 全量重扫或无限扫描。
109
+
110
+ ------------------------------------------------------------------------------
111
+ 重新发现(下一次运行的语义)
112
+ ------------------------------------------------------------------------------
113
+ - 每次运行从第 1 页重新开始(不依赖上一次的页码——页内容已漂移,页码无意义)。
114
+ - 重新发现**不会漏新内容**:新内容总在较前页,整页命中之前必被扫描到;已见内容因完整集合被正确跳过。
115
+ - 重新发现**不会重复处理**:已见内容被集合吸收。
116
+
117
+ ------------------------------------------------------------------------------
118
+ 可验证性
119
+ ------------------------------------------------------------------------------
120
+ V1. 契约正确性必须由回归测试固定,至少覆盖:首次全量扫描(空集合 → 逐页
121
+ → 空页终止);中间翻页运行(部分命中页继续翻,整页命中终止);重新发现
122
+ 含新内容(新 id 被处理,旧 id 被跳过);源端删除场景(删中间/删最旧/删最新
123
+ → 不重不漏);崩溃重跑(wall 快照未推进 → 完整重扫 → 去重吸收);
124
+ max_pages 截断不触发 on_missing;id_func 全失败早停不触发
125
+ on_missing;多 cursor_key 组共享 process_task_type 时 on_missing
126
+ 只报本组。
127
+ """
128
+
129
+ import hashlib
130
+ import logging
131
+ import pickle
132
+ import re
133
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Protocol
134
+
135
+ logger = logging.getLogger("tasklite")
136
+
137
+
138
+ # 框架无关协议(G-DISCOVERY):本模块的扫描核心**不 import 任何框架模块**。
139
+ #
140
+ # ``DiscoveryJob`` / ``DiscoveryContext`` 是扫描循环对运行时的全部要求;
141
+ # ``DiscoveryHost`` 是注册适配器对宿主框架的全部要求。
142
+ #
143
+ # 任何实现这三个协议的框架/测试桩都可以复用本模块:TaskLite 只是
144
+ # 框架提供的**默认宿主适配**,不是唯一宿主。
145
+
146
+
147
+
148
+ class DiscoveryJob(Protocol):
149
+ """扫描任务的最小接口(框架无关):payload 提供命名空间 key 的输入。"""
150
+
151
+ payload: Dict[str, Any]
152
+
153
+
154
+ class DiscoveryContext(Protocol):
155
+ """扫描循环需要的最小上下文接口(框架无关)。
156
+
157
+ 语义契约(由宿主保证):
158
+ - ``is_completed`` / ``is_failed``:派发时刻 wall/failed 快照成员判定;
159
+ - ``attempted_uids``:wall∪failed 的只读快照(full 模式 on_missing 差集用)。
160
+ 本模块不要求也不探测任何其他 ctx 字段/方法。
161
+ """
162
+
163
+ def is_completed(self, uid: str) -> bool: ...
164
+ def is_failed(self, uid: str) -> bool: ...
165
+ def attempted_uids(self) -> Iterable[str]: ...
166
+
167
+
168
+ class DiscoveryHost(Protocol):
169
+ """``register_discovery`` 对宿主框架的全部要求(框架无关协议)。
170
+
171
+ 宿主只需提供两个公开能力:注册一个普通 handler、登记 discovery 默认
172
+ rerun。TaskLite 的实现是框架默认适配,但任何实现本协议的对象都可
173
+ 以直接使用 ``register_discovery``。
174
+ """
175
+
176
+ def register_handler(
177
+ self,
178
+ task_type: str,
179
+ handler_func: Callable[..., Any],
180
+ default_resources: Optional[Dict[str, float]] = None,
181
+ payload_schema: Optional[type] = None,
182
+ ) -> None: ...
183
+
184
+ def set_discovery_rerun(self, task_type: str, rerun: str) -> None: ...
185
+
186
+ # 单射转义:job_id 派生采用「百分号转义」。
187
+ # 干净 id 原样输出;脏 id 逐字符按 **UTF-8 字节** → %XX(每字节固定 2 位 hex)。
188
+ # 与 lockfile 的 safe_uid_filename 同款单射论证(见 sanitize_content_id docstring)。
189
+ _JOB_ID_MAX = 120
190
+ _CLEAN_CHARS = re.compile(r"^[A-Za-z0-9\-_.]+$")
191
+ _CLEAN_SINGLE = re.compile(r"[A-Za-z0-9\-_.]")
192
+
193
+
194
+ def _escape_content_id(content_id: str) -> str:
195
+ """单射转义:非 ``[A-Za-z0-9-_.]`` 字符按 UTF-8 字节 → ``%XX``(固定 2 位)。
196
+
197
+ 必须按字节而非码点转义:``ord(ch)`` 的 hex 变长(码点 0x100+ 产生
198
+ 4+ 位 hex),前缀歧义破坏单射(如 U+04E2+'D' 与 U+04E2D 都映射 %4E2D,
199
+ 违背「不同 content_id 永远派生不同 job_id」的核心不变量)。本函数按
200
+ UTF-8 字节转义:每个非干净字符 → 其 UTF-8 字节序列逐字节 %XX(固定 2 位),
201
+ 不同字符映射不同字节序列 → 单射;``%`` 本身字节 0x25 → ``%25``,编码
202
+ 序列中的 ``%`` 永不与用户输入混淆。
203
+ """
204
+ parts = []
205
+ for ch in content_id:
206
+ if _CLEAN_SINGLE.fullmatch(ch):
207
+ parts.append(ch)
208
+ else:
209
+ parts.append("".join(f"%{b:02X}" for b in ch.encode("utf-8")))
210
+ return "".join(parts)
211
+
212
+
213
+
214
+ # 内容 id 净化 —— 单点函数(唯一事实来源)。指纹格式变更会使同一内容
215
+ # 派生新 job_id(存量 wall 去重失效、历史内容一次性重复处理)——该迁移
216
+ # 代价随版本发布说明明示(与 README 承诺一致)。
217
+
218
+
219
+
220
+ def sanitize_content_id(content_id: str) -> str:
221
+ """净化内容 id 以符合 Job.job_id 约束(不含 "::"、长度合理)。
222
+
223
+ 公共 API:存档迁移、job_id 派生等需要与框架同规则净化 content_id
224
+ 的场景,一律用本函数(不要 import 私有函数/裸 SQL)。
225
+
226
+ 单射转义——**不同 content_id 永远派生不同 job_id**:
227
+ - 干净 id(只含 ``[A-Za-z0-9-_.]`` 且 ≤120)→ **原样输出,零后缀**
228
+ (可读性满分,如画廊数字 id ``12345`` 净化后还是 ``12345``);
229
+ - 脏 id → 百分号转义(先 ``%``→``%25`` 再非干净字符→``%XX``),
230
+ 可读、确定、**单射可逆**——与 ``lockfile.safe_uid_filename`` 同款
231
+ 论证:编码序列中的 ``%`` 永不与用户输入的 ``%`` 混淆(后者已被
232
+ ``%25`` 吸收),故映射数学上单射;
233
+ - 长度超限 → 截断 + 8 位 SHA256 指纹(**唯一保留 hash 的场景**;
234
+ 截断破坏单射是显式接受的工程权衡,32bit 碰撞概率可忽略)。
235
+
236
+ 派生规则是单射的 → 业务方**不需要也不允许**自行追加 hash 后缀防碰撞
237
+ (内容变化触发重跑的需求由 rerun 策略承接,见 README/REFACTOR 文档)。
238
+ 规则变更会使同一内容派生新 job_id(存量 wall 去重失效、历史内容一次性
239
+ 重复处理)——该迁移代价随版本发布说明明示(幂等下游可吸收)。
240
+ """
241
+ if _CLEAN_CHARS.fullmatch(content_id) and len(content_id) <= _JOB_ID_MAX:
242
+ return content_id
243
+ escaped = _escape_content_id(content_id)
244
+ if len(escaped) <= _JOB_ID_MAX:
245
+ return escaped
246
+ digest = hashlib.sha256(content_id.encode()).hexdigest()[:8]
247
+ base_limit = _JOB_ID_MAX - 9 # "_" 分隔符 + 8 位指纹
248
+ return f"{escaped[:base_limit]}_{digest}"
249
+
250
+
251
+ class DiscoveryHandler:
252
+ """按 REQUIREMENTS CONTRACT 实现的增量发现 handler(纯任务模型)。
253
+
254
+ **框架无关**:本类及其扫描循环只依赖 ``DiscoveryJob`` / ``DiscoveryContext``
255
+ 两个最小协议(payload + is_completed/is_failed/attempted_uids),运行时**不 import
256
+ 任何 tasklite 框架模块**,可被任何实现这两个协议的宿主直接复用;
257
+ TaskLite 只是框架默认宿主。
258
+
259
+ 框架负责契约规定的全部语义:
260
+ - 从第 1 页起逐页扫描,page 由框架传入,用户 fetch_func 只拉一页;
261
+ - 「已见」判定 = wall/failed 快照成员判定(``ctx.is_completed`` /
262
+ ``ctx.is_failed``),完整集合由框架保证;
263
+ - 整页命中或空页才终止;仅完整扫描结束才返回成功(本模型无游标,
264
+ 崩溃即整轮重扫,重不漏由确定性 job_id + wall 去重吸收);
265
+ - 每个新 item 以净化后的 content_id 交给 ``process_item_func``,
266
+ 用户在其中 spawn 子任务,job_id 直接用框架传入的 content_id;
267
+ - 防源端删除与记录漂移由「完整 wall/failed 集合 + 整页命中」天然保证。
268
+
269
+ 可 pickle 性:本类是模块级类;回调由用户提供,必须为模块级可 pickle
270
+ 函数(与普通 handler 的 spawn 约束一致)。
271
+ """
272
+
273
+ def __init__(
274
+ self,
275
+ fetch_func: Callable[[DiscoveryJob, DiscoveryContext, int], List[Any]],
276
+ id_func: Callable[[Any], str],
277
+ process_item_func: Callable[[DiscoveryJob, DiscoveryContext, Any, str], None],
278
+ process_task_type: str,
279
+ cursor_key_func: Optional[Callable[[Dict[str, Any]], str]] = None,
280
+ max_pages: int = 1000,
281
+ scan_mode: str = "incremental",
282
+ on_missing: Optional[Callable[[DiscoveryJob, DiscoveryContext, List[str]], None]] = None,
283
+ ):
284
+ self.fetch_func = fetch_func
285
+ self.id_func = id_func
286
+ self.process_item_func = process_item_func
287
+ # process 子任务的 task_type——整页命中判定用 f"{ptype}::{content_id}"
288
+ # 查询 wall/failed,必须与用户 spawn 时一致(见 register_discovery
289
+ # 的校验:非空 str 且不含 "::")。
290
+ self.process_task_type = process_task_type
291
+ self.cursor_key_func = cursor_key_func
292
+ # 单次扫描的页数硬上限。持续更新的内容源可能永远无法整页命中
293
+ # (新内容不断把旧内容顶到后面)——无上限时扫描无限翻页,直到超时
294
+ # 被 TIMEOUT kill → Unknown → DLQ 不重试 → 发现链死亡。
295
+ self.max_pages = max_pages
296
+ # 扫描模式:"incremental"(默认,整页命中终止)/
297
+ # "full"(跳过整页命中,一路扫到空页或 max_pages;已见内容仍逐条
298
+ # 跳过,成本只在 fetch 不在处理)。
299
+ if scan_mode not in ("incremental", "full"):
300
+ raise ValueError(
301
+ f"scan_mode must be 'incremental' or 'full', got {scan_mode!r}"
302
+ )
303
+ self.scan_mode = scan_mode
304
+ # full 模式可选的 missing 检测回调——扫描结束后把
305
+ # 「已见但本次未扫到」的 content_id 差集交给业务(= 源端删除/缺失检测)。
306
+ # 模块级可 pickle(spawn 约束)。
307
+ self.on_missing = on_missing
308
+
309
+ def _process_uid(self, content_id: str) -> str:
310
+ """构造 process 子任务的 uid——与用户 spawn 时的一致。
311
+
312
+ 框架传入的 content_id 已净化,用户应直接用其作 ``Job.job_id``:
313
+ ``Job(process_task_type, content_id, ...)``。判定与 spawn 用同一个
314
+ uid 派生,是「已见判定永不失效」的前提(两处不一致 → 永不整页命中
315
+ → 全量重扫到 max_pages)。
316
+ """
317
+ return f"{self.process_task_type}::{content_id}"
318
+
319
+ def __call__(self, job: DiscoveryJob, ctx: DiscoveryContext) -> bool:
320
+ # 可选命名空间前缀(cursor_key_func 仅作 job_id 分组,无游标语义)
321
+ # prefix 不能是 `key + "_"` 的裸拼接——`_` 属于
322
+ # 干净字符集,`(key="ab", cid="c_d")` 与 `(key="ab_c", cid="d")`
323
+ # 派生相同 job_id → 跨组碰撞 → wall 去重静默吞内容(数据丢失)。
324
+ # 采用 length-prefix(`{len(key)}:{key}`):`:` 会被单射转义为
325
+ # %3A,`%3A` 前的数字给出 key 长度 → 拼接边界可复原 → 不同
326
+ # (key, cid) 对永不派生相同字符串(单射保持)。
327
+ prefix = ""
328
+ if self.cursor_key_func is not None:
329
+ key = self.cursor_key_func(job.payload)
330
+ if not isinstance(key, str) or not key:
331
+ raise ValueError(
332
+ f"cursor_key_func must return a non-empty str, got {key!r}"
333
+ )
334
+ prefix = f"{len(key)}:{key}"
335
+
336
+ # 扫描模式与页数上限:优先尊重 job.payload 中的动态指定,未指定则退回 handler 默认值
337
+ scan_mode = self.scan_mode
338
+ if isinstance(job.payload, dict) and "scan_mode" in job.payload:
339
+ p_scan_mode = job.payload["scan_mode"]
340
+ if p_scan_mode not in ("incremental", "full"):
341
+ raise ValueError(
342
+ f"scan_mode in payload must be 'incremental' or 'full', got {p_scan_mode!r}"
343
+ )
344
+ scan_mode = p_scan_mode
345
+
346
+ effective_max_pages = self.max_pages
347
+ if isinstance(job.payload, dict) and "max_pages" in job.payload:
348
+ p_max = job.payload["max_pages"]
349
+ if not isinstance(p_max, int) or isinstance(p_max, bool) or p_max < 1:
350
+ raise ValueError(
351
+ f"max_pages in payload must be a positive int, got {p_max!r}"
352
+ )
353
+ effective_max_pages = p_max
354
+
355
+ # 从第 1 页开始逐页扫描
356
+ page = 1
357
+ # full 模式收集「本次扫描见过的 content_id」——
358
+ # 扫描结束后与已见集合做差集 = 源站删除检测(missing)。
359
+ seen_this_run: set = set()
360
+ # max_pages 截断 ≠ 完整扫描——未扫到的更深页内容
361
+ # 仍在源上,此时差集会误报为「已删除」。仅当扫到空页(完整结束)
362
+ # 时 on_missing 差集才语义成立。
363
+ completed_full = True
364
+ while True:
365
+ if page > effective_max_pages:
366
+ # 页数硬上限。无法整页命中的持续更新源或 full 模式指定页数在此停止——
367
+ # 已 spawn 的子任务由 wall 吸收;下次 run 从第 1 页重扫。
368
+ completed_full = False
369
+ logger.warning(
370
+ f"Discovery: reached max_pages={effective_max_pages} without "
371
+ f"full-page hit; stopping (next run rescans from page 1)."
372
+ )
373
+ break
374
+ items = self.fetch_func(job, ctx, page)
375
+ # 类型校验必须先于判空——""/None/False 等 falsy 非 list
376
+ # 返回值不得被静默当空页终止(fail-loud:结构 bug 不伪装页尾)。
377
+ if not isinstance(items, (list, tuple)):
378
+ raise ValueError(
379
+ f"fetch_func must return a list of items, "
380
+ f"got {type(items).__name__}"
381
+ )
382
+ if not items:
383
+ break # T1a: 空页 → 页尾,终止
384
+
385
+ # id_func 逐 item 隔离——单个坏 item(抛异常/返回空串/非 str)
386
+ # 跳过(不入任何集合、下次 run 重试),不崩整个扫描(否则该
387
+ # discovery 链永久死)。
388
+ page_entries = []
389
+ for item in items:
390
+ try:
391
+ cid = self.id_func(item)
392
+ except Exception as e:
393
+ logger.error(
394
+ f"Discovery: id_func raised for an item; "
395
+ f"skipping (will retry next run): {e}"
396
+ )
397
+ continue
398
+ if not isinstance(cid, str) or not cid:
399
+ logger.error(
400
+ f"Discovery: id_func returned non-empty str? "
401
+ f"{cid!r}; skipping (will retry next run)"
402
+ )
403
+ continue
404
+ content_id = sanitize_content_id(prefix + cid)
405
+ page_entries.append((item, content_id, self._process_uid(content_id)))
406
+ # 收集判定必须读 payload 覆盖后的 scan_mode(与结尾差集判定
407
+ # 同一变量):误读构造期默认值时,「默认 incremental + payload
408
+ # 指定 full」的组合会让 seen_this_run 恒空 → 差集把全部已见
409
+ # 内容误报「已删除」,触发业务的破坏性动作。
410
+ if scan_mode == "full":
411
+ seen_this_run.add(content_id)
412
+
413
+ # 整页 id_func 全失败 = 源结构异常:终止本轮扫描避免死循环
414
+ # (每页都失败、wall 零增长 → max_pages 前先止损),下轮重试。
415
+ if not page_entries:
416
+ # 此早停也是不完整扫描——该页 item 及
417
+ # 更深页内容仍在源上(只是 id_func 解析失败),此时计算
418
+ # on_missing 差集会把这些内容误报为「已删除」。
419
+ completed_full = False
420
+ logger.error(
421
+ f"Discovery: page {page} had {len(items)} items "
422
+ f"but ALL id_func failed; stopping (will retry next run)."
423
+ )
424
+ break
425
+
426
+ # 单页「部分命中」不算终止——整页命中才说明本页及以后全是
427
+ # 旧内容。判定依据完整 wall/failed 快照。
428
+ # scan_mode="full" 跳过整页命中 break——一路扫到
429
+ # 空页或 max_pages(全量成本只在 fetch,已见内容仍逐条跳过)。
430
+ page_all_seen = all(
431
+ ctx.is_completed(uid) or ctx.is_failed(uid)
432
+ for _, _, uid in page_entries
433
+ )
434
+ if page_all_seen:
435
+ if scan_mode != "full":
436
+ break # 整页命中 → 终止(增量模式)
437
+
438
+ # 处理本页新 item(process_item_func 逐 item 隔离——单个
439
+ # 坏 item 不崩整轮扫描;跳过不入任何集合,下次 run 重试。达
440
+ # DLQ 阈值由 process 子任务的任务层 retry/DLQ 吸收,本层不计数)。
441
+ # 整页已见时复用 page_all_seen 短路——循环内判定与 all 判定
442
+ # 语义一致(均为 is_completed or is_failed),无需对同批 entry
443
+ # 重复查询快照。
444
+ for item, content_id, uid in page_entries:
445
+ if page_all_seen or ctx.is_completed(uid) or ctx.is_failed(uid):
446
+ continue
447
+ try:
448
+ self.process_item_func(job, ctx, item, content_id)
449
+ except Exception as e:
450
+ logger.error(
451
+ f"Discovery: process_item_func failed for content "
452
+ f"{content_id!r}; skipping, will retry next run: {e}"
453
+ )
454
+ continue
455
+ page += 1
456
+
457
+ # full 模式 + on_missing 回调 → 已见但本次未扫到 =
458
+ # 源站删除检测。已见集合从 wall/failed 快照筛 process_task_type 前缀。
459
+ # 仅当完整扫到空页才计算差集——max_pages 截断时未扫的更深页
460
+ # 内容仍在源上,误报「已删除」会触发业务的破坏性动作。
461
+ # 只取本组(cursor_key 命名空间)的已见内容——多组共享
462
+ # process_task_type 时,他组内容在本组 seen_this_run 之外,不过滤
463
+ # 会被误报为「已删除」。
464
+ if scan_mode == "full" and self.on_missing is not None and completed_full:
465
+ try:
466
+ known = set()
467
+ ptype_prefix = f"{self.process_task_type}::"
468
+ group_prefix = sanitize_content_id(prefix) if prefix else ""
469
+ for uid in ctx.attempted_uids():
470
+ if uid.startswith(ptype_prefix):
471
+ c = uid[len(ptype_prefix):]
472
+ if not group_prefix or c.startswith(group_prefix):
473
+ known.add(c)
474
+ missing = sorted(known - seen_this_run)
475
+ if missing:
476
+ logger.warning(
477
+ f"Discovery full scan: {len(missing)} previously-seen "
478
+ f"content(s) not found this run (deleted on source?)."
479
+ )
480
+ self.on_missing(job, ctx, missing)
481
+ except Exception as e:
482
+ logger.error(f"Discovery: on_missing callback failed: {e}")
483
+
484
+ # 本模型无游标可推进——崩溃(fetch 抛异常 → 异常传播,由用户
485
+ # 按错误分类决定重试/DLQ)即整轮重扫;已 spawn 的子任务由确定性
486
+ # job_id 的 wall 去重吸收,不重不漏。
487
+ return True
488
+
489
+
490
+ def register_discovery(
491
+ host: DiscoveryHost,
492
+ task_type: str,
493
+ fetch_func: Callable[[DiscoveryJob, DiscoveryContext, int], List[Any]],
494
+ id_func: Callable[[Any], str],
495
+ process_item_func: Callable[[DiscoveryJob, DiscoveryContext, Any, str], None],
496
+ process_task_type: str,
497
+ cursor_key_func: Optional[Callable[[Dict[str, Any]], str]] = None,
498
+ default_resources: Optional[Dict[str, float]] = None,
499
+ payload_schema: Optional[type] = None,
500
+ max_pages: int = 1000,
501
+ rerun: str = "every_run",
502
+ scan_mode: str = "incremental",
503
+ on_missing: Optional[Callable[[DiscoveryJob, DiscoveryContext, List[str]], None]] = None,
504
+ ) -> None:
505
+ """注册纯任务模型增量发现 handler(per REQUIREMENTS CONTRACT)。
506
+
507
+ 按 REQUIREMENTS CONTRACT(T/S/D/M/R 条款)实现,「已见集合」直接
508
+ 使用框架 wall/failed 集合——无 cursor、无 seen 持久化、无 poison 表、
509
+ 无同 cursor_key 互斥注入。
510
+
511
+ Args:
512
+ host: 实现 ``DiscoveryHost`` 协议的宿主(框架默认适配 = TaskLite;
513
+ 任何只实现 register_handler + set_discovery_rerun 两方法的
514
+ 对象亦可——本函数不依赖框架具体类型)。
515
+ task_type: 发现任务的 task_type(handler 名)。
516
+ fetch_func: ``(job, ctx, page) -> list[item]``。每次扫描从 page=1
517
+ 开始;返回空列表表示页尾(T1a)。
518
+ id_func: ``(item) -> content_id: str``。内容项全局唯一 id(不要求
519
+ 单调递增)。返回非空 str,否则该 item 被隔离跳过。
520
+ process_item_func: ``(job, ctx, item, content_id) -> None``。对每个
521
+ 新 item 的处理逻辑(通常 spawn 一个子任务)。content_id 已经
522
+ 单射转义净化(不含 "::"、长度受限),可直接用作
523
+ ``Job.job_id``;spawn 的 task_type 必须是 ``process_task_type``。
524
+ process_task_type: process 子任务的 task_type——整页命中判定按
525
+ ``f"{process_task_type}::{content_id}"`` 查询 wall/failed,
526
+ 与用户 spawn 时必须一致。
527
+ cursor_key_func: 可选。``(payload) -> str``,仅作 job_id 命名空间
528
+ 前缀(防跨分组内容 id 碰撞),无任何游标/互斥语义。
529
+ default_resources: 发现任务自身的默认资源需求。
530
+ payload_schema: 发现任务 payload 的运行时校验 schema。
531
+ max_pages: 单次扫描页数硬上限(活性保障条款,默认 1000)。
532
+ rerun: discovery job 的默认跨会话重跑策略(默认
533
+ ``"every_run"``)——enqueue 该 task_type 的 job 时自动注入
534
+ (用户显式指定非默认策略则尊重)。发现任务用固定 uid
535
+ (如 ``discover::favorites``)每会话重扫,无需时间戳后缀。
536
+ scan_mode: 扫描模式:``"incremental"``(默认,
537
+ 整页命中终止)/ ``"full"``(跳过整页命中,一路扫到空页或
538
+ max_pages——全量成本只在 fetch,已见内容仍逐条跳过不重复
539
+ spawn)。
540
+ on_missing: 可选回调(仅 full 模式有意义)。
541
+ ``(job, ctx, missing_content_ids) -> None``——扫描结束后把
542
+ 「已见但本次未扫到」的 content_id 差集交给业务(= 源端删除/缺失
543
+ 检测;增量模式原理上给不出该信息)。必须模块级可 pickle。
544
+
545
+ 回调必须为模块级可 pickle 函数(spawn 进程隔离约束,与普通 handler
546
+ 一致——lambda/闭包在子进程 import 时失败)。
547
+ """
548
+ if not isinstance(task_type, str) or not task_type:
549
+ raise TypeError(
550
+ f"task_type must be a non-empty str, "
551
+ f"got {type(task_type).__name__} ({task_type!r})"
552
+ )
553
+ if "::" in task_type:
554
+ raise ValueError(
555
+ f"task_type must not contain '::' (Job.uid separator), "
556
+ f"got {task_type!r}"
557
+ )
558
+ if not isinstance(process_task_type, str) or not process_task_type:
559
+ raise TypeError(
560
+ f"process_task_type must be a non-empty str, "
561
+ f"got {type(process_task_type).__name__} ({process_task_type!r})"
562
+ )
563
+ if "::" in process_task_type:
564
+ raise ValueError(
565
+ f"process_task_type must not contain '::' (Job.uid separator), "
566
+ f"got {process_task_type!r}"
567
+ )
568
+ for name, fn in (
569
+ ("fetch_func", fetch_func),
570
+ ("id_func", id_func),
571
+ ("process_item_func", process_item_func),
572
+ ):
573
+ if not callable(fn):
574
+ raise TypeError(f"{name} must be callable, got {type(fn).__name__}")
575
+ if cursor_key_func is not None and not callable(cursor_key_func):
576
+ raise TypeError(
577
+ f"cursor_key_func must be callable or None, "
578
+ f"got {type(cursor_key_func).__name__}"
579
+ )
580
+ # 代码级强制:discovery 回调必须模块级可 pickle,否则 spawn
581
+ # 子进程必然失败——把文档约定变成注册期 fail-loud。
582
+ for name, fn in (
583
+ ("fetch_func", fetch_func),
584
+ ("id_func", id_func),
585
+ ("process_item_func", process_item_func),
586
+ ("cursor_key_func", cursor_key_func),
587
+ ("on_missing", on_missing),
588
+ ):
589
+ if fn is None:
590
+ continue
591
+ try:
592
+ pickle.dumps(fn)
593
+ except Exception as e:
594
+ raise TypeError(
595
+ f"{name} must be a module-level picklable function for "
596
+ f"spawn subprocess propagation, got {fn!r}: {e}"
597
+ ) from e
598
+ if not isinstance(max_pages, int) or isinstance(max_pages, bool) or max_pages < 1:
599
+ raise ValueError(f"max_pages must be a positive int, got {max_pages!r}")
600
+ if rerun not in ("never", "on_failure", "every_run", "on_input_change"):
601
+ raise ValueError(
602
+ f"rerun must be one of 'never'/'on_failure'/'every_run'/"
603
+ f"'on_input_change', got {rerun!r}"
604
+ )
605
+
606
+ handler = DiscoveryHandler(
607
+ fetch_func,
608
+ id_func,
609
+ process_item_func,
610
+ process_task_type,
611
+ cursor_key_func=cursor_key_func,
612
+ max_pages=max_pages,
613
+ scan_mode=scan_mode,
614
+ on_missing=on_missing,
615
+ )
616
+ # 框架无关适配:只调用宿主公开协议的两个方法,不触碰任何私有字段/
617
+ # 内部注册表——并发安全由 wall ACID + 去重保证。
618
+ host.register_handler(task_type, handler, default_resources, payload_schema)
619
+ # 记录 discovery 默认 rerun 供 enqueue 注入
620
+ host.set_discovery_rerun(task_type, rerun)