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,439 @@
|
|
|
1
|
+
"""完成机器:结果提交、输出清理、资源释放与崩溃恢复族。
|
|
2
|
+
|
|
3
|
+
``complete_job`` 是子进程结果的唯一收尾入口;``apply_result`` 承载
|
|
4
|
+
retry/success/failure 三态事务提交;restore/cleanup/release 是崩溃恢复与
|
|
5
|
+
资源释放的共享助手。依赖经 RunContext 注入,经 ``self._failure`` 复用
|
|
6
|
+
失败机器(3-strike/级联),不反向引用 TaskLite。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .runtime import RunContext
|
|
18
|
+
from .failure import FailureMachine
|
|
19
|
+
|
|
20
|
+
from ..error_codes import ERR_MAX_RETRIES as _ERR_MAX_RETRIES
|
|
21
|
+
from ..exceptions import _JobTerminated
|
|
22
|
+
from ..models.job import Job
|
|
23
|
+
from .executor import (
|
|
24
|
+
ExecutionResult, cleanup_ipc_files, read_inputs, read_outputs,
|
|
25
|
+
)
|
|
26
|
+
from .inflight import InFlightJob
|
|
27
|
+
from .retry import apply_discovery_rerun, compute_backoff, rerun_skips
|
|
28
|
+
from .runtime import RT_BACKOFF_UNTIL, RT_BACKOFF_WALL_DEADLINE, inject_worker_resource
|
|
29
|
+
from ..utils.ipc import inputs_path, outputs_path
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("tasklite")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CompletionMachine:
|
|
35
|
+
"""结果提交 / 输出清理 / 资源释放 / 崩溃恢复的完成侧机器。"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, ctx: "RunContext", failure: "FailureMachine") -> None:
|
|
38
|
+
self._ctx = ctx
|
|
39
|
+
self._failure = failure
|
|
40
|
+
|
|
41
|
+
def complete_job(self, entry: InFlightJob, result: ExecutionResult) -> None:
|
|
42
|
+
"""处理一个 in-flight job 的完成结果(薄包装)。
|
|
43
|
+
|
|
44
|
+
事务性提交与内存 apply 见 ``_apply_result``(两条提交路径共用);
|
|
45
|
+
本方法额外负责 in-flight 注销、资源释放、失败输出清理。
|
|
46
|
+
|
|
47
|
+
身份非真空:in-flight 注销**不在本方法开头**统一
|
|
48
|
+
执行——那会造成「四集合真空」窗口(uid 不在 wall/failed/queue/
|
|
49
|
+
in-flight 任一集合),使 handler 自 spawn 同 uid 子任务时被
|
|
50
|
+
``is_known`` 漏判。注销时机下推到 ``_apply_result`` 内部分支
|
|
51
|
+
(retry 在 requeue 前、success 在 mark_success 后、failure 在
|
|
52
|
+
mark_failed 后),保证 uid 在 ``is_known`` 可达的代码段内始终
|
|
53
|
+
属于至少一个集合。
|
|
54
|
+
|
|
55
|
+
单一出口:IPC 文件生命周期集中在本方法 finally——
|
|
56
|
+
正常路径(drain 已删 result/signals)与恢复路径(``_restore_stale_result``
|
|
57
|
+
伪 entry)统一走 ``cleanup_ipc_files``。
|
|
58
|
+
"""
|
|
59
|
+
state = self._ctx.state
|
|
60
|
+
uid = entry.uid
|
|
61
|
+
terminated = False
|
|
62
|
+
try:
|
|
63
|
+
self.apply_result(
|
|
64
|
+
uid, entry.job, entry.job_dict, result, job_start=entry.job_start,
|
|
65
|
+
expect_in_flight=(entry.handle is not None),
|
|
66
|
+
)
|
|
67
|
+
except _JobTerminated:
|
|
68
|
+
# commit 连续失败达阈值 → job 已 DLQ 终结(正常终态)。
|
|
69
|
+
# _apply_result 已把该 job 标记为 failed;不 re-raise(主循环
|
|
70
|
+
# 继续处理队列中其余 job),finally 仍释放资源。
|
|
71
|
+
result.going_to_retry = False # DLQ 阈值终结,非重试
|
|
72
|
+
# _commit_failed_crash 的 3-strike DLQ 分支
|
|
73
|
+
# 已触发钩子(带完整 COMMIT_FAILURE_DLQ meta),此处标记终结,
|
|
74
|
+
# 跳过尾部 _fire_job_completed——否则同一 job 触发两次钩子
|
|
75
|
+
# (第二次 success 标志还与实际 DLQ 矛盾)。「每个 job 终结时
|
|
76
|
+
# 钩子恰好调用一次」是 _fire_job_completed docstring 的明文承诺。
|
|
77
|
+
terminated = True
|
|
78
|
+
finally:
|
|
79
|
+
# Release resources (无论成功/失败/重试/崩溃,都释放)
|
|
80
|
+
self.release_acquired(entry.acquired, uid=uid)
|
|
81
|
+
|
|
82
|
+
# Cleanup outputs on failure AND retry (intermediate products
|
|
83
|
+
# from a failed retry attempt should not persist)。
|
|
84
|
+
# result 总是非 None(_complete_job 只在有结果时调用)。
|
|
85
|
+
# 输出从落盘 outputs.jsonl 读取(handler 声明时已落盘)。
|
|
86
|
+
if not result.success:
|
|
87
|
+
self.cleanup_outputs(uid)
|
|
88
|
+
else:
|
|
89
|
+
# 成功路径:不删输出文件(cleanup_on_fail 只对失败生效),
|
|
90
|
+
# 仅删除落盘声明文件(outputs.jsonl 生命周期结束)。
|
|
91
|
+
# kind=="cache" 的临时文件在成功路径也要
|
|
92
|
+
# 清理(语义:任务结束时该文件不应存在;.part 已 rename 则
|
|
93
|
+
# no-op)。outputs.jsonl 最后删除,先读完再删。
|
|
94
|
+
try:
|
|
95
|
+
for out_path, _, kind in read_outputs(self._ctx.ipc_dir, uid):
|
|
96
|
+
if kind == "cache":
|
|
97
|
+
out_obj = Path(out_path)
|
|
98
|
+
if out_obj.exists():
|
|
99
|
+
out_obj.unlink()
|
|
100
|
+
logger.info(f"Cleaned cache file: {out_obj}")
|
|
101
|
+
op = outputs_path(self._ctx.ipc_dir, uid)
|
|
102
|
+
if op.exists():
|
|
103
|
+
op.unlink()
|
|
104
|
+
# inputs.jsonl 生命周期与 outputs.jsonl 一致——
|
|
105
|
+
# 成功路径已读入 wall meta,落盘文件可删。失败/重试路径
|
|
106
|
+
# 同样删除:不删则重试时 handler 重新 append,旧指纹残留
|
|
107
|
+
# → input_changed 误判无限重跑(见 _cleanup_outputs 的 finally)。
|
|
108
|
+
ip = inputs_path(self._ctx.ipc_dir, uid)
|
|
109
|
+
if ip.exists():
|
|
110
|
+
ip.unlink()
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
# (单一出口):IPC 文件(result/signals/tmp)生命周期集中
|
|
115
|
+
# 在此 finally——正常路径 drain 已删(重复删除无害,FileNotFound
|
|
116
|
+
# 忽略),恢复路径(_restore_stale_result 伪 entry)也经此
|
|
117
|
+
# 清理,两条路径一致,孤儿 signals 文件不残留。
|
|
118
|
+
try:
|
|
119
|
+
cleanup_ipc_files(self._ctx.ipc_dir, uid)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
# on_job_completed 在 stats 更新之后(_apply_result
|
|
124
|
+
# 已 +1)调用——钩子内读 stats 保证一致。单一出口:正常提交与
|
|
125
|
+
# restore 伪 entry 都经此触发(_dispatch_job 的「不走子进程」直接
|
|
126
|
+
# commit 路径也调用同一 helper)。异常隔离:
|
|
127
|
+
# 钩子抛异常 catch + 计数,绝不影响主循环。
|
|
128
|
+
# 3-strike 终结路径(except _JobTerminated)
|
|
129
|
+
# 的钩子已在 _commit_failed_crash 内触发过,此处跳过防双触发。
|
|
130
|
+
if not terminated:
|
|
131
|
+
self._ctx.fire_job_completed(
|
|
132
|
+
uid, dict(result.result_meta),
|
|
133
|
+
bool(result.success), bool(result.going_to_retry),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def apply_result(
|
|
137
|
+
self,
|
|
138
|
+
uid: str,
|
|
139
|
+
job: Job,
|
|
140
|
+
job_dict: dict,
|
|
141
|
+
result: ExecutionResult,
|
|
142
|
+
job_start: Optional[float] = None,
|
|
143
|
+
expect_in_flight: bool = True,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""事务性提交一个执行结果到后端并同步内存。
|
|
146
|
+
|
|
147
|
+
先提交后端(唯一真相源),commit 成功后才 apply 到内存 state。
|
|
148
|
+
三态分发:
|
|
149
|
+
- retry_requested -> _apply_retry
|
|
150
|
+
- success -> _apply_success
|
|
151
|
+
- failure -> _apply_failure
|
|
152
|
+
"""
|
|
153
|
+
state = self._ctx.state
|
|
154
|
+
if expect_in_flight:
|
|
155
|
+
assert uid in state.in_flight_uids, (
|
|
156
|
+
f"identity vacuity violation: {uid} not in-flight at _apply_result entry"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# 1. 全局应用资源挂起
|
|
160
|
+
applied_suspension = False
|
|
161
|
+
for r_name, secs in result.resource_suspensions:
|
|
162
|
+
if r_name in self._ctx.resources:
|
|
163
|
+
self._ctx.resources[r_name].suspend(secs)
|
|
164
|
+
applied_suspension = True
|
|
165
|
+
else:
|
|
166
|
+
logger.warning(
|
|
167
|
+
f"Skipping suspend for unregistered resource {r_name!r} "
|
|
168
|
+
f"(requested by {uid})"
|
|
169
|
+
)
|
|
170
|
+
if applied_suspension:
|
|
171
|
+
self._ctx.persist_resource_suspends_now()
|
|
172
|
+
|
|
173
|
+
# 2. 状态分发
|
|
174
|
+
if result.retry_requested:
|
|
175
|
+
self._apply_retry(uid, job, job_dict, result)
|
|
176
|
+
elif result.success:
|
|
177
|
+
self._apply_success(uid, job_dict, result, job_start)
|
|
178
|
+
else:
|
|
179
|
+
self._apply_failure(uid, job_dict, result, job_start)
|
|
180
|
+
|
|
181
|
+
def _apply_retry(
|
|
182
|
+
self, uid: str, job: Job, job_dict: dict, result: ExecutionResult
|
|
183
|
+
) -> None:
|
|
184
|
+
"""处理重试分支:max_retries 预算检查、指数退避计算与队尾重入队。"""
|
|
185
|
+
state = self._ctx.state
|
|
186
|
+
# 中断信号与孤儿锁冲突都不消耗预算:中断源于外部信号,锁冲突源于
|
|
187
|
+
# 同 uid 孤儿执行体仍持锁(handler 未执行,孤儿死后重跑本可成功)。
|
|
188
|
+
# 二者即使撞上已耗尽的重试预算也豁免 DLQ,走下方零计数短退避回队
|
|
189
|
+
# 自恢复;其余超限则进入 DLQ。
|
|
190
|
+
if job.retries >= job.max_retries and not (result.interrupted or result.lock_conflict):
|
|
191
|
+
logger.error(f"FAIL: {uid} exceeded max retries ({job.max_retries}). Sent to DLQ.")
|
|
192
|
+
fail_meta = {"error": _ERR_MAX_RETRIES}
|
|
193
|
+
raw_rt = job_dict.get("runtime")
|
|
194
|
+
last_retry_error = raw_rt.get("_last_retry_error") if isinstance(raw_rt, dict) else None
|
|
195
|
+
if last_retry_error:
|
|
196
|
+
fail_meta["last_retry_error"] = last_retry_error
|
|
197
|
+
if result.retry_error:
|
|
198
|
+
fail_meta["retry_error"] = result.retry_error
|
|
199
|
+
committed = self._ctx.backend.commit_job_failure(uid, fail_meta)
|
|
200
|
+
if committed:
|
|
201
|
+
self._failure.apply_failed(uid, fail_meta)
|
|
202
|
+
self._failure.cascade_fail(uid)
|
|
203
|
+
result.going_to_retry = False
|
|
204
|
+
return
|
|
205
|
+
self._failure.commit_failed_crash(uid, "commit_job_failure", job_dict)
|
|
206
|
+
|
|
207
|
+
lock_conflict = result.lock_conflict
|
|
208
|
+
if result.interrupted:
|
|
209
|
+
delay = 1.0
|
|
210
|
+
self._ctx.stats["interrupted_reruns"] += 1
|
|
211
|
+
elif lock_conflict:
|
|
212
|
+
delay = 1.0
|
|
213
|
+
self._ctx.stats["deferred_orphan"] += 1
|
|
214
|
+
else:
|
|
215
|
+
job.retries += 1
|
|
216
|
+
delay = compute_backoff(job.retries, job.backoff_base, job.backoff_max)
|
|
217
|
+
|
|
218
|
+
logger.info(f"RETRY: {uid} (attempt {job.retries}/{job.max_retries}, backoff {delay:.1f}s)")
|
|
219
|
+
retry_dict = job.to_dict()
|
|
220
|
+
retry_dict["resources"] = dict(job_dict.get("resources", {}))
|
|
221
|
+
raw_rt = job_dict.get("runtime")
|
|
222
|
+
retry_dict["runtime"] = dict(raw_rt) if isinstance(raw_rt, dict) else {}
|
|
223
|
+
retry_rt = retry_dict["runtime"]
|
|
224
|
+
retry_rt.setdefault("_last_retry_error", "")
|
|
225
|
+
if result.retry_error and not (lock_conflict or result.interrupted):
|
|
226
|
+
retry_rt["_last_retry_error"] = result.retry_error
|
|
227
|
+
retry_rt[RT_BACKOFF_UNTIL] = time.monotonic() + delay
|
|
228
|
+
retry_rt[RT_BACKOFF_WALL_DEADLINE] = time.time() + delay
|
|
229
|
+
|
|
230
|
+
committed = self._ctx.backend.commit_retry(uid, retry_dict, front=False)
|
|
231
|
+
if not committed:
|
|
232
|
+
self._failure.commit_failed_crash(uid, "commit_retry", job_dict)
|
|
233
|
+
|
|
234
|
+
state.unregister_in_flight(uid)
|
|
235
|
+
state.requeue_jobs([retry_dict], front=False)
|
|
236
|
+
self._ctx.stats["retried"] += 1
|
|
237
|
+
result.going_to_retry = True
|
|
238
|
+
|
|
239
|
+
def _apply_success(
|
|
240
|
+
self, uid: str, job_dict: dict, result: ExecutionResult,
|
|
241
|
+
job_start: Optional[float] = None
|
|
242
|
+
) -> None:
|
|
243
|
+
"""处理成功分支:子任务去重、wall 记录与 cursor 推进。"""
|
|
244
|
+
state = self._ctx.state
|
|
245
|
+
duration = (time.monotonic() - job_start) if job_start is not None else 0.0
|
|
246
|
+
logger.info(f"SUCCESS: {uid} (duration {duration:.2f}s)")
|
|
247
|
+
|
|
248
|
+
# 动态子任务去重与规范化
|
|
249
|
+
spawned_dicts: List[Dict[str, Any]] = []
|
|
250
|
+
new_jobs = result.new_jobs
|
|
251
|
+
if new_jobs:
|
|
252
|
+
seen_in_batch = set()
|
|
253
|
+
unique_new_jobs = []
|
|
254
|
+
for nj in new_jobs:
|
|
255
|
+
nj_uid = nj.uid
|
|
256
|
+
if nj_uid in seen_in_batch:
|
|
257
|
+
logger.debug(f"Skipping duplicate spawn for {nj_uid}.")
|
|
258
|
+
continue
|
|
259
|
+
if state.is_known(nj_uid):
|
|
260
|
+
if nj_uid in state.queue_uids or nj_uid in state.in_flight_uids:
|
|
261
|
+
continue
|
|
262
|
+
wall_hit = nj_uid in state.wall
|
|
263
|
+
failed_hit = nj_uid in state.failed
|
|
264
|
+
if wall_hit or failed_hit:
|
|
265
|
+
if rerun_skips(
|
|
266
|
+
nj.to_dict(), wall_hit=wall_hit, failed_hit=failed_hit,
|
|
267
|
+
wall_meta=state.wall.get(nj_uid),
|
|
268
|
+
):
|
|
269
|
+
continue
|
|
270
|
+
seen_in_batch.add(nj_uid)
|
|
271
|
+
unique_new_jobs.append(nj)
|
|
272
|
+
|
|
273
|
+
for nj in unique_new_jobs:
|
|
274
|
+
jd = nj.to_dict()
|
|
275
|
+
apply_discovery_rerun(jd, nj.task_type, self._ctx.discovery_rerun)
|
|
276
|
+
inject_worker_resource(jd)
|
|
277
|
+
spawned_dicts.append(jd)
|
|
278
|
+
logger.debug(f"Spawned {len(spawned_dicts)} jobs for {uid}.")
|
|
279
|
+
|
|
280
|
+
# 构建 wall meta
|
|
281
|
+
wall_meta = dict(result.result_meta or {})
|
|
282
|
+
prev = state.wall.get(uid)
|
|
283
|
+
raw_count = prev.get("run_count", 0) if isinstance(prev, dict) else 0
|
|
284
|
+
try:
|
|
285
|
+
prev_count = int(raw_count)
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
logger.warning(
|
|
288
|
+
f"Corrupt run_count for {uid} in wall meta ({raw_count!r}); treating as 0"
|
|
289
|
+
)
|
|
290
|
+
prev_count = 0
|
|
291
|
+
wall_meta["run_count"] = prev_count + 1
|
|
292
|
+
wall_meta["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
|
293
|
+
wall_meta["last_run_id"] = self._ctx.run_id
|
|
294
|
+
try:
|
|
295
|
+
declared_inputs = read_inputs(self._ctx.ipc_dir, uid)
|
|
296
|
+
deduped: dict = {}
|
|
297
|
+
for entry in declared_inputs:
|
|
298
|
+
deduped[entry.get("path")] = entry
|
|
299
|
+
if deduped:
|
|
300
|
+
wall_meta["inputs"] = list(deduped.values())
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
committed = self._ctx.backend.commit_job_success(
|
|
305
|
+
uid, wall_meta,
|
|
306
|
+
spawned_jobs=spawned_dicts,
|
|
307
|
+
cursor_updates=result.cursor_updates,
|
|
308
|
+
)
|
|
309
|
+
if committed:
|
|
310
|
+
if spawned_dicts:
|
|
311
|
+
state.spawn_jobs(spawned_dicts, front=True)
|
|
312
|
+
if result.cursor_updates:
|
|
313
|
+
state.update_cursors(result.cursor_updates)
|
|
314
|
+
state.mark_success(uid, wall_meta)
|
|
315
|
+
state.unregister_in_flight(uid)
|
|
316
|
+
self._ctx.stats["completed"] += 1
|
|
317
|
+
result.going_to_retry = False
|
|
318
|
+
return
|
|
319
|
+
self._failure.commit_failed_crash(uid, "commit_job_success", job_dict)
|
|
320
|
+
|
|
321
|
+
def _apply_failure(
|
|
322
|
+
self, uid: str, job_dict: dict, result: ExecutionResult,
|
|
323
|
+
job_start: Optional[float] = None
|
|
324
|
+
) -> None:
|
|
325
|
+
"""处理永久失败分支:写入 DLQ 与级联阻断下游。"""
|
|
326
|
+
duration = (time.monotonic() - job_start) if job_start is not None else 0.0
|
|
327
|
+
logger.error(f"FAIL: {uid} (duration {duration:.2f}s, Sent to DLQ). Meta: {result.result_meta}")
|
|
328
|
+
committed = self._ctx.backend.commit_job_failure(
|
|
329
|
+
uid, result.result_meta
|
|
330
|
+
)
|
|
331
|
+
if committed:
|
|
332
|
+
self._failure.apply_failed(uid, result.result_meta)
|
|
333
|
+
self._failure.cascade_fail(uid)
|
|
334
|
+
result.going_to_retry = False
|
|
335
|
+
return
|
|
336
|
+
self._failure.commit_failed_crash(uid, "commit_job_failure", job_dict)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def restore_stale_result(self, uid: str, job: Job, job_dict: dict) -> bool:
|
|
341
|
+
"""崩溃恢复:派发子进程前消费该 uid 的残留结果文件。
|
|
342
|
+
|
|
343
|
+
上次 run 主进程 SIGKILL/OOM/断电 崩溃时,子进程可能已写好结果文件
|
|
344
|
+
但未及 commit。本方法在 ``_dispatch_job`` 的 acquire/submit 之前
|
|
345
|
+
调用:若有残留结果,直接经 ``_apply_result`` 提交(不启动子进程),
|
|
346
|
+
避免「新子进程已启动、却被旧结果文件误判完成而 kill」的双重执行窗口。
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
True 表示已消费残留(job 已提交/重入队,调用方应返回 None,
|
|
350
|
+
不再派发子进程);False 表示无残留,照常派发。
|
|
351
|
+
"""
|
|
352
|
+
result = self._ctx.executor.consume_stale_result(uid, job)
|
|
353
|
+
if result is None:
|
|
354
|
+
return False
|
|
355
|
+
logger.info(f"RESTORE: {uid} (stale result from previous run, no subprocess)")
|
|
356
|
+
# (单一出口):构造伪 entry 走 _complete_job 同一条路径
|
|
357
|
+
# (acquired=[] 无资源、handle=None 无进程),复用完整的收尾契约:
|
|
358
|
+
# 失败输出清理(_cleanup_outputs)、IPC 文件清理(cleanup_ipc_files)、
|
|
359
|
+
# 身份注销。
|
|
360
|
+
# expect_in_flight=False:uid 从未注册 in-flight(restore 在派发前),
|
|
361
|
+
# _apply_result 内的 unregister 对未注册 uid 是 no-op,断言跳过。
|
|
362
|
+
entry = InFlightJob(
|
|
363
|
+
uid=uid,
|
|
364
|
+
job_dict=job_dict,
|
|
365
|
+
job=job,
|
|
366
|
+
acquired=[],
|
|
367
|
+
handle=None,
|
|
368
|
+
job_start=None,
|
|
369
|
+
)
|
|
370
|
+
try:
|
|
371
|
+
self.complete_job(entry, result)
|
|
372
|
+
except _JobTerminated:
|
|
373
|
+
# 残留结果提交时 commit 连续失败达阈值 → job 已 DLQ 终结。
|
|
374
|
+
# 不 re-raise(主循环继续),消费语义视为完成(返回 True)。
|
|
375
|
+
pass
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
def cleanup_outputs(self, uid: str) -> None:
|
|
379
|
+
"""清理失败/中断 job 的半成品输出(abort 路径复用)。
|
|
380
|
+
|
|
381
|
+
遍历 handler 声明的输出(从落盘 ``{uid}.outputs.jsonl`` 读取,
|
|
382
|
+
handler 崩溃/kill 后声明仍可读),对 ``cleanup_on_fail=True`` 且
|
|
383
|
+
物理存在的路径执行删除(文件 unlink / 目录 rmtree)。
|
|
384
|
+
读后**删除落盘文件**(消费语义,与 read_signals 一致)——
|
|
385
|
+
outputs.jsonl 生命周期在此结束,drain 的 cleanup_ipc_files
|
|
386
|
+
不覆盖它。
|
|
387
|
+
|
|
388
|
+
``_complete_job`` 与 ``_abort_in_flight`` 共用同一清理:被 kill 的
|
|
389
|
+
in-flight job 半成品输出若不清理,重启后 handler 若「文件已存在
|
|
390
|
+
则跳过」会读到半残文件。
|
|
391
|
+
"""
|
|
392
|
+
outputs = read_outputs(self._ctx.ipc_dir, uid)
|
|
393
|
+
try:
|
|
394
|
+
for out_path, cleanup, kind in outputs:
|
|
395
|
+
# kind=="cache" 的临时文件**无条件删除**
|
|
396
|
+
# (语义:任务结束时该文件不应存在;成功路径 rename 已发生
|
|
397
|
+
# 则是 no-op)。kind=="output" 按 cleanup_on_fail 删除。
|
|
398
|
+
if kind == "cache" or cleanup:
|
|
399
|
+
out_path_obj = Path(out_path)
|
|
400
|
+
if out_path_obj.exists():
|
|
401
|
+
if out_path_obj.is_dir():
|
|
402
|
+
shutil.rmtree(out_path_obj)
|
|
403
|
+
else:
|
|
404
|
+
out_path_obj.unlink()
|
|
405
|
+
logger.info(f"Cleaned broken output: {out_path_obj}")
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.error(f"Could not remove outputs for {uid}: {e}")
|
|
408
|
+
finally:
|
|
409
|
+
try:
|
|
410
|
+
# 兼容测试对 Path.unlink 的无参 monkeypatch:
|
|
411
|
+
# 避免 missing_ok keyword 触发 TypeError。
|
|
412
|
+
p = outputs_path(self._ctx.ipc_dir, uid)
|
|
413
|
+
if p.exists():
|
|
414
|
+
p.unlink()
|
|
415
|
+
# inputs.jsonl 生命周期与 outputs.jsonl 一致——
|
|
416
|
+
# 失败/重试路径也消费删除:不删则重试时 handler 重新 append,
|
|
417
|
+
# 旧指纹残留 → input_changed 任一 entry 不匹配 →
|
|
418
|
+
# on_input_change 无限重跑。输入文件本身不是框架产物(不删),
|
|
419
|
+
# 但声明文件(inputs.jsonl)属于本次执行。
|
|
420
|
+
ip = inputs_path(self._ctx.ipc_dir, uid)
|
|
421
|
+
if ip.exists():
|
|
422
|
+
ip.unlink()
|
|
423
|
+
except OSError:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
def release_acquired(
|
|
427
|
+
self, acquired: List[Tuple[str, float]], uid: Optional[str] = None
|
|
428
|
+
) -> None:
|
|
429
|
+
"""释放已 acquire 的资源列表。
|
|
430
|
+
|
|
431
|
+
逐资源 try/except:单个资源的 ``release()`` 失败不中断循环,
|
|
432
|
+
确保其余资源也被释放(否则资源计数器永久抬高,形成泄漏)。
|
|
433
|
+
"""
|
|
434
|
+
for res_name, amount in acquired:
|
|
435
|
+
try:
|
|
436
|
+
self._ctx.resources[res_name].release(amount)
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.error(f"Error releasing resource '{res_name}' for {uid}: {e}")
|
|
439
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""死锁细粒度归因的纯逻辑。
|
|
2
|
+
|
|
3
|
+
``split_deadlock`` 是 `_handle_deadlock` 细粒度归因分支共用的拆分原语——
|
|
4
|
+
各分支的唯一差异是 uid 提取方式(malformed 无法 Job.from_dict,用
|
|
5
|
+
`uid_from_job_dict` 的 hash fallback)与筛选条件(索引 vs uid 集合)。
|
|
6
|
+
输入 queue + 筛选谓词,输出「进 DLQ 的肇事者」与「保留的剩余队列」。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Callable, List, Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def split_deadlock(
|
|
13
|
+
queue: List[dict],
|
|
14
|
+
error: str,
|
|
15
|
+
*,
|
|
16
|
+
extract_uid: Callable[[dict], str],
|
|
17
|
+
include: Callable[[int, str], bool],
|
|
18
|
+
) -> Tuple[List[Tuple[str, dict]], List[dict]]:
|
|
19
|
+
"""把队列拆分为「进 DLQ 的肇事者」与「保留的剩余队列」。
|
|
20
|
+
|
|
21
|
+
各分支的唯一差异是 uid 提取方式(malformed 无法
|
|
22
|
+
Job.from_dict,用 uid_from_job_dict 的 hash fallback)与筛选条件
|
|
23
|
+
(索引 vs uid 集合)。
|
|
24
|
+
"""
|
|
25
|
+
uids_metas = []
|
|
26
|
+
remaining_queue = []
|
|
27
|
+
for idx, jd in enumerate(queue):
|
|
28
|
+
uid = extract_uid(jd)
|
|
29
|
+
if include(idx, uid):
|
|
30
|
+
uids_metas.append((uid, {"error": error, "root_cause": True}))
|
|
31
|
+
else:
|
|
32
|
+
remaining_queue.append(jd)
|
|
33
|
+
return uids_metas, remaining_queue
|