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,409 @@
|
|
|
1
|
+
"""恢复机器(崩溃安全保存 / suspend 信号排空 / abort 分类消费)。
|
|
2
|
+
|
|
3
|
+
``abort_in_flight`` 是异常退出与强制停机的统一收尾:先排空信号,再按
|
|
4
|
+
「结果文件是否已原子落盘」分类——已完成走完成机器提交(不重跑),
|
|
5
|
+
未完成 kill + 清半成品 + requeue(at-least-once)。依赖经 RunContext
|
|
6
|
+
注入、经 CompletionMachine 复用收尾契约,不反向引用 TaskLite。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
12
|
+
from typing import List, Optional, Tuple, TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .runtime import RunContext
|
|
16
|
+
from .completion import CompletionMachine
|
|
17
|
+
|
|
18
|
+
from ..exceptions import _CommitCrashSignal, _JobTerminated
|
|
19
|
+
from ..models.state import uid_from_job_dict
|
|
20
|
+
from ..utils.jsonutil import loads
|
|
21
|
+
from .executor import (
|
|
22
|
+
_decode_ipc_result, cleanup_ipc_files, read_result_file, read_signals,
|
|
23
|
+
result_path,
|
|
24
|
+
)
|
|
25
|
+
from .inflight import InFlightJob
|
|
26
|
+
from .retry import rerun_skips
|
|
27
|
+
from .runtime import (
|
|
28
|
+
META_RESOURCE_SUSPENDS,
|
|
29
|
+
RT_BACKOFF_UNTIL,
|
|
30
|
+
RT_BACKOFF_WALL_DEADLINE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("tasklite")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RecoveryMachine:
|
|
37
|
+
"""崩溃恢复 + 强制停机收尾 + 启动期修复。"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, ctx: "RunContext", completion: "CompletionMachine") -> None:
|
|
40
|
+
self._ctx = ctx
|
|
41
|
+
self._completion = completion
|
|
42
|
+
|
|
43
|
+
def repair_queue_on_load(
|
|
44
|
+
self, q_data: list, wall: dict, failed: dict
|
|
45
|
+
) -> list:
|
|
46
|
+
"""加载期队列整理:退避换算 + 过滤残留 + UID 去重,并执行单次原子持久化。
|
|
47
|
+
|
|
48
|
+
1. 退避换算:以 wall_deadline 换算为单调时钟 monotonic _backoff_until。
|
|
49
|
+
2. 残留过滤:过滤已在 wall/failed 中且不符合 rerun 策略的残留。
|
|
50
|
+
3. UID 去重:同一 UID 重复条目保留首条。
|
|
51
|
+
4. 变更持久化:若有过滤或去重发生,单次原子落盘,避免多次保存出现中间状态。
|
|
52
|
+
"""
|
|
53
|
+
now = time.monotonic()
|
|
54
|
+
wall_now = time.time()
|
|
55
|
+
seen_uid: set = set()
|
|
56
|
+
clean_q = []
|
|
57
|
+
|
|
58
|
+
for jd in q_data:
|
|
59
|
+
# 1. 退避换算
|
|
60
|
+
rt = jd.setdefault("runtime", {})
|
|
61
|
+
if not isinstance(rt, dict):
|
|
62
|
+
rt = {}
|
|
63
|
+
jd["runtime"] = rt
|
|
64
|
+
wall_deadline = rt.get(RT_BACKOFF_WALL_DEADLINE)
|
|
65
|
+
if (
|
|
66
|
+
isinstance(wall_deadline, (int, float))
|
|
67
|
+
and not isinstance(wall_deadline, bool)
|
|
68
|
+
and math.isfinite(wall_deadline)
|
|
69
|
+
):
|
|
70
|
+
if wall_now >= wall_deadline:
|
|
71
|
+
rt.pop(RT_BACKOFF_WALL_DEADLINE, None)
|
|
72
|
+
rt.pop(RT_BACKOFF_UNTIL, None)
|
|
73
|
+
else:
|
|
74
|
+
rt[RT_BACKOFF_UNTIL] = now + (wall_deadline - wall_now)
|
|
75
|
+
else:
|
|
76
|
+
rt.pop(RT_BACKOFF_WALL_DEADLINE, None)
|
|
77
|
+
rt.pop(RT_BACKOFF_UNTIL, None)
|
|
78
|
+
|
|
79
|
+
# 2. 过滤残留
|
|
80
|
+
u = uid_from_job_dict(jd)
|
|
81
|
+
wall_hit = u in wall
|
|
82
|
+
failed_hit = u in failed
|
|
83
|
+
if (wall_hit or failed_hit) and rerun_skips(
|
|
84
|
+
jd,
|
|
85
|
+
wall_hit=wall_hit,
|
|
86
|
+
failed_hit=failed_hit,
|
|
87
|
+
wall_meta=wall.get(u),
|
|
88
|
+
):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# 3. 去重(保留首条)
|
|
92
|
+
if u in seen_uid:
|
|
93
|
+
logger.warning(
|
|
94
|
+
f"Loading duplicate uid {u} in queue; keeping first occurrence "
|
|
95
|
+
f"(dropping {len(clean_q)}-th)."
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
seen_uid.add(u)
|
|
99
|
+
clean_q.append(jd)
|
|
100
|
+
|
|
101
|
+
# 4. 单次原子落盘
|
|
102
|
+
if len(clean_q) < len(q_data):
|
|
103
|
+
self._ctx.backend.save_queue(clean_q)
|
|
104
|
+
return clean_q
|
|
105
|
+
|
|
106
|
+
def load_resource_suspends(self) -> None:
|
|
107
|
+
"""从 meta 表恢复资源 suspend 状态(换算回 monotonic 挂起时刻)。"""
|
|
108
|
+
try:
|
|
109
|
+
raw = self._ctx.backend.get_meta(META_RESOURCE_SUSPENDS)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.warning(f"Failed to load resource suspends from meta: {e}")
|
|
112
|
+
return
|
|
113
|
+
if raw is None:
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
deadlines = loads(raw)
|
|
117
|
+
except (ValueError, TypeError) as e:
|
|
118
|
+
logger.warning(f"Corrupted resource_suspends meta, ignoring: {e}")
|
|
119
|
+
return
|
|
120
|
+
if not isinstance(deadlines, dict):
|
|
121
|
+
logger.warning("resource_suspends meta is not a dict, ignoring")
|
|
122
|
+
return
|
|
123
|
+
now = time.time()
|
|
124
|
+
for name, deadline in deadlines.items():
|
|
125
|
+
try:
|
|
126
|
+
if name not in self._ctx.resources:
|
|
127
|
+
logger.warning(
|
|
128
|
+
f"Skipping persisted suspend for unknown resource '{name}'"
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
if (
|
|
132
|
+
not isinstance(deadline, (int, float))
|
|
133
|
+
or isinstance(deadline, bool)
|
|
134
|
+
or not math.isfinite(deadline)
|
|
135
|
+
):
|
|
136
|
+
logger.warning(
|
|
137
|
+
f"Skipping invalid suspend deadline for resource '{name}'"
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
remaining = deadline - now
|
|
141
|
+
if remaining <= 0:
|
|
142
|
+
continue # 已过期:放行
|
|
143
|
+
self._ctx.resources[name].suspend(remaining)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.warning(
|
|
146
|
+
f"Skipping persisted suspend for resource '{name}' "
|
|
147
|
+
f"(corrupt entry {deadline!r}): {e}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def persist_resource_suspends(self) -> None:
|
|
151
|
+
"""run 结束时把资源挂起截止持久化到 meta 表。
|
|
152
|
+
|
|
153
|
+
薄转发到 RunContext.persist_resource_suspends_now——挂起的每个
|
|
154
|
+
应用点即时持久化(消除 kill -9/OOM 时挂起丢失窗口)见该方法;
|
|
155
|
+
本入口保留 run 收尾调用面与既有契约测试的兼容。
|
|
156
|
+
"""
|
|
157
|
+
self._ctx.persist_resource_suspends_now()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def save_queue_crash_safe(self) -> None:
|
|
161
|
+
"""崩溃路径保存队列:以「磁盘真相」合并「内存队列」,避免丢失作业。
|
|
162
|
+
|
|
163
|
+
崩溃处理器不能盲目用内存队列全量覆盖磁盘——在以下窗口内,内存队列
|
|
164
|
+
会缺失磁盘上仍存在的作业:
|
|
165
|
+
- ``_dispatch_job`` 的 pop 之后、try 之前(作业既不在内存也不在 in-flight);
|
|
166
|
+
- ``_complete_job`` 的 commit 成功之后、apply 到内存之前(spawned 子任务、
|
|
167
|
+
retry 重入队已在磁盘提交但内存未同步)。
|
|
168
|
+
|
|
169
|
+
此方法重新加载磁盘队列,把「磁盘有而内存没有」的作业补回队首,
|
|
170
|
+
再按 uid 去重保存(含内存自身的重复,来自异常路径的双 requeue)。
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
disk_q = self._ctx.backend.load_queue()
|
|
174
|
+
except Exception as e:
|
|
175
|
+
# load 失败不得用空列表继续覆盖——磁盘上「已
|
|
176
|
+
# commit 但内存未同步」的作业会在此次保存中被永久抹除(覆盖
|
|
177
|
+
# 用空 disk 基准)。磁盘至少是上次成功保存的状态,保留原样
|
|
178
|
+
# 比覆盖更安全;内存丢失由 at-least-once 重跑吸收。
|
|
179
|
+
logger.error(
|
|
180
|
+
f"Failed to reload queue from disk for crash-safe save; "
|
|
181
|
+
f"skipping overwrite to preserve disk truth: {e}"
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
mem_q = self._ctx.state.queue
|
|
185
|
+
mem_uids = {uid_from_job_dict(jd) for jd in mem_q}
|
|
186
|
+
# 磁盘有而内存没有的作业(commit/pop 窗口内丢失的)补回队首
|
|
187
|
+
extra = [jd for jd in disk_q if uid_from_job_dict(jd) not in mem_uids]
|
|
188
|
+
if extra:
|
|
189
|
+
logger.warning(
|
|
190
|
+
f"Crash-safe save: recovered {len(extra)} job(s) from disk "
|
|
191
|
+
f"that were missing in memory."
|
|
192
|
+
)
|
|
193
|
+
# 内存内按 uid 去重(异常路径可能重复 requeue 同一作业)
|
|
194
|
+
seen: set = set()
|
|
195
|
+
dedup_mem = []
|
|
196
|
+
for jd in mem_q:
|
|
197
|
+
u = uid_from_job_dict(jd)
|
|
198
|
+
if u in seen:
|
|
199
|
+
continue
|
|
200
|
+
seen.add(u)
|
|
201
|
+
dedup_mem.append(jd)
|
|
202
|
+
self._ctx.backend.save_queue(extra + dedup_mem)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def apply_pending_signals(self) -> None:
|
|
206
|
+
"""读取所有 in-flight job 的 suspend 信号文件,即时应用。
|
|
207
|
+
|
|
208
|
+
handler 在子进程中调用 ``ctx.suspend_resource()`` 时,信号追加到
|
|
209
|
+
``{ipc_dir}/{uid}.signals.jsonl``(落盘 flush)。此方法在主循环
|
|
210
|
+
drain 阶段读取并**删除**各 job 的信号文件(排空语义)——即使
|
|
211
|
+
handler 随后崩溃/超时,落盘的限流信息也不丢失(文件仍在)。
|
|
212
|
+
|
|
213
|
+
``suspend()`` 使用 ``max`` 语义,重复应用同一信号是幂等的。
|
|
214
|
+
"""
|
|
215
|
+
for entry in self._ctx.in_flight.values():
|
|
216
|
+
signals = read_signals(self._ctx.ipc_dir, entry.uid)
|
|
217
|
+
for r_name, secs in signals:
|
|
218
|
+
if r_name in self._ctx.resources:
|
|
219
|
+
self._ctx.resources[r_name].suspend(secs)
|
|
220
|
+
logger.info(f"Applied suspend signal from {entry.uid}: {r_name} for {secs}s")
|
|
221
|
+
# 挂起应用后即时持久化——kill -9/OOM 时已应用的
|
|
222
|
+
# 限流不丢(否则重启后全速重打正在限流的 API)。幂等
|
|
223
|
+
# UPSERT,仅在确有应用动作时触发(本循环体内有应用即写,
|
|
224
|
+
# 无应用零开销)。
|
|
225
|
+
self._ctx.persist_resource_suspends_now()
|
|
226
|
+
else:
|
|
227
|
+
# 纵深防御对称:未注册资源名告警跳过
|
|
228
|
+
# (入口已 fail-loud,此为结果文件/旧信号文件通道兜底)。
|
|
229
|
+
logger.warning(
|
|
230
|
+
f"Skipping suspend signal for unregistered resource "
|
|
231
|
+
f"{r_name!r} (from {entry.uid})"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── 派发机器薄转发(实现见 engine/dispatch.py)──────────
|
|
238
|
+
|
|
239
|
+
def abort_in_flight(self) -> None:
|
|
240
|
+
"""异常/强制停机时:kill 进行中的子进程、消费已完成的结果、requeue。
|
|
241
|
+
|
|
242
|
+
在 ``_run_loop`` 的 except 块和 ABORTING 停机路径调用。
|
|
243
|
+
kill 子进程避免泄漏 fd;清理半成品输出(与 ``_complete_job``
|
|
244
|
+
finally 共用 ``_cleanup_outputs``,被 kill job 的输出不残留);
|
|
245
|
+
requeue 保证未 commit 的 job 不丢
|
|
246
|
+
(磁盘 queue 本就含它们,这里同步内存状态便于 save_queue)。
|
|
247
|
+
务必在 clear() 前释放已 acquire 的资源,避免资源计数器永久抬高。
|
|
248
|
+
|
|
249
|
+
时序契约:**先 kill 子进程、再清理输出**——子进程仍可能
|
|
250
|
+
正在写 declared_outputs,先清理会与写入并发交错(读到半截文件/
|
|
251
|
+
删除后被重新创建)。与正常路径(``_complete_job``:先等子进程死、
|
|
252
|
+
再清理)时序对齐。
|
|
253
|
+
|
|
254
|
+
按「当前 incarnation 的结果文件是否存在」分类:
|
|
255
|
+
「已完成」entry(结果文件已原子落盘、只差 drain 轮询回收)不得按
|
|
256
|
+
失败处理——若走「kill + 删结果文件 + 删已成功产出的物理文件 +
|
|
257
|
+
requeue」→ 重启必重跑,非幂等副作用(API POST/外发邮件)被重复执行
|
|
258
|
+
(窗口「job 完成至下一次 drain ≤50ms + 本轮调度」,在受控中止路径
|
|
259
|
+
确定性可命中)。结果文件存在的 entry 不 kill(进程已自然退出或
|
|
260
|
+
即将退出),改走 ``_complete_job`` 伪 entry 提交(与
|
|
261
|
+
``_restore_stale_result`` 同模式)消费结果;只有无结果文件的 entry
|
|
262
|
+
才执行 kill + 清半成品 + requeue。该时序只适用于进行中 entry。
|
|
263
|
+
|
|
264
|
+
分类(读结果文件)与 kill 之间存在竞态窗口:分类读到无结果
|
|
265
|
+
(归 pending)→ worker 恰在 kill 前完成 ``write_result_atomic``
|
|
266
|
+
(结果文件出现)——若 kill 后立即删结果文件 + 删成功产出 +
|
|
267
|
+
requeue,重启必重跑,非幂等副作用被重复执行。由「kill 后重查」
|
|
268
|
+
兜底:先对全部 pending 只 kill + join(``executor.finalize_processes``,
|
|
269
|
+
不删结果文件),随后**重查** pending 的结果文件——kill 后新出现的
|
|
270
|
+
entry(worker 恰在分类与 kill 之间完成)移入 done 消费(走
|
|
271
|
+
``_complete_job`` 伪 entry,不删成功输出、不 requeue),与 drain()
|
|
272
|
+
超时路径的「kill 后重读」(``executor._collect_outcome``)对称;
|
|
273
|
+
剩余 pending 才是真正「未完成」——此刻写入已因 kill/join 停止,
|
|
274
|
+
再统一 ``cleanup_ipc_files`` + ``_cleanup_outputs`` + requeue。
|
|
275
|
+
时序(先 kill 再清理)不变。
|
|
276
|
+
"""
|
|
277
|
+
if not self._ctx.in_flight:
|
|
278
|
+
return
|
|
279
|
+
# abort 前先消费所有 in-flight 的 suspend 信号——
|
|
280
|
+
# _apply_pending_signals 是 read_signals 的唯一消费点,只在正常主循环
|
|
281
|
+
# drain 阶段调用;abort 路径若直接 kill + cleanup_ipc_files(targets
|
|
282
|
+
# 含 signals 文件),未被读取的 suspend 限流信息会随文件删除丢失,
|
|
283
|
+
# 违反「崩溃/强制停机也要保留 429 限流状态」的已文档化承诺。在
|
|
284
|
+
# 分类/kill 前统一 drain 一次(排空语义:读后删,幂等),被杀 job
|
|
285
|
+
# 的挂起状态在 apply_pending_signals 内应用后即时持久化
|
|
286
|
+
# (RunContext.persist_resource_suspends_now 保证即时持久化)。
|
|
287
|
+
self.apply_pending_signals()
|
|
288
|
+
# 分类——结果文件已落盘 = 执行已完成,只差提交。
|
|
289
|
+
# 只读一次并缓存 result dict(复用消费,避免双读 TOCTOU);损坏/
|
|
290
|
+
# 非标准结果(read_result_file 返回 None 或无 "status" 键)按「未完成」
|
|
291
|
+
# 处理(与 consume_stale_result 的「损坏 = 无残留」语义一致:宁可重跑,不可
|
|
292
|
+
# 误判;且 _decode_ipc_result 遇无 status 的 dict 会访问 p.exitcode
|
|
293
|
+
# 崩 run——分类保证进入消费的 res 必为合法结果 dict)。
|
|
294
|
+
done_entries: List[Tuple[InFlightJob, dict]] = []
|
|
295
|
+
pending_entries: List[InFlightJob] = []
|
|
296
|
+
for entry in self._ctx.in_flight.values():
|
|
297
|
+
res_path = result_path(
|
|
298
|
+
entry.handle.ipc_dir, entry.uid, entry.handle.incarnation
|
|
299
|
+
)
|
|
300
|
+
if res_path.exists():
|
|
301
|
+
res = read_result_file(res_path)
|
|
302
|
+
# status=="interrupted"(worker 被 Ctrl+C/
|
|
303
|
+
# SIGTERM 中断)不视为已完成——abort 语义是「全部进行中
|
|
304
|
+
# job 视为未完成」,interrupted 归 pending requeue(不进
|
|
305
|
+
# DLQ、不级联),与 README「仅进行中 job requeue」一致。
|
|
306
|
+
if (res is not None and isinstance(res, dict) and "status" in res
|
|
307
|
+
and res.get("status") != "interrupted"):
|
|
308
|
+
done_entries.append((entry, res))
|
|
309
|
+
continue
|
|
310
|
+
pending_entries.append(entry)
|
|
311
|
+
# 先释放资源(务必在 clear 前)
|
|
312
|
+
for entry in self._ctx.in_flight.values():
|
|
313
|
+
self._completion.release_acquired(entry.acquired, uid=entry.uid)
|
|
314
|
+
# 先 kill 全部**进行中**子进程(确保输出写入停止),
|
|
315
|
+
# 再清理半成品输出(与 _complete_job 的正常路径时序对齐)。
|
|
316
|
+
# 「已完成」entry 不 kill——结果已落盘,子进程已自然退出或即将退出。
|
|
317
|
+
# **先只 kill + join、不删 IPC 文件**
|
|
318
|
+
# (executor.finalize_processes)——分类(上方读结果文件判 done/
|
|
319
|
+
# pending)与 kill 之间,worker 可能恰好完成 write_result_atomic:
|
|
320
|
+
# 若 kill 后立即 cleanup_ipc_files 删结果文件,刚写好的成功结果被
|
|
321
|
+
# 清掉 → 成功 job 被误判未完成 → requeue → 重启必重跑,非幂等
|
|
322
|
+
# 副作用被重复执行。kill/join 后进程写入已停止,此刻重查结果文件
|
|
323
|
+
# 才无竞态。
|
|
324
|
+
handles = [entry.handle for entry in pending_entries]
|
|
325
|
+
self._ctx.executor.finalize_processes(handles)
|
|
326
|
+
# kill 后重查 pending 的结果文件——worker 恰在分类与 kill
|
|
327
|
+
# 之间完成写结果的 entry(结果文件此刻才出现)移入 done 消费
|
|
328
|
+
# (不删成功输出、不 requeue);与 drain 超时路径的「kill 后
|
|
329
|
+
# 重读」对称。
|
|
330
|
+
still_pending: List[InFlightJob] = []
|
|
331
|
+
for entry in pending_entries:
|
|
332
|
+
res_path = result_path(
|
|
333
|
+
entry.handle.ipc_dir, entry.uid, entry.handle.incarnation
|
|
334
|
+
)
|
|
335
|
+
if res_path.exists():
|
|
336
|
+
res = read_result_file(res_path)
|
|
337
|
+
# 同初查——interrupted 结果归 pending requeue
|
|
338
|
+
if (res is not None and isinstance(res, dict) and "status" in res
|
|
339
|
+
and res.get("status") != "interrupted"):
|
|
340
|
+
done_entries.append((entry, res))
|
|
341
|
+
continue
|
|
342
|
+
still_pending.append(entry)
|
|
343
|
+
pending_entries = still_pending
|
|
344
|
+
# 此刻进程已 kill/join(写入停止),对真正「未完成」的 entry
|
|
345
|
+
# 统一清理 IPC 文件(结果/signals/tmp——cleanup_ipc_files 语义)
|
|
346
|
+
# 与半成品输出。清理动作与 executor.cleanup 相同,但推迟到
|
|
347
|
+
# 重查之后,消除 TOCTOU 窗口。
|
|
348
|
+
for entry in pending_entries:
|
|
349
|
+
try:
|
|
350
|
+
cleanup_ipc_files(entry.handle.ipc_dir, entry.uid)
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
# 清理被 kill job 的半成品输出(与 _complete_job 一致)。
|
|
354
|
+
# 输出从落盘 outputs.jsonl 读取(kill 前 handler 已落盘声明)。
|
|
355
|
+
for entry in pending_entries:
|
|
356
|
+
self._completion.cleanup_outputs(entry.uid)
|
|
357
|
+
# self._ctx.state 的 queue 不含 in-flight job(已 pop),requeue 后含它们,
|
|
358
|
+
# 与磁盘一致(commit 才删,它们未 commit)。requeue 到队首。
|
|
359
|
+
# 注意:只 requeue 进行中的 job——「已完成」entry 的结果将被
|
|
360
|
+
# _complete_job 提交到 wall/failed,不 requeue(否则重启必重跑,
|
|
361
|
+
# 非幂等副作用被重复执行,避免重复提交造成副作用重复)。
|
|
362
|
+
job_dicts = [entry.job_dict for entry in pending_entries]
|
|
363
|
+
# requeue 前先把 pending 从 in-flight 集合注销——
|
|
364
|
+
# 否则 pending 同时属于 queue(requeue 后)与 _in_flight_uids,随后
|
|
365
|
+
# done entry 消费(_complete_job → unregister_in_flight)触发
|
|
366
|
+
# `_assert_state_consistent` 的「queue ∩ in_flight 互斥」断言崩溃,
|
|
367
|
+
# 恢复路径被替换原异常(身份非真空的集合互斥被破坏)。
|
|
368
|
+
# 此处 unregister 的 pending 在 requeue 前仅在 in-flight(不在
|
|
369
|
+
# queue),断言通过;requeue 后 pending 只在 queue,互斥保持。rerun
|
|
370
|
+
# 任务经 spawn_jobs 重新登记 _rerun_active_uids,不泄漏豁免。
|
|
371
|
+
for pentry in pending_entries:
|
|
372
|
+
self._ctx.state.unregister_in_flight(pentry.uid)
|
|
373
|
+
self._ctx.state.requeue_jobs(job_dicts, front=True)
|
|
374
|
+
# 消费「已完成」entry 的结果——走 _complete_job 伪
|
|
375
|
+
# entry 提交(acquired=[] 防二次释放、handle=None 使
|
|
376
|
+
# expect_in_flight=False 跳过 DEBUG 断言),复用单一出口收尾
|
|
377
|
+
# (成功进 wall / 失败进 DLQ / retry 退避重入队;失败清理与 IPC
|
|
378
|
+
# 文件清理由 _complete_job finally 统一执行)。
|
|
379
|
+
# commit 连续失败达阈值时 _complete_job 内层已转 _JobTerminated
|
|
380
|
+
# (job 已 DLQ 终结,消费语义视为完成);_CommitCrashSignal(未达
|
|
381
|
+
# 阈值)保留到 abort 收尾后重抛——requeue 已由 _commit_failed_crash
|
|
382
|
+
commit_crash: Optional[BaseException] = None
|
|
383
|
+
for entry, res in done_entries:
|
|
384
|
+
result = _decode_ipc_result(
|
|
385
|
+
res, None, entry.job, entry.handle.ipc_dir
|
|
386
|
+
)
|
|
387
|
+
pseudo = InFlightJob(
|
|
388
|
+
uid=entry.uid,
|
|
389
|
+
job_dict=entry.job_dict,
|
|
390
|
+
job=entry.job,
|
|
391
|
+
acquired=[],
|
|
392
|
+
handle=None,
|
|
393
|
+
job_start=None,
|
|
394
|
+
)
|
|
395
|
+
try:
|
|
396
|
+
self._completion.complete_job(pseudo, result)
|
|
397
|
+
except _JobTerminated:
|
|
398
|
+
# commit 连续失败达阈值 → job 已 DLQ 终结(正常终态)。
|
|
399
|
+
# 与 _restore_stale_result 的消费语义一致:视为完成,继续收尾。
|
|
400
|
+
pass
|
|
401
|
+
except _CommitCrashSignal as e:
|
|
402
|
+
# 完成 abort 收尾(clear 后续 entry)后再重抛,
|
|
403
|
+
# 避免中途 raise 使 _in_flight 残留、其余 entry 不处理。
|
|
404
|
+
commit_crash = e
|
|
405
|
+
self._ctx.in_flight.clear()
|
|
406
|
+
# 同步清空 state 的 in-flight 集合
|
|
407
|
+
self._ctx.state.clear_in_flight()
|
|
408
|
+
if commit_crash is not None:
|
|
409
|
+
raise commit_crash
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("tasklite")
|
|
8
|
+
|
|
9
|
+
# 单次 suspend 的上限(秒):防止子进程传入 1e12 等超大值永久停摆管线
|
|
10
|
+
_MAX_SUSPEND_SECONDS = 86400.0 # 24h
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Resource(ABC):
|
|
14
|
+
def __init__(self, name: str):
|
|
15
|
+
self.name = name
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def can_acquire(self, amount: float) -> Tuple[bool, float]:
|
|
19
|
+
"""Returns (is_available, seconds_to_wait_if_not)"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def acquire(self, amount: float) -> None:
|
|
24
|
+
"""占用 amount 个单位的资源。"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def release(self, amount: float) -> None:
|
|
29
|
+
"""归还 amount 个单位的资源(job 终结时由框架调用)。
|
|
30
|
+
|
|
31
|
+
契约说明:无用量计数的资源类型(如 RateLimitResource——时间片
|
|
32
|
+
消费不可撤销)**允许实现为 no-op**;调用方不得假设 release 一定
|
|
33
|
+
释放可复用容量。
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def suspend(self, seconds: float) -> None:
|
|
39
|
+
"""挂起资源 seconds 秒。
|
|
40
|
+
|
|
41
|
+
语义是「设置最早可用截止」:重复挂起取 max(幂等去重),**不是
|
|
42
|
+
累加**——suspend(30) 后再 suspend(60),总挂起到 now+60 而非
|
|
43
|
+
now+90。与 acquire 的推进语义(多次调用线性累加)不同。
|
|
44
|
+
"""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def suspended_until(self) -> Optional[float]:
|
|
48
|
+
"""挂起截止的协议访问器——monotonic 时钟
|
|
49
|
+
时刻,无挂起/无限速等待返回 None。
|
|
50
|
+
|
|
51
|
+
需要持久化挂起/等待状态的子类应覆写(默认实现返回 None,不持久
|
|
52
|
+
化)——协议方法优于 getattr duck-typing:后者探测各实现的私有属
|
|
53
|
+
性名,新增实现会静默漏持久化 429 状态。
|
|
54
|
+
"""
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def _sanitize_suspend(self, seconds: float) -> float:
|
|
58
|
+
"""校验并钳制 suspend 秒数:非有限/负值忽略,超上限钳制。
|
|
59
|
+
|
|
60
|
+
子进程(handler)可经 ``ctx.suspend_resource`` 任意传值——无校验时
|
|
61
|
+
1e12 秒的 suspend 会让整个管线永久停摆(min_wait 有限→主循环无限
|
|
62
|
+
sleep,不触发死锁处理)。返回钳制后的有效秒数。
|
|
63
|
+
|
|
64
|
+
大整数溢出防御:超大 int(如 ``10**400``)通过 isinstance 检查后,
|
|
65
|
+
``math.isfinite`` 转 float 抛 OverflowError 击穿本防御直达 suspend
|
|
66
|
+
调用点(worker 内未捕获 → 进程崩溃)。溢出 int 必然超出上限:正值
|
|
67
|
+
钳制、负值按无效忽略。
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(seconds, (int, float)) or isinstance(seconds, bool):
|
|
70
|
+
logger.warning(f"Resource '{self.name}' suspend: non-numeric seconds {seconds!r}, ignoring")
|
|
71
|
+
return 0.0
|
|
72
|
+
try:
|
|
73
|
+
finite = math.isfinite(seconds)
|
|
74
|
+
except OverflowError:
|
|
75
|
+
if seconds > 0:
|
|
76
|
+
logger.warning(
|
|
77
|
+
f"Resource '{self.name}' suspend: {seconds} overflows float, "
|
|
78
|
+
f"clamping to max {_MAX_SUSPEND_SECONDS}s"
|
|
79
|
+
)
|
|
80
|
+
return float(_MAX_SUSPEND_SECONDS)
|
|
81
|
+
logger.warning(f"Resource '{self.name}' suspend: invalid seconds {seconds!r}, ignoring")
|
|
82
|
+
return 0.0
|
|
83
|
+
if not finite or seconds <= 0:
|
|
84
|
+
logger.warning(f"Resource '{self.name}' suspend: invalid seconds {seconds!r}, ignoring")
|
|
85
|
+
return 0.0
|
|
86
|
+
if seconds > _MAX_SUSPEND_SECONDS:
|
|
87
|
+
logger.warning(
|
|
88
|
+
f"Resource '{self.name}' suspend: {seconds}s exceeds max {_MAX_SUSPEND_SECONDS}s, clamping"
|
|
89
|
+
)
|
|
90
|
+
return _MAX_SUSPEND_SECONDS
|
|
91
|
+
return seconds
|
|
92
|
+
|
|
93
|
+
class RateLimitResource(Resource):
|
|
94
|
+
"""Controls frequency of operations, e.g., 1 request per 5 seconds.
|
|
95
|
+
|
|
96
|
+
直觉警告:`acquire(amount)` 的 ``amount`` 是「消费多少个时间片」,
|
|
97
|
+
**不是请求次数**——一般限流器的直觉是传请求数、间隔内部管理,本类
|
|
98
|
+
相反(amount 直接缩放间隔)。绝大多数调用方应保持默认 1.0。
|
|
99
|
+
For example, with interval_seconds=2.0:
|
|
100
|
+
- acquire(amount=1.0) advances next_available by 2 seconds (default)
|
|
101
|
+
- acquire(amount=0.5) advances next_available by 1 second (half interval)
|
|
102
|
+
- acquire(amount=2.0) advances next_available by 4 seconds (two intervals)
|
|
103
|
+
|
|
104
|
+
Note: `can_acquire()` only checks if the resource is currently available (now >= next_available).
|
|
105
|
+
It does NOT validate whether the requested amount would block the resource for too long.
|
|
106
|
+
Callers should use reasonable amounts (typically 1.0) to avoid accidentally blocking.
|
|
107
|
+
"""
|
|
108
|
+
def __init__(self, name: str, interval_seconds: float):
|
|
109
|
+
super().__init__(name)
|
|
110
|
+
# interval 必须为有限正数——负值会把 next_available 推向过去使
|
|
111
|
+
# can_acquire 恒 True,静默禁用限速(CapacityResource 的容量校验
|
|
112
|
+
# 是 fail-loud,两者不对称,此处需显式校验)。
|
|
113
|
+
if not isinstance(interval_seconds, (int, float)) or isinstance(interval_seconds, bool):
|
|
114
|
+
raise TypeError(
|
|
115
|
+
f"interval_seconds must be a number, got {type(interval_seconds).__name__}"
|
|
116
|
+
)
|
|
117
|
+
if not math.isfinite(interval_seconds) or interval_seconds <= 0:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"interval_seconds must be finite and > 0, got {interval_seconds!r}"
|
|
120
|
+
)
|
|
121
|
+
self.interval = interval_seconds
|
|
122
|
+
self.next_available = time.monotonic()
|
|
123
|
+
|
|
124
|
+
def can_acquire(self, amount: float) -> Tuple[bool, float]:
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
if now >= self.next_available:
|
|
127
|
+
return True, 0.0
|
|
128
|
+
return False, self.next_available - now
|
|
129
|
+
|
|
130
|
+
def acquire(self, amount: float) -> None:
|
|
131
|
+
if amount < 0:
|
|
132
|
+
raise ValueError(f"amount must be non-negative, got {amount}")
|
|
133
|
+
now = time.monotonic()
|
|
134
|
+
base_time = max(now, self.next_available)
|
|
135
|
+
self.next_available = base_time + (self.interval * amount)
|
|
136
|
+
|
|
137
|
+
def release(self, amount: float) -> None:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def suspend(self, seconds: float) -> None:
|
|
141
|
+
now = time.monotonic()
|
|
142
|
+
seconds = self._sanitize_suspend(seconds)
|
|
143
|
+
self.next_available = max(self.next_available, now + seconds)
|
|
144
|
+
|
|
145
|
+
def suspended_until(self) -> Optional[float]:
|
|
146
|
+
# 限速的挂起语义 = 下次可用时刻(next_available 为 monotonic)。
|
|
147
|
+
# 仅当存在真实等待/挂起(未来时刻)时返回;否则返回 None,与
|
|
148
|
+
# CapacityResource 的“无挂起返回 None”语义对齐。
|
|
149
|
+
if self.next_available > time.monotonic():
|
|
150
|
+
return self.next_available
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
return f"RateLimitResource(name={self.name!r}, interval={self.interval})"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CapacityResource(Resource):
|
|
158
|
+
"""Controls concurrent volume, e.g., max 8000 MB VRAM."""
|
|
159
|
+
_CAPACITY_POLL_INTERVAL: float = 0.5
|
|
160
|
+
|
|
161
|
+
def __init__(self, name: str, max_capacity: float):
|
|
162
|
+
super().__init__(name)
|
|
163
|
+
# 构造期校验 max_capacity——与 RateLimitResource 的 interval 校验
|
|
164
|
+
# 对称。
|
|
165
|
+
# NaN 让 can_acquire 的 ``amount > NaN`` 恒 False、``used + amount
|
|
166
|
+
# <= NaN`` 恒 False → 永远返回 (False, 0.5) → 主循环无限 sleep
|
|
167
|
+
# (livelock,死锁检测只认 inf 不认 NaN);负值让 acquire 永远
|
|
168
|
+
# 抛 ValueError(amount > capacity 恒 True)→ 全灭。入口即拒。
|
|
169
|
+
if not isinstance(max_capacity, (int, float)) or isinstance(max_capacity, bool):
|
|
170
|
+
raise TypeError(
|
|
171
|
+
f"max_capacity must be a number, got {type(max_capacity).__name__} ({max_capacity!r})"
|
|
172
|
+
)
|
|
173
|
+
if not math.isfinite(max_capacity) or max_capacity < 0:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"max_capacity must be finite and >= 0, got {max_capacity!r}"
|
|
176
|
+
)
|
|
177
|
+
self.capacity = max_capacity
|
|
178
|
+
self.used = 0.0
|
|
179
|
+
self.suspend_until = 0.0
|
|
180
|
+
# 可观测性增强:release 超量(双重释放/记错账)计数——保持 clamp 行为不变,
|
|
181
|
+
# 用计数器提升可观测性(双重释放是资源泄漏的早期信号)。
|
|
182
|
+
self.release_overruns = 0
|
|
183
|
+
|
|
184
|
+
def can_acquire(self, amount: float) -> Tuple[bool, float]:
|
|
185
|
+
if amount > self.capacity:
|
|
186
|
+
# Deadlock safeguard: impossible request
|
|
187
|
+
return False, float('inf')
|
|
188
|
+
|
|
189
|
+
now = time.monotonic()
|
|
190
|
+
if now < self.suspend_until:
|
|
191
|
+
return False, self.suspend_until - now
|
|
192
|
+
|
|
193
|
+
if self.used + amount <= self.capacity:
|
|
194
|
+
return True, 0.0
|
|
195
|
+
|
|
196
|
+
# Wait a small poll interval until other tasks release it
|
|
197
|
+
return False, self._CAPACITY_POLL_INTERVAL
|
|
198
|
+
|
|
199
|
+
def acquire(self, amount: float) -> None:
|
|
200
|
+
"""Acquire ``amount`` units of capacity.
|
|
201
|
+
|
|
202
|
+
``amount=0`` is valid: it does not occupy capacity, only validates
|
|
203
|
+
that ``capacity >= 0``. Used by jobs that declare a resource but
|
|
204
|
+
don't consume slots.
|
|
205
|
+
"""
|
|
206
|
+
if amount < 0:
|
|
207
|
+
raise ValueError(f"amount must be non-negative, got {amount}")
|
|
208
|
+
if amount > self.capacity:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"amount {amount} exceeds resource capacity {self.capacity}"
|
|
211
|
+
)
|
|
212
|
+
self.used += amount
|
|
213
|
+
|
|
214
|
+
def release(self, amount: float) -> None:
|
|
215
|
+
if amount < 0:
|
|
216
|
+
raise ValueError(f"amount must be non-negative, got {amount}")
|
|
217
|
+
if amount > self.used:
|
|
218
|
+
# release 超量(双重释放/记错账)时保持 clamp 行为(release
|
|
219
|
+
# 常在 finally,抛异常会遮蔽原异常),以计数 + 告警提升可观测性。
|
|
220
|
+
# 该计数仅用于本条日志内联展示(管线侧不汇总读取,仅作排障
|
|
221
|
+
# 线索)。
|
|
222
|
+
self.release_overruns += 1
|
|
223
|
+
logger.warning(
|
|
224
|
+
f"Resource '{self.name}' underflow: release({amount}) > used({self.used}). "
|
|
225
|
+
f"Possible double-release. Clamping to 0. (overruns={self.release_overruns})"
|
|
226
|
+
)
|
|
227
|
+
self.used = max(0.0, self.used - amount)
|
|
228
|
+
|
|
229
|
+
def suspend(self, seconds: float) -> None:
|
|
230
|
+
now = time.monotonic()
|
|
231
|
+
seconds = self._sanitize_suspend(seconds)
|
|
232
|
+
self.suspend_until = max(self.suspend_until, now + seconds)
|
|
233
|
+
|
|
234
|
+
def suspended_until(self) -> Optional[float]:
|
|
235
|
+
# Capacity 的挂起字段初始 0.0(无挂起)→ None;挂起后为
|
|
236
|
+
# monotonic 截止时刻
|
|
237
|
+
return self.suspend_until if self.suspend_until > 0 else None
|
|
238
|
+
|
|
239
|
+
def __repr__(self) -> str:
|
|
240
|
+
return f"CapacityResource(name={self.name!r}, used={self.used}, capacity={self.capacity})"
|