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.
- tasklite/__init__.py +55 -0
- tasklite/backend/__init__.py +1 -0
- tasklite/backend/base.py +199 -0
- tasklite/backend/sqlite_backend.py +570 -0
- tasklite/contrib/__init__.py +6 -0
- tasklite/engine/__init__.py +16 -0
- tasklite/engine/completion.py +439 -0
- tasklite/engine/deadlock.py +33 -0
- tasklite/engine/dispatch.py +426 -0
- tasklite/engine/executor.py +1202 -0
- tasklite/engine/failure.py +509 -0
- tasklite/engine/inflight.py +25 -0
- tasklite/engine/loop.py +281 -0
- tasklite/engine/recovery.py +409 -0
- tasklite/engine/resource.py +240 -0
- tasklite/engine/retry.py +147 -0
- tasklite/engine/runtime.py +331 -0
- tasklite/engine/scheduler.py +346 -0
- tasklite/error_codes.py +66 -0
- tasklite/exceptions.py +215 -0
- tasklite/models/__init__.py +6 -0
- tasklite/models/context.py +313 -0
- tasklite/models/job.py +304 -0
- tasklite/models/state.py +413 -0
- tasklite/pipeline.py +914 -0
- tasklite/pipeline_util.py +204 -0
- tasklite/py.typed +0 -0
- tasklite/utils/__init__.py +6 -0
- tasklite/utils/ipc.py +88 -0
- tasklite/utils/jsonutil.py +65 -0
- tasklite/utils/lockfile.py +154 -0
- tasklite/utils/validation.py +177 -0
- tasklite/wrappers/__init__.py +26 -0
- tasklite/wrappers/discovery.py +620 -0
- tasklite_engine-1.0.0.dist-info/METADATA +321 -0
- tasklite_engine-1.0.0.dist-info/RECORD +39 -0
- tasklite_engine-1.0.0.dist-info/WHEEL +5 -0
- tasklite_engine-1.0.0.dist-info/licenses/LICENSE +21 -0
- tasklite_engine-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Job scheduler for tasklite.
|
|
2
|
+
|
|
3
|
+
The scheduler performs a read-only scan over the queue to find the next
|
|
4
|
+
runnable job. It does NOT acquire resources (only ``can_acquire``); the
|
|
5
|
+
actual acquisition happens in the pipeline after the job is popped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, FrozenSet, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .runtime import RT_BACKOFF_UNTIL
|
|
14
|
+
from ..models.job import Job
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("tasklite")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class JobFacts:
|
|
21
|
+
"""不可变调度投影(scheduler 缓存唯一持有的 Job 视图)。
|
|
22
|
+
|
|
23
|
+
调度扫描只读四个事实:uid / task_type / resources / depends_on。
|
|
24
|
+
frozen dataclass + tuple/tuple 让 `_job_cache` 命中返回的对象无法被
|
|
25
|
+
任何调用点原地改写——「缓存不透出可变对象」由类型直接保证。
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
uid: str
|
|
29
|
+
task_type: str
|
|
30
|
+
resources: Tuple[Tuple[str, float], ...]
|
|
31
|
+
depends_on: Tuple[str, ...]
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_job_dict(cls, job_dict: dict) -> "JobFacts":
|
|
35
|
+
"""完整走 Job.from_dict 校验,然后冻结调度所需字段。"""
|
|
36
|
+
job = Job.from_dict(job_dict)
|
|
37
|
+
return cls(
|
|
38
|
+
uid=job.uid,
|
|
39
|
+
task_type=job.task_type,
|
|
40
|
+
resources=tuple(sorted(job.resources.items())),
|
|
41
|
+
depends_on=tuple(job.depends_on),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def resource_map(self) -> Dict[str, float]:
|
|
45
|
+
"""解冻 resources 供 can_acquire/merge 使用(只读消费)。"""
|
|
46
|
+
return dict(self.resources)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ScheduleResult:
|
|
51
|
+
"""Outcome of a read-only scan over the queue.
|
|
52
|
+
|
|
53
|
+
``kind`` 显式表达 runnable_idx + pending_dep_failure + dep_failed_idx
|
|
54
|
+
的兜底关系:
|
|
55
|
+
|
|
56
|
+
- ``kind == "runnable"``:runnable_idx 指向真正可运行的 job;
|
|
57
|
+
- ``kind == "dep_failed"``:runnable_idx 指向 dep-failed 兜底位置
|
|
58
|
+
(pending_dep_failure 携带失败依赖,_dispatch_job 走依赖失败分支);
|
|
59
|
+
- ``kind == "none"``:无可运行 job(本轮无 job 可派发)。
|
|
60
|
+
"""
|
|
61
|
+
# 「无」用 None 而非 C 风格 -1 哨兵;kind 字段显式表达状态,
|
|
62
|
+
# 索引仅在 kind 为 "runnable"/"dep_failed" 时有意义。
|
|
63
|
+
runnable_idx: Optional[int] = None
|
|
64
|
+
pending_dep_failure: Optional[str] = None
|
|
65
|
+
min_wait: float = float('inf')
|
|
66
|
+
waiting_for_dependency: bool = False
|
|
67
|
+
# 结果类型(runnable / dep_failed / none)
|
|
68
|
+
kind: str = "none"
|
|
69
|
+
# 细粒度死锁分类:只失败肇事者,被阻断者走正常 cascade
|
|
70
|
+
unknown_resource_indices: List[int] = field(default_factory=list)
|
|
71
|
+
missing_dependency_indices: List[int] = field(default_factory=list)
|
|
72
|
+
# 畸形 job dict(无法反序列化)的索引,优先级最高
|
|
73
|
+
malformed_indices: List[int] = field(default_factory=list)
|
|
74
|
+
# 不可达资源(can_acquire 返回 inf)的索引,细粒度失败而非全部 cascade
|
|
75
|
+
impossible_resource_indices: List[int] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class JobScheduler:
|
|
79
|
+
"""Read-only scanner that locates the next runnable job in the queue."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, resources: Dict[str, "Resource"], handlers: Optional[Dict[str, "HandlerEntry"]] = None):
|
|
82
|
+
self.resources = resources
|
|
83
|
+
# handler 注册表(task_type -> HandlerEntry)的引用,用于把
|
|
84
|
+
# handler 默认资源合并进扫描的资源检查(调度器必须看到
|
|
85
|
+
# 与 _dispatch_job 实际 acquire 完全一致的资源集,否则 handler 注册前
|
|
86
|
+
# 入队的作业会绕过限速/容量检查)。
|
|
87
|
+
# 不能写 `handlers or {}`——空 dict 是 falsy,or 会换成
|
|
88
|
+
# **新**空 dict,register_handler 的写入对 scheduler 不可见(handler
|
|
89
|
+
# 默认资源从扫描资源集消失)。
|
|
90
|
+
self.handlers = handlers if handlers is not None else {}
|
|
91
|
+
# Job 反序列化缓存——pop_next_runnable 每轮对每条 job_dict
|
|
92
|
+
# 全量 Job.from_dict(含全部字段校验),W 槽位填池 = W×N 次解析;
|
|
93
|
+
# 万级队列 + 高完成频率下纯烧 CPU。
|
|
94
|
+
# 缓存键为内容键 (task_type, job_id)——id(job_dict) 键有
|
|
95
|
+
# id-reuse-after-free 风险:dict 被 pop 释放后地址
|
|
96
|
+
# 可被新队列条目复用,cached_job 返回陈旧 Job → 调度决策错误;
|
|
97
|
+
# 内容键天然免疫地址复用。queue 中 uid 唯一(_queue_uids set),
|
|
98
|
+
# (task_type, job_id) 与 uid 一一对应,无碰撞。
|
|
99
|
+
# 缓存值为不可变 JobFacts(调度投影)——
|
|
100
|
+
# 缓存不透出、可变字段走活 dict、派发重解析三重约定中的前两重
|
|
101
|
+
# 由不可变类型直接保证。
|
|
102
|
+
self._job_cache: Dict[Tuple[str, str], JobFacts] = {}
|
|
103
|
+
self._JOB_CACHE_MAX = 100_000
|
|
104
|
+
|
|
105
|
+
def begin_round(self) -> None:
|
|
106
|
+
"""清空调度缓存(**run 生命周期**,由 ``_run_body`` 加载期调用)。
|
|
107
|
+
|
|
108
|
+
内容键 + 只读字段(depends_on/task_type/resources/uid)
|
|
109
|
+
使缓存跨轮安全:retries/runtime/backoff 从 live job_dict 读取,
|
|
110
|
+
retry 路径用 retry_dict["resources"] 恢复原始资源与缓存一致,
|
|
111
|
+
spawn 去重防同 uid 冲突——阻塞/退避/慢 job 阶段主循环 ~20Hz 轮询
|
|
112
|
+
时避免每轮全量反序列化队列(N=10 万 ≈ 240ms/轮 CPU 空烧)。
|
|
113
|
+
跨 run 陈旧(clear_history + 重新 enqueue 同 uid 不同内容)由
|
|
114
|
+
加载期清空覆盖。
|
|
115
|
+
"""
|
|
116
|
+
self._job_cache.clear()
|
|
117
|
+
|
|
118
|
+
def cached_job(self, job_dict: dict) -> JobFacts:
|
|
119
|
+
"""返回 job_dict 的不可变调度投影(内容键缓存复用)。
|
|
120
|
+
|
|
121
|
+
公开方法:失败机器的依赖宽限路径跨模块复用同一缓存(免二次
|
|
122
|
+
全量反序列化),调度扫描本身也是唯一内部消费方。
|
|
123
|
+
"""
|
|
124
|
+
key = (job_dict.get("task_type"), job_dict.get("job_id"))
|
|
125
|
+
if key[0] is None or key[1] is None:
|
|
126
|
+
# 畸形 dict(缺 task_type/job_id)无法用内容键——直接解析不缓存
|
|
127
|
+
return JobFacts.from_job_dict(job_dict)
|
|
128
|
+
facts = self._job_cache.get(key)
|
|
129
|
+
# 缓存一致性校验:缓存命中时校验「调度只读字段」一致——rerun="every_run"
|
|
130
|
+
# 的 job 在 wall 命中时被 spawn 去重豁免(文档化「每会话重扫」语义),
|
|
131
|
+
# 可在**同一 run 内**让同 uid 以不同 resources/depends_on 二次入队。
|
|
132
|
+
# 若直接返回陈旧缓存,调度器(缓存投影)与派发器(Job.from_dict 重新
|
|
133
|
+
# 解析的 fresh Job)看到不同字段 → 未知资源/依赖判定背离,以裸 KeyError
|
|
134
|
+
# 击穿整条 run。字段不一致视为 miss 重新解析;同 uid 同内容
|
|
135
|
+
# (retry/常规重跑/requeue)仍命中缓存,保留缓存复用的性能收益。
|
|
136
|
+
if facts is not None and self._sched_fields_match(facts, job_dict):
|
|
137
|
+
return facts
|
|
138
|
+
facts = JobFacts.from_job_dict(job_dict)
|
|
139
|
+
if len(self._job_cache) >= self._JOB_CACHE_MAX:
|
|
140
|
+
self._job_cache.clear()
|
|
141
|
+
self._job_cache[key] = facts
|
|
142
|
+
return facts
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _sched_fields_match(job, job_dict: dict) -> bool:
|
|
146
|
+
"""校验缓存投影(或 Job)与 job_dict 的调度只读字段一致。
|
|
147
|
+
|
|
148
|
+
``job`` 接受 ``JobFacts``(生产缓存路径)或 ``Job``(测试直调)——
|
|
149
|
+
两者都只读 depends_on/resources;``dict(...)`` 归一化让
|
|
150
|
+
``tuple[(k,v),...]``(JobFacts)与 ``dict``(Job)在此等价。
|
|
151
|
+
|
|
152
|
+
内容键 (task_type, job_id) 假设同 uid 内容不变;every_run 重 spawn 打破
|
|
153
|
+
该假设。仅 depends_on/resources 影响调度判定(unknown/missing/依赖/
|
|
154
|
+
can_acquire),比对二者即可判定能否安全复用缓存。
|
|
155
|
+
|
|
156
|
+
null 语义与 ``Job.from_dict`` 对齐:``resources=None``→``{}``、
|
|
157
|
+
``depends_on=None``→``[]``(否则 ``dict(None)``/``list(None)`` 抛
|
|
158
|
+
TypeError,把**本可正常解析执行**的合法 job 误判为畸形进 DLQ——
|
|
159
|
+
避免将合法任务误判为畸形)。非 dict/非 list 的畸形值视为不一致,
|
|
160
|
+
返回 False(重新解析,``Job.from_dict`` 会对畸形正确抛错归位)。
|
|
161
|
+
"""
|
|
162
|
+
raw_res = job_dict.get("resources")
|
|
163
|
+
raw_dep = job_dict.get("depends_on")
|
|
164
|
+
try:
|
|
165
|
+
job_res = dict(raw_res) if raw_res is not None else {}
|
|
166
|
+
job_dep = list(raw_dep) if raw_dep is not None else []
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
return False # 畸形值 → 不一致,重新解析(Job.from_dict 会拒)
|
|
169
|
+
cached_res = dict(job.resources) if not isinstance(job.resources, dict) else job.resources
|
|
170
|
+
return list(job.depends_on) == job_dep and cached_res == job_res
|
|
171
|
+
|
|
172
|
+
def _effective_resources(self, job) -> Dict[str, float]:
|
|
173
|
+
"""job 实际会 acquire 的资源集 = handler 默认资源 ∪ job 自身 resources。
|
|
174
|
+
|
|
175
|
+
接受 ``JobFacts``(扫描路径)或 ``Job``(测试/兼容直调)——两者都有
|
|
176
|
+
``task_type``/``resources`` 只读字段。
|
|
177
|
+
"""
|
|
178
|
+
if isinstance(job, JobFacts):
|
|
179
|
+
job_resources = job.resource_map()
|
|
180
|
+
else:
|
|
181
|
+
job_resources = dict(job.resources)
|
|
182
|
+
entry = self.handlers.get(job.task_type)
|
|
183
|
+
defaults = entry.default_resources if entry is not None else {}
|
|
184
|
+
if not defaults:
|
|
185
|
+
return job_resources
|
|
186
|
+
return {**defaults, **job_resources}
|
|
187
|
+
|
|
188
|
+
def pop_next_runnable(
|
|
189
|
+
self,
|
|
190
|
+
state,
|
|
191
|
+
in_flight_uids: FrozenSet[str] = frozenset(),
|
|
192
|
+
) -> ScheduleResult:
|
|
193
|
+
"""Scan queue read-only. Returns index and wait info. Does NOT acquire resources.
|
|
194
|
+
|
|
195
|
+
``state`` 是 PipelineState 实例(读取 queue/wall/failed/queue_uids——
|
|
196
|
+
``queue_uids`` 返回活索引引用,提供 O(1) 索引,无 O(N) 拷贝)。
|
|
197
|
+
|
|
198
|
+
``in_flight_uids`` 是当前正在子进程中执行(已 pop 但未 commit)的 job uid
|
|
199
|
+
集合。在 missing dependency 判定时,依赖正在运行的 job 不算 missing
|
|
200
|
+
(待其完成 commit 到 wall 后自然解锁),避免并发模型下误判死锁。
|
|
201
|
+
"""
|
|
202
|
+
q_data = state.queue
|
|
203
|
+
wall_data = state.wall
|
|
204
|
+
failed_data = state.failed
|
|
205
|
+
queue_uids = state.queue_uids
|
|
206
|
+
pending_or_running = queue_uids | set(in_flight_uids)
|
|
207
|
+
|
|
208
|
+
runnable_idx = None
|
|
209
|
+
min_wait = float('inf')
|
|
210
|
+
waiting_for_dependency = False
|
|
211
|
+
pending_dep_failure: Optional[str] = None
|
|
212
|
+
dep_failed_idx: int = -1 # 首个 dep-failed job 的索引(兜底)
|
|
213
|
+
unknown_resource_indices: List[int] = []
|
|
214
|
+
missing_dependency_indices: List[int] = []
|
|
215
|
+
malformed_indices: List[int] = []
|
|
216
|
+
impossible_resource_indices: List[int] = []
|
|
217
|
+
|
|
218
|
+
now = time.monotonic()
|
|
219
|
+
|
|
220
|
+
for i, job_dict in enumerate(q_data):
|
|
221
|
+
# 捕获畸形 job dict(缺 task_type/job_id 等),记录索引避免整个扫描崩溃
|
|
222
|
+
try:
|
|
223
|
+
job = self.cached_job(job_dict)
|
|
224
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
225
|
+
logger.error(f"Malformed job dict at index {i}: {e}")
|
|
226
|
+
malformed_indices.append(i)
|
|
227
|
+
continue
|
|
228
|
+
can_run = True
|
|
229
|
+
failed_dependency = None
|
|
230
|
+
|
|
231
|
+
# 1. Failed dependency: job is runnable (will be marked as dependency failure)
|
|
232
|
+
if job.depends_on:
|
|
233
|
+
for dep_uid in job.depends_on:
|
|
234
|
+
if dep_uid in failed_data:
|
|
235
|
+
failed_dependency = dep_uid
|
|
236
|
+
can_run = False
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
if failed_dependency:
|
|
240
|
+
# 不在此 break——排在 dep-failed job 后面的**可运行** job
|
|
241
|
+
# 本轮会失去调度机会(且 malformed/impossible 等归因信息收集
|
|
242
|
+
# 被截断)。记录首个 pending_dep_failure,继续扫描:优先把
|
|
243
|
+
# 可运行 job 挑出来;若整轮没有可运行 job,runnable_idx 落回
|
|
244
|
+
# 最后一个 dep-failed 位置(_dispatch_job 会处理它)。
|
|
245
|
+
if pending_dep_failure is None:
|
|
246
|
+
pending_dep_failure = failed_dependency
|
|
247
|
+
dep_failed_idx = i
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# 2. Unknown resource detection (permanent deadlock regardless of dependencies)
|
|
251
|
+
# 用合并后资源集检查(含 handler 默认资源),与 _dispatch_job 的 acquire 一致
|
|
252
|
+
eff_resources = self._effective_resources(job)
|
|
253
|
+
for res_name in eff_resources:
|
|
254
|
+
if res_name not in self.resources:
|
|
255
|
+
logger.error(f"Job {job.uid} references unknown resource '{res_name}'.")
|
|
256
|
+
unknown_resource_indices.append(i)
|
|
257
|
+
min_wait = float('inf') # Deadlock: Unknown resource
|
|
258
|
+
can_run = False
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
# 3. Missing dependency: not runnable
|
|
262
|
+
if job.depends_on:
|
|
263
|
+
for dep_uid in job.depends_on:
|
|
264
|
+
if dep_uid not in wall_data:
|
|
265
|
+
can_run = False
|
|
266
|
+
waiting_for_dependency = True
|
|
267
|
+
# 不在第一个缺失依赖处 break——排在前面
|
|
268
|
+
# 的 pending 依赖会遮蔽后面真正缺失的依赖(死锁漏检
|
|
269
|
+
# → 无限轮询)。逐个检查全部依赖,把「不在 wall 也
|
|
270
|
+
# 不在 queue/in-flight」的依赖全部记录为 missing。
|
|
271
|
+
if dep_uid not in pending_or_running:
|
|
272
|
+
missing_dependency_indices.append(i)
|
|
273
|
+
|
|
274
|
+
if not can_run:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# 4. Resource availability(必须在本轮检查,
|
|
278
|
+
# 不能因退避跳过——否则退避中的 job 引用的 unknown/impossible
|
|
279
|
+
# 资源死锁会被无限推迟到退避结束)
|
|
280
|
+
for res_name, amount in eff_resources.items():
|
|
281
|
+
ok, wait_time = self.resources[res_name].can_acquire(amount)
|
|
282
|
+
if ok:
|
|
283
|
+
continue
|
|
284
|
+
can_run = False
|
|
285
|
+
if wait_time == float('inf'):
|
|
286
|
+
# 不可达资源(amount > capacity)→ 永久死锁,细粒度记录
|
|
287
|
+
impossible_resource_indices.append(i)
|
|
288
|
+
else:
|
|
289
|
+
min_wait = min(min_wait, wait_time)
|
|
290
|
+
|
|
291
|
+
# 5. Backoff — 复用循环顶部的 now 值,避免双重 time.monotonic 调用。
|
|
292
|
+
# 类型防御:脏数据(字符串等非数值 _backoff_until)直接视为无退避,
|
|
293
|
+
# 避免加载期崩溃(_run_body 只清理数值脏数据)。
|
|
294
|
+
# 注意:退避只影响「本轮可运行」,不影响上述资源死锁归因。
|
|
295
|
+
raw_rt = job_dict.get("runtime")
|
|
296
|
+
_backoff = raw_rt.get(RT_BACKOFF_UNTIL) if isinstance(raw_rt, dict) else None
|
|
297
|
+
if isinstance(_backoff, (int, float)) and _backoff > now:
|
|
298
|
+
remaining = _backoff - now
|
|
299
|
+
min_wait = min(min_wait, max(0.0, remaining))
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
if can_run:
|
|
303
|
+
runnable_idx = i
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
# 兜底:整轮没有可运行 job 但存在 dep-failed job →
|
|
307
|
+
# runnable_idx 落回首个 dep-failed 位置(_dispatch_job 会处理它)。
|
|
308
|
+
if runnable_idx is None and pending_dep_failure is not None:
|
|
309
|
+
runnable_idx = dep_failed_idx
|
|
310
|
+
|
|
311
|
+
# pending_dep_failure 只在 runnable_idx 落回 dep-failed 兜底位置时才有效。
|
|
312
|
+
# 若 runnable_idx 指向真正可运行的 job(排在 dep-failed job 之后扫描选出的),
|
|
313
|
+
# 必须清空 pending_dep_failure,防止将依赖失败误判给可运行作业。
|
|
314
|
+
if runnable_idx != dep_failed_idx:
|
|
315
|
+
pending_dep_failure = None
|
|
316
|
+
|
|
317
|
+
# 死锁归因类集合(impossible/unknown/missing/malformed)任一非空时
|
|
318
|
+
# 强制 min_wait=inf:这些类别的判定不依赖任何等待——impossible/
|
|
319
|
+
# unknown 是永久性死锁,missing 由宽限逻辑单独裁决,malformed 无法
|
|
320
|
+
# 反序列化、永远不可能变为可运行。若被队列中另一 job 的有限退避/
|
|
321
|
+
# 资源等待覆盖 min_wait,死锁判定被逐轮推迟到该等待终结(退避逐轮
|
|
322
|
+
# 放大时可拖数十分钟,管线表现为卡死无日志)。
|
|
323
|
+
if (impossible_resource_indices or unknown_resource_indices
|
|
324
|
+
or missing_dependency_indices or malformed_indices) and min_wait != float('inf'):
|
|
325
|
+
min_wait = float('inf')
|
|
326
|
+
|
|
327
|
+
# 显式表达结果类型——兜底后 runnable_idx 与 pending_dep_failure
|
|
328
|
+
# 的关系决定 kind。
|
|
329
|
+
if runnable_idx is not None and pending_dep_failure is not None:
|
|
330
|
+
kind = "dep_failed"
|
|
331
|
+
elif runnable_idx is not None:
|
|
332
|
+
kind = "runnable"
|
|
333
|
+
else:
|
|
334
|
+
kind = "none"
|
|
335
|
+
|
|
336
|
+
return ScheduleResult(
|
|
337
|
+
runnable_idx=runnable_idx,
|
|
338
|
+
pending_dep_failure=pending_dep_failure,
|
|
339
|
+
min_wait=min_wait,
|
|
340
|
+
waiting_for_dependency=waiting_for_dependency,
|
|
341
|
+
kind=kind,
|
|
342
|
+
unknown_resource_indices=unknown_resource_indices,
|
|
343
|
+
missing_dependency_indices=missing_dependency_indices,
|
|
344
|
+
malformed_indices=malformed_indices,
|
|
345
|
+
impossible_resource_indices=impossible_resource_indices,
|
|
346
|
+
)
|
tasklite/error_codes.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""框架错误码 —— DLQ error 字段与 error_type 分类的单一事实来源。
|
|
2
|
+
``pipeline.py``(构造 DLQ meta)与 ``backend/base.py``(error_type 分类)
|
|
3
|
+
共同引用,避免两处字符串字面量漂移。错误码是公开契约(README/AGENTS
|
|
4
|
+
文档化):任何新增的 DLQ 写入路径必须先在此登记错误码,再决定分类。
|
|
5
|
+
"""
|
|
6
|
+
# DLQ meta["error"] 字段值(框架写入的错误码) -------------------------
|
|
7
|
+
ERR_DEPENDENCY_DEADLOCK = "DEPENDENCY_DEADLOCK"
|
|
8
|
+
ERR_JOB_DEPENDENCY = "JOB_DEPENDENCY"
|
|
9
|
+
ERR_PAYLOAD_VALIDATION = "PAYLOAD_VALIDATION_FAILED"
|
|
10
|
+
ERR_MAX_RETRIES = "MAX_RETRIES_EXCEEDED"
|
|
11
|
+
ERR_NO_HANDLER = "NO_HANDLER"
|
|
12
|
+
ERR_RESOURCE_DEADLOCK = "RESOURCE_DEADLOCK"
|
|
13
|
+
ERR_MALFORMED_JOB = "MALFORMED_JOB"
|
|
14
|
+
ERR_COMMIT_FAILURE_DLQ = "COMMIT_FAILURE_DLQ"
|
|
15
|
+
# dispatch 阶段失败(submit 的 pickle/启动报错、资源
|
|
16
|
+
# acquire 校验失败)连续达阈值转 DLQ 的错误码——不可 pickle 的 lambda
|
|
17
|
+
# handler 会触发无限崩溃重启循环(无 3-strike 兜底)
|
|
18
|
+
ERR_DISPATCH_FAILURE = "DISPATCH_FAILURE"
|
|
19
|
+
# 死锁分类缺口(环检测空 / 不可归因)连续多轮未分类 →
|
|
20
|
+
# 升级为整队列 DLQ 时的专属错误码(区别于正常归因的 DEPENDENCY/RESOURCE_DEADLOCK)
|
|
21
|
+
ERR_DEADLOCK_GAP = "DEADLOCK_CLASSIFICATION_GAP"
|
|
22
|
+
# DLQ error_type 分类值(list_dlq 查询与 _write_dlq_row 落库共用) --------
|
|
23
|
+
ERROR_TYPE_FATAL = "fatal"
|
|
24
|
+
ERROR_TYPE_TRANSIENT_EXHAUSTED = "transient_exhausted"
|
|
25
|
+
ERROR_TYPE_DEPENDENCY = "dependency"
|
|
26
|
+
ERROR_TYPE_DEADLOCK = "deadlock"
|
|
27
|
+
ERROR_TYPE_NO_HANDLER = "no_handler"
|
|
28
|
+
ERROR_TYPE_VALIDATION = "validation"
|
|
29
|
+
ERROR_TYPE_COMMIT_FAILURE = "commit_failure"
|
|
30
|
+
# dispatch 失败(submit pickle/启动报错、资源 acquire 校验
|
|
31
|
+
# 失败)的专属 error_type——list_dlq 查询可按「派发失败」过滤,与
|
|
32
|
+
# 错误码集中登记的精神一致。
|
|
33
|
+
ERROR_TYPE_DISPATCH = "dispatch"
|
|
34
|
+
ERROR_TYPE_UNKNOWN = "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify_error_type(meta: dict) -> str:
|
|
38
|
+
"""从 DLQ meta 推导结构化 error_type(list_dlq() 查询与 _write_dlq_row 落库共用)。
|
|
39
|
+
|
|
40
|
+
分类优先级:fatal 标志最高(FatalError 确定性失败);其次按 error 错误码
|
|
41
|
+
精确/前缀匹配;其余归 unknown。error 字段是自由字符串,前缀匹配只对框架错误码生效。
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(meta, dict):
|
|
44
|
+
return ERROR_TYPE_UNKNOWN
|
|
45
|
+
if meta.get("fatal"):
|
|
46
|
+
return ERROR_TYPE_FATAL
|
|
47
|
+
error = str(meta.get("error", ""))
|
|
48
|
+
mapping = (
|
|
49
|
+
(ERR_DEPENDENCY_DEADLOCK, ERROR_TYPE_DEADLOCK),
|
|
50
|
+
(ERR_RESOURCE_DEADLOCK, ERROR_TYPE_DEADLOCK),
|
|
51
|
+
(ERR_MALFORMED_JOB, ERROR_TYPE_DEADLOCK),
|
|
52
|
+
(ERR_DEADLOCK_GAP, ERROR_TYPE_DEADLOCK),
|
|
53
|
+
(ERR_JOB_DEPENDENCY, ERROR_TYPE_DEPENDENCY),
|
|
54
|
+
(ERR_MAX_RETRIES, ERROR_TYPE_TRANSIENT_EXHAUSTED),
|
|
55
|
+
(ERR_NO_HANDLER, ERROR_TYPE_NO_HANDLER),
|
|
56
|
+
(ERR_PAYLOAD_VALIDATION, ERROR_TYPE_VALIDATION),
|
|
57
|
+
)
|
|
58
|
+
for code, etype in mapping:
|
|
59
|
+
if error == code or error.startswith(code):
|
|
60
|
+
return etype
|
|
61
|
+
if error.startswith(ERR_COMMIT_FAILURE_DLQ):
|
|
62
|
+
return ERROR_TYPE_COMMIT_FAILURE
|
|
63
|
+
if error.startswith(ERR_DISPATCH_FAILURE):
|
|
64
|
+
return ERROR_TYPE_DISPATCH
|
|
65
|
+
return ERROR_TYPE_UNKNOWN
|
|
66
|
+
|
tasklite/exceptions.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Exception types for tasklite.
|
|
2
|
+
|
|
3
|
+
错误分类模型:
|
|
4
|
+
|
|
5
|
+
三类异常在 handler 中的处理待遇完全不同:
|
|
6
|
+
|
|
7
|
+
| 类别 | 包含 | 处理 |
|
|
8
|
+
|------|------|------|
|
|
9
|
+
| **Fatal** | `FatalError` + `FATAL_EXCEPTIONS` | 直接 DLQ,不消耗重试次数(确定性 bug) |
|
|
10
|
+
| **Transient** | `RetryError` + `TRANSIENT_EXCEPTIONS` + 用户注册的瞬态类 | 自动重试(退避等待),达上限进 DLQ |
|
|
11
|
+
| **Unknown** | 其余 `Exception` | 永久失败进 DLQ(与 Fatal 同路径,但元数据标记未知) |
|
|
12
|
+
|
|
13
|
+
设计决策:
|
|
14
|
+
- `RetryError` 是 Transient 的显式入口(业务主动声明「这次是瞬态」)。
|
|
15
|
+
- `TRANSIENT_EXCEPTIONS` 覆盖常见瞬时故障(网络连接/超时/远端错误)——
|
|
16
|
+
业务无需手包 RetryError 即可让这些异常自动重试。
|
|
17
|
+
- `pipeline.register_transient_exception(cls)`(per-pipeline 注册表)允许
|
|
18
|
+
业务把自有库的异常(如 requests.exceptions.ConnectionError)注册为瞬态。
|
|
19
|
+
- 三分类 + 可配置白名单使网络抖动/超时这类天然瞬态错误自动重试而非
|
|
20
|
+
直接判死;`ValueError` 不入 FATAL_EXCEPTIONS——它含 JSONDecodeError
|
|
21
|
+
等可能瞬态的子类,直接判死会误杀可重试失败。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PipelineError(Exception):
|
|
28
|
+
"""Base class for all tasklite framework exceptions."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RetryError(PipelineError):
|
|
33
|
+
"""Raise this in a handler to signal a transient failure. The job will be pushed back to the queue."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FatalError(PipelineError):
|
|
38
|
+
"""Non-retryable error. Goes directly to DLQ without consuming retries.
|
|
39
|
+
|
|
40
|
+
Raise this for code-level bugs (invalid config, missing files that will
|
|
41
|
+
never appear, logic errors).
|
|
42
|
+
"""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RateLimitHit(RetryError):
|
|
47
|
+
"""HTTP 429 信号。
|
|
48
|
+
|
|
49
|
+
``RateLimitHit`` 是 ``RetryError`` 子类:裸抛即按瞬态退避重试。
|
|
50
|
+
裸抛降级语义——handler 忘记在重试前挂起资源时,不会因「未匹配任何
|
|
51
|
+
分类」按 Unknown 判死进 DLQ + 级联下游,仍走退避重试(代价是限流
|
|
52
|
+
资源在重试间隔内未被挂起)。需要在重试前挂起资源时,由调用方自行
|
|
53
|
+
调用资源挂起逻辑后抛 ``RetryError``。
|
|
54
|
+
"""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# 确定性 bug 类:直接 DLQ,不重试。不含 ValueError——它包含 JSONDecodeError
|
|
59
|
+
# 等可能瞬态的子类(由 TRANSIENT_EXCEPTIONS / register_transient_exception
|
|
60
|
+
# 另行归类)。
|
|
61
|
+
FATAL_EXCEPTIONS = (TypeError, KeyError, AttributeError,
|
|
62
|
+
IndexError, StopIteration, ArithmeticError,
|
|
63
|
+
ImportError, NotImplementedError, RecursionError)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# 常见瞬态故障:自动重试(与 RetryError 同等待遇)。
|
|
67
|
+
# ConnectionError/TimeoutError 是 OSError 子类;RemoteDisconnected 等
|
|
68
|
+
# 网络断开异常在此被自动归类为可重试,业务无需手包 RetryError。
|
|
69
|
+
TRANSIENT_EXCEPTIONS = (
|
|
70
|
+
ConnectionError,
|
|
71
|
+
TimeoutError,
|
|
72
|
+
ConnectionRefusedError,
|
|
73
|
+
ConnectionResetError,
|
|
74
|
+
ConnectionAbortedError,
|
|
75
|
+
BrokenPipeError,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _validate_transient_class(exception_cls: type) -> None:
|
|
79
|
+
"""注册入口校验(fail-loud)——per-pipeline 注册表与外部直调共用。"""
|
|
80
|
+
if not isinstance(exception_cls, type) or not issubclass(exception_cls, Exception):
|
|
81
|
+
raise TypeError(
|
|
82
|
+
f"register_transient_exception requires an Exception subclass, got {exception_cls!r}"
|
|
83
|
+
)
|
|
84
|
+
if issubclass(exception_cls, (RetryError, FatalError)):
|
|
85
|
+
# 专用 except 分支优先于注册表,注册必然静默无效,入口直接拒绝
|
|
86
|
+
raise TypeError(
|
|
87
|
+
f"register_transient_exception cannot register a "
|
|
88
|
+
f"{'RetryError' if issubclass(exception_cls, RetryError) else 'FatalError'} "
|
|
89
|
+
f"subclass ({exception_cls!r}) — these have dedicated except branches "
|
|
90
|
+
f"that bypass the registry; registration would silently no-op."
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
import pickle
|
|
94
|
+
pickle.dumps(exception_cls)
|
|
95
|
+
except (pickle.PicklingError, AttributeError, TypeError) as e:
|
|
96
|
+
# 函数作用域定义的类无法被 spawn 子进程 import → 注册必然失效。
|
|
97
|
+
# 在此 fail-loud,把「子进程静默判死」提前为「注册时显式报错」。
|
|
98
|
+
raise TypeError(
|
|
99
|
+
f"register_transient_exception requires a module-level (picklable) "
|
|
100
|
+
f"Exception class for spawn-subprocess propagation, got {exception_cls!r}: {e}"
|
|
101
|
+
) from e
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TransientRegistry:
|
|
105
|
+
"""**per-pipeline** 瞬态异常注册表。
|
|
106
|
+
|
|
107
|
+
注册在父进程、分类在子进程:注册表归 ``TaskLite`` 实例所有,
|
|
108
|
+
子进程分类只消费 ``TaskContext`` 携带的 ``snapshot()``(不可变
|
|
109
|
+
tuple)——无模块级可变注册表参与子进程决策,杜绝跨 pipeline
|
|
110
|
+
累积与跨 run 泄漏。
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self) -> None:
|
|
114
|
+
self._classes: list = []
|
|
115
|
+
|
|
116
|
+
def register(self, exception_cls: type) -> None:
|
|
117
|
+
"""把业务自有异常类注册为瞬态(自动重试),幂等。
|
|
118
|
+
|
|
119
|
+
用于第三方库的异常(如 ``requests.exceptions.ConnectionError``)。
|
|
120
|
+
模块级类才可通过 pickle 预检(spawn 子进程分类依赖 ctx 快照)。
|
|
121
|
+
"""
|
|
122
|
+
_validate_transient_class(exception_cls)
|
|
123
|
+
if exception_cls not in self._classes:
|
|
124
|
+
self._classes.append(exception_cls)
|
|
125
|
+
|
|
126
|
+
def snapshot(self) -> tuple:
|
|
127
|
+
"""返回不可变注册表快照(随 ctx 显式下发子进程)。"""
|
|
128
|
+
return tuple(self._classes)
|
|
129
|
+
|
|
130
|
+
def matches(self, exc: BaseException) -> bool:
|
|
131
|
+
"""exc 是否命中本注册表(父进程侧测试/诊断用)。"""
|
|
132
|
+
return any(isinstance(exc, cls) for cls in self._classes)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _matches_registry(exc: BaseException, registry) -> bool:
|
|
136
|
+
"""纯判定:exc 是否命中给定注册表快照(tuple/TransientRegistry 皆可)。"""
|
|
137
|
+
classes = registry.snapshot() if isinstance(registry, TransientRegistry) else tuple(registry or ())
|
|
138
|
+
return any(isinstance(exc, cls) for cls in classes)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def classify_exception(
|
|
142
|
+
exc: BaseException,
|
|
143
|
+
registry=(),
|
|
144
|
+
*,
|
|
145
|
+
fatal_exceptions: Optional[tuple] = None,
|
|
146
|
+
transient_exceptions: Optional[tuple] = None,
|
|
147
|
+
) -> str:
|
|
148
|
+
"""异常三分类的**唯一生产语义**,返回 retry/fatal/error。
|
|
149
|
+
|
|
150
|
+
分类顺序:RetryError → FatalError → 注册表(用户显式声明)→
|
|
151
|
+
FATAL_EXCEPTIONS(内置启发式)→ TRANSIENT_EXCEPTIONS → Unknown。
|
|
152
|
+
注册表判定必须在 FATAL_EXCEPTIONS 之前——用户显式声明永远优先于内置
|
|
153
|
+
启发式(否则注册 FATAL 子类会被 FATAL 分支短路)。
|
|
154
|
+
|
|
155
|
+
``registry`` 是 ``ctx.transient_registry`` 下发的快照(不可变 tuple);
|
|
156
|
+
子进程调用本函数**不读任何模块级可变状态**。
|
|
157
|
+
|
|
158
|
+
``fatal_exceptions``/``transient_exceptions``:per-pipeline 覆盖——
|
|
159
|
+
与瞬态注册表同纪律,确定性/瞬态内置启发式的成员集合也可按 pipeline
|
|
160
|
+
定制(None=用模块默认元组)。快照随 ctx 下发子进程(可 pickle 的
|
|
161
|
+
tuple),分类决策不读模块级可变全局。
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(exc, RetryError):
|
|
164
|
+
return "retry"
|
|
165
|
+
if isinstance(exc, FatalError):
|
|
166
|
+
return "fatal"
|
|
167
|
+
if _matches_registry(exc, registry):
|
|
168
|
+
return "retry"
|
|
169
|
+
# is not None 判定(非真值判定):空元组 = 显式「本 pipeline 无内置
|
|
170
|
+
# 判死/瞬态成员」,必须与 None(用模块默认)区分。
|
|
171
|
+
_fatal = FATAL_EXCEPTIONS if fatal_exceptions is None else tuple(fatal_exceptions)
|
|
172
|
+
_transient = TRANSIENT_EXCEPTIONS if transient_exceptions is None else tuple(transient_exceptions)
|
|
173
|
+
if isinstance(exc, _fatal):
|
|
174
|
+
return "fatal"
|
|
175
|
+
if isinstance(exc, _transient):
|
|
176
|
+
return "retry"
|
|
177
|
+
return "error"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def is_transient_exception(exc: BaseException, registry=()) -> bool:
|
|
181
|
+
"""判断异常是否属于瞬态(应自动重试)——分类语义的公开只读助手。
|
|
182
|
+
|
|
183
|
+
``registry`` 缺省为空快照;测试/文档调用方如需包含注册表项,应传入
|
|
184
|
+
``pipeline.transient_registry.snapshot()`` 或 ``TransientRegistry``。
|
|
185
|
+
生产侧分类一律走 ``classify_exception``(executor 子进程入口)。
|
|
186
|
+
"""
|
|
187
|
+
return classify_exception(exc, registry) == "retry"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _CommitCrashSignal(BaseException):
|
|
191
|
+
"""内部信号:commit 失败后已 requeue,需立即崩溃。
|
|
192
|
+
|
|
193
|
+
继承 ``BaseException``(而非 Exception)——从调用纪律变成结构保证:
|
|
194
|
+
任何 ``except Exception`` 兜底在类型系统层面捕不到它,无需依赖「except 顺序」约定。
|
|
195
|
+
它穿透一切 ``except Exception`` 直到 ``_run_loop`` 的显式分支(commit 失败需崩溃语义),
|
|
196
|
+
中途不会被误吞。不导出为公共 API(内部信号)。
|
|
197
|
+
"""
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _JobTerminated(BaseException):
|
|
202
|
+
"""内部信号:当前 job 已终结(DLQ 阈值命中),需立即停止处理。
|
|
203
|
+
|
|
204
|
+
继承 ``BaseException``——理由同 ``_CommitCrashSignal``:``except
|
|
205
|
+
Exception`` 在类型系统层面捕不到它,漏捕调用点不会被静默 requeue
|
|
206
|
+
(那是复发)。捕获点**必须**在 ``_dispatch_job``/``_complete_job``/
|
|
207
|
+
``_restore_stale_result`` 内层(语义是「当前 job 停止处理」→ 返回 None),
|
|
208
|
+
**绝不应逃逸到 _run_loop**——若逃逸说明有调用点漏捕,应上抛暴露
|
|
209
|
+
而非静默吞掉(否则未来类漏洞被掩盖)。
|
|
210
|
+
|
|
211
|
+
与 _CommitCrashSignal 的区别:前者表达「commit 失败、系统需崩溃重启」
|
|
212
|
+
(on-disk 队列保留,at-least-once 重跑);本异常表达「job 已被判定为
|
|
213
|
+
确定性坏输入、正常终结」(不崩溃、不重跑)。语义不同,分开捕获。
|
|
214
|
+
"""
|
|
215
|
+
pass
|