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,413 @@
1
+ """Mutable pipeline state container.
2
+
3
+ Single-threaded event loop; immutability is unnecessary. Three contracts
4
+ govern consistency:
5
+ 1. In-memory queue = on-disk queue minus in-flight jobs.
6
+ 2. wall/failed/cursors advance only after backend commit returns True.
7
+ 3. ``_run_body`` repairs residual drift by filtering queue against
8
+ wall/failed on load (at-least-once backstop).
9
+
10
+ All mutations are in-place; queue changes go through the three methods
11
+ (``pop_job``/``spawn_jobs``/``requeue_jobs``) so ``_queue_uids`` never drifts.
12
+ """
13
+
14
+ import copy
15
+ from typing import Any, Dict, FrozenSet, List, Mapping
16
+
17
+ from .job import Job
18
+
19
+
20
+ def uid_from_job_dict(job_dict: dict) -> str:
21
+ """从 job dict 中直接提取 uid,避免完整的 Job.from_dict 反序列化开销。"""
22
+ task_type = job_dict.get("task_type")
23
+ job_id = job_dict.get("job_id")
24
+ if task_type is not None and job_id is not None:
25
+ return f"{task_type}::{job_id}"
26
+ # fallback: 对非标准 dict(测试中可能使用),尝试完整反序列化
27
+ try:
28
+ return Job.from_dict(job_dict).uid
29
+ except (KeyError, TypeError, ValueError):
30
+ # 最终 fallback: 基于 dict 内容生成稳定 hash uid(完整 64 字符降低碰撞概率)
31
+ import hashlib, json
32
+ h = hashlib.sha256(json.dumps(job_dict, sort_keys=True, default=str).encode()).hexdigest()
33
+ return f"_unknown::{h}"
34
+
35
+
36
+ class PipelineState:
37
+ """Mutable pipeline state: wall/failed/cursors dicts + queue list.
38
+
39
+ All operations are in-place, O(1)/O(k)。除 ``_queue_uids``(由
40
+ ``pop_job``/``spawn_jobs`` 独占维护)外,另维护 ``_wall_uids``/
41
+ ``_failed_uids`` 两个增量 uid 索引——所有 wall/failed 终态转移必须经
42
+ ``mark_success``/``mark_failed``,
43
+ 直接改 dict 会使索引漂移并被 DEBUG 断言捕获。
44
+
45
+ 所有权契约:构造时对 wall/failed 做顶层浅拷贝(dict(...)),value
46
+ (meta dict)与调用方共享引用——生产路径的 value 来自 backend
47
+ 反序列化的新鲜对象;调用方构造后不得变异传入的 value。终态写入经
48
+ ``mark_success``/``mark_failed``(内部深拷贝),不受外部别名影响。
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ wall: Mapping[str, Dict[str, Any]],
54
+ failed: Mapping[str, Dict[str, Any]],
55
+ cursors: Mapping[str, str],
56
+ queue: List[Dict[str, Any]],
57
+ ):
58
+ # 顶层浅拷贝而非深拷贝:生产路径的 wall/failed 来自 backend 反序列化
59
+ # 的新鲜对象(每次 run 重建),deepcopy 是纯双倍开销——百万级 wall
60
+ # 时启动时间与内存 ×2。所有权契约:value(meta dict)共享引用,
61
+ # 调用方构造后不得变异传入的 value;终态写入只经 mark_success/
62
+ # mark_failed(内部对 meta 深拷贝后整体替换),不受外部别名影响。
63
+ self.wall: Dict[str, Dict[str, Any]] = dict(wall)
64
+ self.failed: Dict[str, Dict[str, Any]] = dict(failed)
65
+ self.cursors: Dict[str, str] = dict(cursors)
66
+ self.queue: List[dict] = list(queue)
67
+ self._wall_uids: set = set(self.wall)
68
+ self._failed_uids: set = set(self.failed)
69
+ self._queue_uids: set = {uid_from_job_dict(j) for j in self.queue}
70
+ # in-flight 作业 uid 集合纳入 state,作为
71
+ # 「作业是否在系统中」的统一事实源之一。由 register/unregister 维护,
72
+ # 与 _queue_uids 同款模式(派发即出队 → 二者互斥)。
73
+ self._in_flight_uids: set = set()
74
+ # rerun 策略任务(every_run/on_failure/on_input_change)
75
+ # 的 uid——其「同时属于 wall/failed(历史)与 queue/in-flight(重跑中)」
76
+ # 是合法语义,DEBUG 互斥断言对此豁免(身份非真空仍成立:始终 ≥1 集合)。
77
+ # 生命周期:spawn/requeue/入队时加入,任务终结(unregister)时移除。
78
+ self._rerun_active_uids: set = set()
79
+ for j in self.queue:
80
+ if j.get("rerun") in ("every_run", "on_failure", "on_input_change"):
81
+ self._rerun_active_uids.add(uid_from_job_dict(j))
82
+
83
+ # 队列变更(唯一修改 queue + _queue_uids 的路径)----------------
84
+
85
+ def pop_job(self, idx: int) -> dict:
86
+ """弹出并返回 job dict,同步从 _queue_uids 移除其 uid。"""
87
+ job_dict = self.queue.pop(idx)
88
+ uid = uid_from_job_dict(job_dict)
89
+ if job_dict.get("rerun") in ("every_run", "on_failure", "on_input_change"):
90
+ self._rerun_active_uids.add(uid)
91
+ self._queue_uids.discard(uid)
92
+ if __debug__:
93
+ self._assert_uids_consistent()
94
+ return job_dict
95
+
96
+ def spawn_jobs(self, job_dicts: List[Dict[str, Any]], front: bool = True) -> None:
97
+ """批量入队(spawn 默认队首,preserving order),逐条同步 uid。"""
98
+ if front:
99
+ self.queue[0:0] = list(job_dicts)
100
+ else:
101
+ self.queue.extend(job_dicts)
102
+ self._queue_uids.update(uid_from_job_dict(j) for j in job_dicts)
103
+ for j in job_dicts:
104
+ if j.get("rerun") in ("every_run", "on_failure", "on_input_change"):
105
+ self._rerun_active_uids.add(uid_from_job_dict(j))
106
+ if __debug__:
107
+ self._assert_uids_consistent()
108
+
109
+ def requeue_jobs(self, job_dicts: List[Dict[str, Any]], front: bool = True) -> None:
110
+ """重入队(崩溃恢复/重试),语义同 spawn_jobs 队首插入。"""
111
+ self.spawn_jobs(job_dicts, front=front)
112
+
113
+ # in-flight 集合(唯一修改 _in_flight_uids 的路径)-------------
114
+
115
+ def register_in_flight(self, uid: str) -> None:
116
+ """登记一个已派发到子进程、尚未 commit 的作业 uid。"""
117
+ self._in_flight_uids.add(uid)
118
+ if __debug__:
119
+ self._assert_state_consistent()
120
+
121
+ def unregister_in_flight(self, uid: str) -> None:
122
+ """注销一个已完成/失败/崩溃回收的 in-flight 作业 uid。"""
123
+ self._in_flight_uids.discard(uid)
124
+ self._rerun_active_uids.discard(uid) # 任务终结,重跑豁免结束
125
+ if __debug__:
126
+ self._assert_state_consistent()
127
+
128
+ def clear_in_flight(self) -> None:
129
+ """清空 in-flight 集合(_abort_in_flight 使用)。
130
+
131
+ 不整体清空 ``_rerun_active_uids``——abort 时先
132
+ requeue(rerun 任务回到 queue 且仍在 wall 有历史记录)再清空豁免
133
+ 集合会让 DEBUG 互斥断言崩(wall∩queue 无豁免)。重建为当前 queue
134
+ 中的 rerun 任务:requeue 后的 rerun 任务保留豁免。
135
+ """
136
+ self._in_flight_uids.clear()
137
+ self._rerun_active_uids = {
138
+ uid_from_job_dict(j) for j in self.queue
139
+ if j.get("rerun") in ("every_run", "on_failure", "on_input_change")
140
+ }
141
+ if __debug__:
142
+ self._assert_state_consistent()
143
+
144
+ def replace_queue(self, job_dicts: List[Dict[str, Any]]) -> None:
145
+ """整体替换队列(死锁批量移除肇事者场景),重建 uid 索引。"""
146
+ self.queue = list(job_dicts)
147
+ self._queue_uids = {uid_from_job_dict(j) for j in self.queue}
148
+ self._rerun_active_uids = {
149
+ uid_from_job_dict(j) for j in self.queue
150
+ if j.get("rerun") in ("every_run", "on_failure", "on_input_change")
151
+ }
152
+ if __debug__:
153
+ self._assert_uids_consistent()
154
+
155
+ # 级联失败(按需计算反向依赖)-------------------------------
156
+
157
+ def _build_dependents(self) -> Dict[str, set]:
158
+ """按需从当前 queue 构建反向依赖索引 dependents[dep_uid] -> {job_uid...}。
159
+
160
+ 级联是失败路径上的罕见操作,按需 O(N·k) 构建比
161
+ 在 pop/spawn/mark_success/replace 四条变更路径上增量维护索引更简单——
162
+ eager 索引存在同步漏维护风险(级联漏标)。
163
+ """
164
+ dependents: Dict[str, set] = {}
165
+ for jd in self.queue:
166
+ try:
167
+ job = Job.from_dict(jd)
168
+ except (KeyError, TypeError, ValueError):
169
+ continue # 畸形 job 无法解析依赖,跳过
170
+ job_uid = job.uid
171
+ for dep_uid in job.depends_on:
172
+ dependents.setdefault(dep_uid, set()).add(job_uid)
173
+ return dependents
174
+
175
+ def fail_cascade(self, failed_uid: str) -> List[str]:
176
+ """沿反向依赖递归标记下游为级联失败。
177
+
178
+ 父 job 失败后,所有(直接/间接)依赖它的 job 都不可能满足依赖,
179
+ 应被级联标记。返回本次级联标记的 uid 列表(供调用方持久化)。
180
+
181
+ 一次性 O(下游数) 标记,消除调度循环逐轮扫描的级联检测延迟。
182
+ 排除 wall(已成功)与 failed(已失败,避免重复级联)。
183
+ """
184
+ dependents = self._build_dependents()
185
+ cascade: List[str] = []
186
+ stack = [failed_uid]
187
+ seen = {failed_uid}
188
+ while stack:
189
+ cur = stack.pop()
190
+ for downstream in dependents.get(cur, ()):
191
+ if downstream not in seen and downstream not in self.wall \
192
+ and downstream not in self.failed:
193
+ seen.add(downstream)
194
+ cascade.append(downstream)
195
+ stack.append(downstream)
196
+ return cascade
197
+
198
+ def find_dependency_cycles(self) -> List[str]:
199
+ """在当前 queue 的依赖图中找出所有依赖环成员。
200
+
201
+ 返回**环内成员 uid 集合**(扁平 list,含每个环的成员)。采用
202
+ DFS + 三色标记(0=未访问, 1=在路径上, 2=已结束)检测有向环。
203
+
204
+ 我们只需要「环成员集合」用于细粒度失败,不需要断环回溯删除单个
205
+ 成员(我们的环=确定性死锁,全部环成员都标 DEPENDENCY_DEADLOCK)。
206
+
207
+ Returns:
208
+ 环内成员 uid 列表(可能有重复,调用方用 set 吸收)。
209
+ """
210
+ # 依赖边:job_uid -> set(dep_uids)
211
+ edges: Dict[str, set] = {}
212
+ for jd in self.queue:
213
+ try:
214
+ job = Job.from_dict(jd)
215
+ except (KeyError, TypeError, ValueError):
216
+ continue
217
+ edges[job.uid] = set(job.depends_on)
218
+
219
+ color: Dict[str, int] = {} # 0/缺失=未访问, 1=在路径上, 2=完成
220
+ cycle_members: List[str] = []
221
+ path_stack: List[str] = []
222
+
223
+ def dfs(uid: str) -> None:
224
+ color[uid] = 1
225
+ path_stack.append(uid)
226
+ for dep in edges.get(uid, ()):
227
+ # 依赖不在当前队列中(在 wall/in_flight/缺失)→ 不算环
228
+ if dep not in edges:
229
+ continue
230
+ if color.get(dep, 0) == 1:
231
+ # 找到环:从 dep 到当前路径末尾都是环成员
232
+ try:
233
+ start = path_stack.index(dep)
234
+ except ValueError:
235
+ start = 0
236
+ cycle_members.extend(path_stack[start:])
237
+ cycle_members.append(dep) # 闭环
238
+ elif color.get(dep, 0) == 0:
239
+ dfs(dep)
240
+ path_stack.pop()
241
+ color[uid] = 2
242
+
243
+ for uid in list(edges):
244
+ if color.get(uid, 0) == 0:
245
+ dfs(uid)
246
+ return cycle_members
247
+
248
+ # 完成/失败记录 ------------------------------------------------
249
+
250
+ def mark_success(self, uid: str, meta: Dict[str, Any]) -> None:
251
+ """终态转移:uid → wall(成功)。单一入口维护 wall/failed 索引。
252
+
253
+ 「最终状态唯一」在这里结构保证:进入 wall 的同时清除 failed 残行
254
+ (rerun 任务重跑成功时磁盘同事务已删 DLQ 行,内存必须镜像),
255
+ 任何路径都不得再手工 ``failed.pop`` + ``mark_success`` 复制该出口逻辑。
256
+ 对新 meta 做防御性深拷贝。反向依赖索引不做增量维护——级联按需
257
+ 计算(``_build_dependents``),job 成功与否由 wall/failed 排除防御。
258
+ """
259
+ self.failed.pop(uid, None)
260
+ self._failed_uids.discard(uid)
261
+ self.wall[uid] = copy.deepcopy(meta)
262
+ self._wall_uids.add(uid)
263
+ if __debug__:
264
+ self._assert_terminal_uids_consistent()
265
+
266
+ def mark_failed(self, uid: str, meta: Dict[str, Any]) -> None:
267
+ """终态转移:uid → failed(DLQ)。单一入口维护 wall/failed 索引。
268
+
269
+ 与 ``mark_success`` 对称:进入 failed 的同时清除 wall 旧成功记录
270
+ (rerun 任务重跑失败时 wall 旧记录必须作废),杜绝 wall∩failed
271
+ 并存。对新 meta 做防御性深拷贝。
272
+ """
273
+ self.wall.pop(uid, None)
274
+ self._wall_uids.discard(uid)
275
+ self.failed[uid] = copy.deepcopy(meta)
276
+ self._failed_uids.add(uid)
277
+ if __debug__:
278
+ self._assert_terminal_uids_consistent()
279
+
280
+ def update_cursors(self, updates: Mapping[str, str]) -> None:
281
+ """合并游标更新(覆盖语义)。
282
+
283
+ 与后端语义对齐——值为 None 表示删除该游标(pop),
284
+ 非 None 值才写入(直接 ``dict.update`` 会把 {key: None} 写进
285
+ cursors,偏离 None-删除语义)。
286
+ """
287
+ for k, v in updates.items():
288
+ if v is None:
289
+ self.cursors.pop(k, None)
290
+ else:
291
+ self.cursors[k] = v
292
+
293
+ # 只读访问 ----------------------------------------------------
294
+
295
+ @property
296
+ def queue_uids(self) -> set:
297
+ """队列 uid 的**活索引**(O(1) 去重/依赖判定;性能敏感路径直读免拷贝)。
298
+
299
+ 返回活引用而非 frozenset 副本:调度扫描与 spawn 去重每轮消费,
300
+ O(N) 拷贝在万级队列下纯烧 CPU。调用方不得变异返回值——索引由
301
+ ``pop_job``/``spawn_jobs``/``replace_queue`` 独占维护。
302
+ """
303
+ return self._queue_uids
304
+
305
+ @property
306
+ def wall_uids(self) -> set:
307
+ """wall 终态 uid 的**活索引**(O(1) 构建;仅供派发快照/内部判定)。
308
+
309
+ 返回活引用而非 frozenset 副本:``_dispatch_job`` 构造 ctx 时随即
310
+ pickle 下发,单线程派发路径上 state 不会并发变化。调用方不得修改
311
+ 返回值——索引由 ``mark_success``/``mark_failed`` 独占维护。
312
+ """
313
+ return self._wall_uids
314
+
315
+ @property
316
+ def failed_uids(self) -> set:
317
+ """failed 终态 uid 的活索引(语义同 ``wall_uids``)。"""
318
+ return self._failed_uids
319
+
320
+ @property
321
+ def attempted_uids(self) -> set:
322
+ """wall∪failed 终态 uid 活索引(= ``ctx.attempted_uids()`` 的父进程侧视图)。
323
+
324
+ 命名即语义:「已尝试过」(wall 成功 ∪ failed 失败)——队列中待跑的
325
+ 不算;与四集合并集 ``is_known(uid)``(含 queue/in-flight)区分。
326
+ """
327
+ return self._wall_uids | self._failed_uids
328
+
329
+ @property
330
+ def in_flight_uids(self) -> FrozenSet[str]:
331
+ """in-flight 作业 uid 集合。"""
332
+ return frozenset(self._in_flight_uids)
333
+
334
+ @property
335
+ def is_empty(self) -> bool:
336
+ """队列是否为空。"""
337
+ return not self.queue
338
+
339
+ def is_known(self, uid: str) -> bool:
340
+ """作业是否「在系统中」的统一谓词。
341
+
342
+ 作业身份的唯一事实源 = wall ∪ failed ∪ 队列 ∪ in-flight。
343
+ 所有去重判定(spawn 去重、dispatch 去重、依赖判定)必须调用本
344
+ 谓词,而不是自行查某个集合——曾因「调用点自己拼集合」漏查
345
+ in-flight 导致双重执行。
346
+ """
347
+ return (
348
+ uid in self.wall
349
+ or uid in self.failed
350
+ or uid in self._queue_uids
351
+ or uid in self._in_flight_uids
352
+ )
353
+
354
+ # 内部 ---------------------------------------------------------
355
+
356
+ def _assert_uids_consistent(self) -> None:
357
+ """DEBUG 不变式:_queue_uids 与 queue 完全一致。"""
358
+ recomputed = {uid_from_job_dict(j) for j in self.queue}
359
+ assert self._queue_uids == recomputed, (
360
+ f"_queue_uids drift: cached={self._queue_uids!r} actual={recomputed!r}"
361
+ )
362
+
363
+ def _assert_terminal_uids_consistent(self) -> None:
364
+ """DEBUG 不变式:wall/failed 增量索引与 dict 完全一致。
365
+
366
+ 捕获「绕开 mark_success/mark_failed 直接改 dict」的索引漂移——
367
+ 这正是 ctx 派发快照与 is_known 的事实源,漂移会让子进程看到过期
368
+ 终态集合(身份判定失真)。
369
+ """
370
+ assert self._wall_uids == set(self.wall), (
371
+ f"_wall_uids drift: cached={self._wall_uids!r} actual={set(self.wall)!r}"
372
+ )
373
+ assert self._failed_uids == set(self.failed), (
374
+ f"_failed_uids drift: cached={self._failed_uids!r} actual={set(self.failed)!r}"
375
+ )
376
+
377
+ def _assert_state_consistent(self) -> None:
378
+ """DEBUG 不变式:作业身份六集合互斥(C(4,2)=6 对两两无交集)。
379
+
380
+ - 派发即出队:_queue_uids 与 _in_flight_uids 互斥(同一 uid 不可能
381
+ 既在队列又 in-flight)。
382
+ - wall/failed 与活动集合互斥:已完成的作业不在队列/不在 in-flight。
383
+ - wall/failed 与彼此互斥:同一 uid 不可能既成功又失败。该互斥的
384
+ 后果落点:已 DLQ 的 job 若因控制流异常继续执行并
385
+ commit_job_success,会同时进入 wall 和 failed——本条捕获。
386
+ - 无重复:in-flight 集合本身无重复(set 语义保证)。
387
+ """
388
+ overlap = self._queue_uids & self._in_flight_uids
389
+ assert not overlap, (
390
+ f"uid in both queue and in_flight: {overlap!r} "
391
+ f"(queue={self._queue_uids!r} in_flight={self._in_flight_uids!r})"
392
+ )
393
+ # rerun 策略任务(every_run/on_failure)重跑期间
394
+ # uid 合法地同时属于 wall/failed(历史记录)与 queue/in-flight(重跑
395
+ # 中)——互斥断言对此豁免(身份非真空仍成立)。非 rerun 任务的
396
+ # wall/failed∩in-flight 仍是异常状态(已 DLQ 的 job 继续执行)的捕获网。
397
+ done_overlap = (set(self.wall) | set(self.failed)) & self._in_flight_uids
398
+ assert not (done_overlap - self._rerun_active_uids), (
399
+ f"uid in wall/failed and in_flight: {done_overlap - self._rerun_active_uids!r}"
400
+ )
401
+ # 显式断言剩余互斥对——wall∩queue、failed∩queue、wall∩failed:
402
+ # 「已 DLQ 的 job 被继续执行并 commit 成功」会让 uid 同时进入
403
+ # wall 和 failed,wall/failed 与队列的漂移也在此一并显式捕获。
404
+ wall_failed = set(self.wall) & set(self.failed)
405
+ assert not wall_failed, (
406
+ f"uid in both wall and failed: {wall_failed!r}"
407
+ )
408
+ done_queue = (set(self.wall) | set(self.failed)) & self._queue_uids
409
+ assert not (done_queue - self._rerun_active_uids), (
410
+ f"uid in wall/failed and queue: {done_queue - self._rerun_active_uids!r}"
411
+ )
412
+ self._assert_uids_consistent()
413
+ self._assert_terminal_uids_consistent()