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,1202 @@
1
+ """Multiprocessing executor for tasklite.
2
+
3
+ Encapsulates the subprocess lifecycle and IPC result handling.
4
+
5
+ IPC 模型:结果走**落盘文件**而非 mp.Queue——``mp.Queue.get_nowait()``
6
+ 是伪非阻塞(管道中出现「部分消息 + 进程存活(挂起)」时会在 ``_recv``
7
+ 上无限阻塞,主循环卡死,超时检查与 SIGKILL 永远执行不到),且超时宽限
8
+ ``join(5.0)`` 串行阻塞主循环、拖累其他 in-flight handle 的回收与
9
+ SIGTERM 响应。
10
+
11
+ - 子进程把结果写 ``{uid}.{incarnation}.result.json.tmp`` → ``os.replace``
12
+ → ``{uid}.{incarnation}.result.json``(与后端 ``_atomic_write_json`` 同构的
13
+ 原子写;文件系统保证「要么全有要么全无」,不存在部分消息)。
14
+ - 父进程 ``drain()`` 只做 ``os.path.exists(result_path)`` 非阻塞轮询——无管道、
15
+ 无阻塞点、无 EOF 问题;超时判定独立于文件读取。
16
+ - suspend 信号写 ``{uid}.signals.jsonl`` 追加行——进程被 kill 后文件仍在,
17
+ 信号不丢(即时性由文件 flush 保证)。
18
+ - 附带收益:写盘时就必须 JSON 序列化(result_meta JSON 预检变强制)。
19
+ """
20
+
21
+ # 本文件签名注解引用 Callable/mp 等名字——
22
+ # Python 3.10–3.13 注解在 def 执行时立即求值,缺导入即 NameError(声明
23
+ # 的 >=3.10 实际不可用);3.14 因 PEP 649 惰性注解侥幸存活,但任何注解
24
+ # 求值(inspect.signature/get_type_hints)仍炸。future import 兜底 +
25
+ # 补齐真实导入双保险。
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import multiprocessing as mp
31
+ import os
32
+ import re
33
+ import signal
34
+ import time
35
+ import traceback
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Any, Callable, Dict, List, Optional, Tuple
39
+
40
+ from ..exceptions import (
41
+ FatalError, RetryError, classify_exception,
42
+ )
43
+ from ..models.job import Job
44
+ # IPC 写入侧(append_*/路径构造/后缀常量)已下沉 utils.ipc——TaskContext
45
+ #(models 层)直接消费,models 不得 import engine。此处导入保持 executor
46
+ # 模块内名字可用(既有外部引用经 executor 路径取这些名字)。
47
+ from ..utils.ipc import (
48
+ append_input, append_output, append_signal,
49
+ inputs_path, outputs_path, signals_path,
50
+ )
51
+ from ..utils.jsonutil import dump, dumps, loads, load as json_load
52
+ from ..utils.lockfile import safe_uid_filename
53
+
54
+ logger = logging.getLogger("tasklite")
55
+
56
+ _KEY_TRACEBACK = "traceback"
57
+
58
+ # 结果文件的扩展名(inputs/outputs/signals 声明文件的后缀在 utils.ipc)
59
+ _RESULT_TMP_SUFFIX = ".result.json.tmp"
60
+ _RESULT_SUFFIX = ".result.json"
61
+
62
+ # 执行身份 fencing:结果文件名携带 incarnation
63
+ # ``{uid}.{run_id}.{seq}.result.json``,run_id 为 32 位 hex uuid。
64
+ # ``drain`` 只查当前 incarnation 路径——上次 run 崩溃后遗留的孤儿进程
65
+ # 写出的旧 incarnation 结果文件不会被新 run 看见(防止「孤儿结果被
66
+ # 误认为新子进程的结果而 kill 新子进程 + commit 孤儿上下文」)。
67
+ # 精确匹配正则(32-hex + 数字):消除 uid 前缀歧义(job_id 可含点,
68
+ # glob ``{uid}.*`` 会误匹配 ``{uid}.X`` 这类更长 uid 的文件)。
69
+ _INCARNATION_RE = re.compile(r"\.([0-9a-f]{32})\.(\d+)\.result\.json$")
70
+
71
+ # 结果目录环境变量(测试可覆盖;默认由 pipeline 在 state_dir 下创建)
72
+ _RESULT_DIR_ENV = "TASKLITE_IPC_DIR"
73
+
74
+ # raw_result 的类型标记:JSON 无法保留 Python tuple,落盘前
75
+ # 编码类型、读取后还原。None/True/False/数字/字符串/列表/字典 JSON 原生可
76
+ # 保真;只有 tuple 需要编码(decode 后还原为 tuple 传给
77
+ # _normalize_handler_result)。
78
+ # list-marker 方案:tuple → [哨兵, items],decode 要求外层是 list 且首元素
79
+ # 为带版本号的哨兵——用户 dict 永不误判;用户 list 撞哨兵需 len==2 且
80
+ # [0] 恰为哨兵字符串(带 v1 版本号,不可枚举)。
81
+ _RAW_TUPLE_SENTINEL = "__tl_tuple_v1"
82
+
83
+
84
+ def _encode_raw_result(raw_result: Any) -> Any:
85
+ """编码 handler 返回值以便 JSON 落盘。
86
+
87
+ JSON 只支持 list,不支持 tuple——tuple(bool, dict) 是合法 handler
88
+ 返回类型,必须保真。编码:tuple → [哨兵, list(items)],
89
+ 其余类型原样(JSON 原生保真)。
90
+ """
91
+ if isinstance(raw_result, tuple):
92
+ return [_RAW_TUPLE_SENTINEL, list(raw_result)]
93
+ return raw_result
94
+
95
+
96
+ def _decode_raw_result(encoded: Any) -> Any:
97
+ """读取结果文件后还原 handler 返回值(_encode_raw_result 的逆操作)。
98
+
99
+ 健壮性保障:还原段必须是 list/tuple,否则视为用户原始 list 原样返回
100
+ ——用户 handler 合法返回 ``["__tl_tuple_v1", 123]``(如透传上游协议标记)
101
+ 时,``tuple(123)`` 会抛 TypeError 穿透 drain 崩掉整个 run;
102
+ ``[..., {"a": 1}]`` 会被静默转成 ``('a',)`` 使成功的 job 被假 DLQ。
103
+ 下游 _normalize_handler_result 会以明确的 invalid-return-type
104
+ 消息判失败进 DLQ——宁可 DLQ,不可崩。
105
+ """
106
+ if (isinstance(encoded, list) and len(encoded) == 2
107
+ and encoded[0] == _RAW_TUPLE_SENTINEL
108
+ and isinstance(encoded[1], (list, tuple))):
109
+ return tuple(encoded[1])
110
+ return encoded
111
+
112
+
113
+
114
+ # 文件级 IPC 辅助
115
+
116
+
117
+
118
+ def result_path(ipc_dir, uid: str, incarnation: str) -> Path:
119
+ """某个 job 的最终结果文件路径。
120
+
121
+ 返回 ``{uid}.{incarnation}.result.json``,incarnation 为执行代标识。
122
+ """
123
+ suffix = f".{incarnation}{_RESULT_SUFFIX}"
124
+ return Path(ipc_dir) / f"{safe_uid_filename(uid)}{suffix}"
125
+
126
+
127
+ def result_tmp_path(ipc_dir, uid: str, incarnation: str) -> Path:
128
+ """某个 job 的结果临时文件路径(子进程写入中)。"""
129
+ suffix = f".{incarnation}{_RESULT_TMP_SUFFIX}"
130
+ return Path(ipc_dir) / f"{safe_uid_filename(uid)}{suffix}"
131
+
132
+
133
+ def _iter_stale_result_paths(ipc_dir, uid: str) -> List[Path]:
134
+ """枚举某个 uid 的全部**残留**结果文件路径。
135
+
136
+ 返回任意 incarnation 的 ``{uid}.{run_id}.{seq}.result.json`` 匹配项。
137
+ 用正则精确过滤(job_id 可含点:glob ``{uid}.*`` 会误匹配 ``{uid}.X``
138
+ 这类更长 uid 的文件,正则要求 incarnation 段为 32-hex + 数字)。
139
+
140
+ 同时枚举 ``.result.json.tmp`` 半成品(kill 中断原子写的残留)——
141
+ 不清理会随崩溃/重试无限累积(长跑管线 ipc 目录膨胀)。
142
+ """
143
+ d = Path(ipc_dir)
144
+ base = f"{safe_uid_filename(uid)}"
145
+ found: List[Path] = []
146
+ try:
147
+ for pat in (f"{base}.*{_RESULT_SUFFIX}", f"{base}.*{_RESULT_TMP_SUFFIX}"):
148
+ for p in d.glob(pat):
149
+ # .match 从 ``len(base)`` 处锚定,兄弟剩余部分
150
+ # (如 `.1.`)不满足 ``\.hex32\.\d+`` 而整体失配。
151
+ if _INCARNATION_RE.match(p.name, pos=len(base)):
152
+ found.append(p)
153
+ except OSError:
154
+ pass
155
+ return found
156
+
157
+
158
+ def read_inputs(ipc_dir, uid: str) -> List[dict]:
159
+ """读取一个 job 的全部输入声明(落盘文件)。
160
+
161
+ 返回 ``[entry, ...]``;文件不存在/坏行跳过。**不删除文件**。
162
+ """
163
+ path = inputs_path(ipc_dir, uid)
164
+ entries: List[dict] = []
165
+ try:
166
+ if path.exists():
167
+ with open(path, encoding="utf-8") as f:
168
+ for line in f:
169
+ line = line.strip()
170
+ if not line:
171
+ continue
172
+ try:
173
+ data = loads(line)
174
+ if isinstance(data, dict) and isinstance(data.get("path"), str):
175
+ entries.append(data)
176
+ except (json.JSONDecodeError, TypeError, ValueError):
177
+ continue
178
+ except OSError:
179
+ pass
180
+ return entries
181
+
182
+
183
+ def read_outputs(ipc_dir, uid: str) -> List[Tuple[str, bool, str]]:
184
+ """读取一个 job 的全部已声明输出(落盘文件)。
185
+
186
+ 返回 ``[(path, cleanup, kind), ...]``;文件不存在/坏行跳过。
187
+ **不删除文件**——outputs.jsonl 的生命周期由 ``_complete_job``
188
+ (成功路径 finally 读后删除)与 ``_abort_in_flight``(经
189
+ ``_cleanup_outputs`` 读后删除)管理,**不属于本函数、也不属于
190
+ cleanup_ipc_files**(其不清理 outputs.jsonl)。``_dispatch_job`` 也会在
191
+ submit 前清崩溃残留的声明文件(outputs.jsonl/inputs.jsonl,幂等 no-op)。
192
+ 声明行必须带 ``kind``(``"output"`` / ``"cache"``);缺 kind 的旧行跳过。
193
+ """
194
+ path = outputs_path(ipc_dir, uid)
195
+ outputs: List[Tuple[str, bool, str]] = []
196
+ try:
197
+ if path.exists():
198
+ with open(path, encoding="utf-8") as f:
199
+ for line in f:
200
+ line = line.strip()
201
+ if not line:
202
+ continue
203
+ try:
204
+ data = loads(line)
205
+ if isinstance(data, dict) and isinstance(data.get("path"), str):
206
+ kind = data.get("kind")
207
+ if not isinstance(kind, str):
208
+ continue
209
+ outputs.append((
210
+ data["path"],
211
+ bool(data.get("cleanup", True)),
212
+ kind,
213
+ ))
214
+ except (json.JSONDecodeError, TypeError, ValueError):
215
+ continue # 坏行跳过
216
+ except OSError:
217
+ pass
218
+ return outputs
219
+
220
+
221
+ def write_result_atomic(
222
+ ipc_dir, uid: str, result_dict: dict, incarnation: str
223
+ ) -> None:
224
+ """原子写结果:先写 .tmp 再 os.replace。失败时清理 .tmp。
225
+
226
+ allow_nan=False——子进程侧对 handler 返回值的最后一道序列化关卡,
227
+ 与 _normalize_handler_result 的预检对齐,杜绝 NaN/Infinity 写入结果文件。
228
+ rename 后 fsync 父目录(与后端原子写同构),
229
+ 保证断电后 rename 持久化,否则崩溃恢复(consume_stale_result)可能读不到结果。
230
+ """
231
+ tmp = result_tmp_path(ipc_dir, uid, incarnation)
232
+ final = result_path(ipc_dir, uid, incarnation)
233
+ tmp.parent.mkdir(parents=True, exist_ok=True)
234
+ try:
235
+ with open(tmp, "w", encoding="utf-8") as f:
236
+ dump(result_dict, f)
237
+ f.flush()
238
+ os.fsync(f.fileno())
239
+ os.replace(tmp, final)
240
+ # 目录 fsync:确保 rename 的目录条目在断电时随文件一起落盘
241
+ try:
242
+ dir_fd = os.open(str(final.parent), os.O_RDONLY)
243
+ try:
244
+ os.fsync(dir_fd)
245
+ finally:
246
+ os.close(dir_fd)
247
+ except OSError:
248
+ pass
249
+ except Exception:
250
+ try:
251
+ tmp.unlink(missing_ok=True)
252
+ except OSError:
253
+ pass
254
+ raise
255
+
256
+
257
+ def _write_result_with_degradation(
258
+ ipc_dir, uid: str, payload: Dict[str, Any], incarnation: str
259
+ ) -> None:
260
+ """worker 结果落盘的唯一出口:完整写失败时两级降级,绝不裸抛 OSError。
261
+
262
+ 主路径各分支若裸调 write_result_atomic——磁盘满/瞬态 IO 故障时
263
+ OSError 直接穿透,worker 裸崩退出(无结果文件)→ 父进程按崩溃收割
264
+ 且不设 retry_requested → **成功执行的 job 被误判 DLQ、成果永久丢失**。
265
+ 统一降级链:
266
+
267
+ 1. 完整 payload 写盘(含 raw_result/new_jobs/cursor_updates 全部字段);
268
+ 2. 失败(OSError)记 warning,短暂停顿后重试一次完整写——给瞬态
269
+ IO 故障一个自愈窗口;
270
+ 3. 仍失败则写「降级结果」——原 status 为 "success" 时改写
271
+ {"status": "retry", "error": "IPC_RESULT_WRITE_DEGRADED: ..."}:
272
+ 成果虽未能落盘,但让 job 走重跑(at-least-once 契约本就以重跑
273
+ 吸收副作用,静默丢失 spawned jobs/cursor_updates 更糟);其他
274
+ status 保持原语义(retry/fatal/error/interrupted 不因写失败
275
+ 改变判定)。无 status 的异常 payload 也归入 retry(宁可重跑)。
276
+ 结构化 lock_conflict 字段保留——判定端读字段而非 error 前缀,
277
+ 丢失会把框架锁冲突误当业务 retry 烧 max_retries 预算;
278
+ 4. 降级写也失败(磁盘满到连小 payload 都写不下)记 error 后放弃
279
+ ——worker 无结果退出,由 drain 按崩溃语义收割。
280
+ """
281
+ try:
282
+ write_result_atomic(ipc_dir, uid, payload, incarnation=incarnation)
283
+ return
284
+ except OSError as e:
285
+ logger.warning(
286
+ f"result write failed for {uid}: {e}; "
287
+ f"retrying full payload once after brief pause"
288
+ )
289
+ time.sleep(0.05)
290
+ try:
291
+ write_result_atomic(ipc_dir, uid, payload, incarnation=incarnation)
292
+ return
293
+ except OSError as e:
294
+ # except 块结束后 ``as e`` 绑定即被清除——降级 payload 的错误
295
+ # 消息必须在块内先取出,块外引用会 UnboundLocalError。
296
+ write_err = str(e)
297
+ logger.warning(
298
+ f"full result write retry also failed for {uid}: {e}; "
299
+ f"falling back to degraded result"
300
+ )
301
+ orig_status = payload.get("status")
302
+ degraded_status = "retry" if orig_status in (None, "success") else orig_status
303
+ degraded: Dict[str, Any] = {
304
+ "status": degraded_status,
305
+ "error": f"IPC_RESULT_WRITE_DEGRADED: {write_err}",
306
+ }
307
+ if payload.get("lock_conflict"):
308
+ degraded["lock_conflict"] = True
309
+ try:
310
+ write_result_atomic(ipc_dir, uid, degraded, incarnation=incarnation)
311
+ except OSError as e2:
312
+ logger.error(
313
+ f"degraded result write also failed for {uid}: {e2}; "
314
+ f"worker exiting without IPC result"
315
+ )
316
+
317
+
318
+ def read_result_file(path: Path) -> Optional[dict]:
319
+ """读取结果文件;损坏/不存在返回 None(宁可重跑,不可崩)。"""
320
+ try:
321
+ with open(path, encoding="utf-8") as f:
322
+ data = json_load(f)
323
+ if isinstance(data, dict):
324
+ return data
325
+ logger.warning(f"Corrupt result file {path}: not a dict, ignoring")
326
+ return None
327
+ except FileNotFoundError:
328
+ return None
329
+ except (json.JSONDecodeError, OSError, TypeError, ValueError) as e:
330
+ logger.warning(f"Corrupt result file {path}: {e}, ignoring")
331
+ return None
332
+
333
+
334
+ def read_signals(ipc_dir, uid: str) -> List[Tuple[str, float]]:
335
+ """读取并**删除**一个 job 的 suspend 信号文件(排空语义)。
336
+
337
+ 防止并发写入丢失:读后截断再删——「读完整个文件 → unlink」会把
338
+ worker 在读取进行中追加的**未读到信号**一并删除(丢失)。读后
339
+ ``truncate(0)`` 再删:读取期间追加的行保留在文件里,由下一轮
340
+ drain 消费,不随本轮丢失。
341
+ """
342
+ path = signals_path(ipc_dir, uid)
343
+ signals: List[Tuple[str, float]] = []
344
+ try:
345
+ if path.exists():
346
+ # "r+" 读写模式——截断需要写权限;只读模式下 f.truncate 抛
347
+ # io.UnsupportedOperation(OSError 子类)被下方防御静默吞掉,
348
+ # 截断沦为死代码。权限不足时 open 即抛
349
+ # PermissionError,同样走外层 OSError 防御:信号照常读出,
350
+ # 仅放弃截断(优雅降级)。
351
+ with open(path, "r+", encoding="utf-8") as f:
352
+ for line in f:
353
+ line = line.strip()
354
+ if not line:
355
+ continue
356
+ try:
357
+ data = loads(line)
358
+ if isinstance(data, dict) and "suspend" in data:
359
+ r_name, secs = data["suspend"]
360
+ signals.append((r_name, secs))
361
+ except (json.JSONDecodeError, TypeError, ValueError):
362
+ continue # 坏行跳过
363
+ # 读后先截断:读取期间 worker 追加的新信号不随本轮丢失
364
+ try:
365
+ f.seek(0)
366
+ f.truncate(0)
367
+ except OSError:
368
+ pass
369
+ try:
370
+ path.unlink()
371
+ except FileNotFoundError:
372
+ pass
373
+ except OSError:
374
+ pass
375
+ except OSError:
376
+ pass
377
+ return signals
378
+
379
+
380
+ def cleanup_ipc_files(ipc_dir, uid: str, incarnation: Optional[str] = None) -> None:
381
+ """删除某个 job 的结果/信号/临时文件(**不含** outputs.jsonl)。
382
+
383
+ incarnation 提供时精确删除当前执行代的文件;同时始终清理任意
384
+ incarnation 的残留变体(孤儿文件由 consume_stale_result 消费后清理,
385
+ 防止目录无限膨胀)。
386
+
387
+ outputs.jsonl 的生命周期由 ``_complete_job``/``_abort_in_flight``
388
+ 管理(``_cleanup_outputs`` 读后删除)——drain 在此处删掉它,
389
+ 会让随后的输出清理读不到声明。
390
+
391
+ ``{uid}.lock`` 锁文件**永不在此清理**——unlink 后
392
+ 新进程 create 同名文件拿到的是新 inode 的锁,与旧持锁者不互斥
393
+ (经典 unlink-recreate 竞争)。锁文件跨 run 同名、累积可接受,
394
+ 由运维在 pipeline 停止时整目录离线清理。
395
+ """
396
+ targets = [signals_path(ipc_dir, uid)]
397
+ if incarnation is not None:
398
+ targets += [
399
+ result_path(ipc_dir, uid, incarnation),
400
+ result_tmp_path(ipc_dir, uid, incarnation),
401
+ ]
402
+ targets += _iter_stale_result_paths(ipc_dir, uid)
403
+ for p in targets:
404
+ try:
405
+ p.unlink()
406
+ except FileNotFoundError:
407
+ pass # 文件本就不存在(未写入/已删除)
408
+ except OSError:
409
+ pass
410
+
411
+
412
+
413
+ # Worker 包装(在子进程内运行)
414
+
415
+
416
+
417
+ def _mp_worker_wrapper(handler_func, job, ctx, ipc_dir) -> None:
418
+ """Wrapper for multiprocessing worker execution.
419
+
420
+ 异常分类三态契约(retry / fatal / error):
421
+ - RetryError(显式抛出)→ "retry"
422
+ - FatalError(显式抛出)→ "fatal"
423
+ - 注册表(用户显式声明,优先于内置 FATAL 启发式)→ "retry"
424
+ - FATAL_EXCEPTIONS(内置启发式)→ "fatal"
425
+ - TRANSIENT_EXCEPTIONS(内置启发式)→ "retry"
426
+ - 其余 Exception(Unknown)→ "error"
427
+
428
+ 注册表快照契约:父进程在 submit 时把 **per-pipeline 注册表快照**
429
+ 随 ctx pickle 下发(``ctx.transient_registry``,不可变 tuple),此处
430
+ 直接用 ``classify_exception`` 消费——**不重放、不写任何模块级可变
431
+ 注册表**。spawn 子进程是全新解释器,函数作用域的注册不随进程继承;
432
+ 模块级类可 pickle(注册时已预检 fail-loud),快照判定与父进程一致。
433
+
434
+ 结果经 ``_write_result_with_degradation`` 落盘(原子 rename + 完整写
435
+ 失败时的两级降级),文件名携带 incarnation(ctx.incarnation 由
436
+ submit 时写入)——崩溃后的孤儿进程用旧 incarnation
437
+ 写文件,新 run 的 drain 不会看见。
438
+ """
439
+ uid = job.uid
440
+ incarnation = getattr(ctx, "incarnation", None)
441
+ # incarnation 缺失 fail-loud——结果文件名
442
+ # 携带执行代标识,None 会写出 "{uid}.None.result.json" 垃圾路径:drain
443
+ # 永远看不见它 → job 被判 NO_IPC_RESULT 判死,且孤儿结果文件永久残留。
444
+ # 这只可能是框架内部装配错误(submit 未写 ctx.incarnation),尽早暴露。
445
+ if not incarnation:
446
+ raise RuntimeError(
447
+ f"worker started without incarnation for {uid}: "
448
+ f"fencing requires ctx.incarnation set by submit"
449
+ )
450
+ # 注册表快照是 ctx 的不可变字段——分类只读它,不触碰任何
451
+ # 父进程模块级全局。
452
+ transient_registry = getattr(ctx, "transient_registry", ()) or ()
453
+ # worker 子进程入口持有 {uid}.lock 排他锁(≤2s 短等待)。
454
+ # 锁的生命周期 = 执行体生命周期——主进程崩溃后孤儿 worker 仍持锁,
455
+ # 新 run 的探测失败 → requeue 而非双跑。拿锁超时 = 另有
456
+ # 执行体(同 uid 孤儿仍活)→ 按瞬态重试(自恢复:孤儿死后重跑成功)。
457
+ from ..utils.lockfile import try_acquire_lock, release_lock
458
+ # None 仅=「锁被其他执行体占用」(瞬态,孤儿死后自恢复);锁文件
459
+ # 打不开/建不出(权限、磁盘满等环境故障)由 try_acquire_lock 抛
460
+ # OSError——此处不捕获:环境错误应 fail-loud(worker 非零退出 →
461
+ # drain 按崩溃收割判死),而非误当锁冲突无限静默重试。
462
+ _lock_fd = try_acquire_lock(ipc_dir, uid, timeout=2.0)
463
+ if _lock_fd is None:
464
+ # lock_conflict 走零计数重试(同 uid 孤儿仍持锁时 handler 未
465
+ # 执行,孤儿死后重跑本可成功,判死进 DLQ 会永久误杀)。
466
+ # status="retry" 使 _decode_ipc_result 走退避重试。
467
+ # 同时写结构化字段 lock_conflict=True——判定端读字段
468
+ # 而非 error 字符串前缀(业务 RetryError 消息可能撞 "LOCK_CONFLICT"
469
+ # 前缀,读前缀会误判为零计数重试)。
470
+ # 此写在主 try 之外——写失败(磁盘满等 OSError)若让 worker 裸崩
471
+ # → 无结果文件 → NO_IPC_RESULT 判死进 DLQ,与「孤儿死后自恢复
472
+ # 重试」的设计意图相反。与主路径共用 _write_result_with_degradation
473
+ # 的两级降级(完整写 → 重试 → 降级写最小结果);降级时保留
474
+ # lock_conflict 结构化字段(判定端读字段而非 error 前缀,误丢会把
475
+ # 框架锁冲突当业务 retry 烧 max_retries 预算)。
476
+ _write_result_with_degradation(ipc_dir, uid, {
477
+ "status": "retry",
478
+ "lock_conflict": True,
479
+ "error": f"LOCK_CONFLICT: another execution body holds {uid} lock",
480
+ }, incarnation=incarnation)
481
+ return
482
+ try:
483
+ raw_result = handler_func(job, ctx)
484
+ _write_result_with_degradation(ipc_dir, uid, {
485
+ "status": "success",
486
+ "raw_result": _encode_raw_result(raw_result),
487
+ "new_jobs": [j.to_dict() for j in ctx.new_jobs],
488
+ "resource_suspensions": ctx.resource_suspensions,
489
+ "cursor_updates": ctx.cursor_updates
490
+ }, incarnation=incarnation)
491
+ except RetryError as e:
492
+ _write_result_with_degradation(ipc_dir, uid, {"status": "retry", "error": str(e)}, incarnation=incarnation)
493
+ except FatalError as e:
494
+ _write_result_with_degradation(ipc_dir, uid, {
495
+ "status": "fatal",
496
+ "error": str(e),
497
+ _KEY_TRACEBACK: traceback.format_exc()
498
+ }, incarnation=incarnation)
499
+ except Exception as e:
500
+ # 注册表判定先于内置 FATAL 启发式——用户显式声明永远优先。用户
501
+ # 注册了 FATAL 子类时,此处按瞬态重试而非被 FATAL 启发式短路判死。
502
+ # 分类语义唯一来源是 exceptions.classify_exception;fatal/transient
503
+ # 覆盖集合同 transient_registry 一样经 ctx 快照下发(None=用模块
504
+ # 默认元组),子进程分类不读模块级可变全局。
505
+ kind = classify_exception(
506
+ e, transient_registry,
507
+ fatal_exceptions=getattr(ctx, "fatal_exceptions", None),
508
+ transient_exceptions=getattr(ctx, "transient_exceptions", None),
509
+ )
510
+ if kind == "retry":
511
+ # 瞬态异常(连接断开/超时/注册表命中等)自动重试
512
+ _write_result_with_degradation(ipc_dir, uid, {"status": "retry", "error": f"{type(e).__name__}: {e}"}, incarnation=incarnation)
513
+ elif kind == "fatal":
514
+ _write_result_with_degradation(ipc_dir, uid, {
515
+ "status": "fatal",
516
+ "error": f"{type(e).__name__}: {e}",
517
+ _KEY_TRACEBACK: traceback.format_exc()
518
+ }, incarnation=incarnation)
519
+ else:
520
+ _write_result_with_degradation(ipc_dir, uid, {
521
+ "status": "error",
522
+ "error": f"{type(e).__name__}: {e}",
523
+ _KEY_TRACEBACK: traceback.format_exc()
524
+ }, incarnation=incarnation)
525
+ except KeyboardInterrupt as e:
526
+ # 前台进程组的 Ctrl+C 同时送达 worker——写结构化
527
+ # "interrupted" status,消费端零计数回队(不进 DLQ 不级联),兑现
528
+ # README「仅进行中 job requeue」承诺。只覆盖 KeyboardInterrupt:
529
+ # handler 显式 raise SystemExit 是代码 bug/主动退出请求,走下方
530
+ # 分支维持 Unknown 判死(否则会以 1s 间隔无限零计数重跑打转,
531
+ # run 永不返回)。
532
+ _write_result_with_degradation(ipc_dir, uid, {
533
+ "status": "interrupted",
534
+ "error": f"WORKER_INTERRUPTED: {type(e).__name__}",
535
+ _KEY_TRACEBACK: traceback.format_exc()
536
+ }, incarnation=incarnation)
537
+ except SystemExit as e:
538
+ # handler 主动 sys.exit/raise SystemExit——按 handler bug 处理
539
+ # (Unknown 判死进 DLQ),与 KeyboardInterrupt 的外部中断语义分离。
540
+ _write_result_with_degradation(ipc_dir, uid, {
541
+ "status": "error",
542
+ "error": f"WORKER_INTERRUPTED: {type(e).__name__}",
543
+ _KEY_TRACEBACK: traceback.format_exc()
544
+ }, incarnation=incarnation)
545
+ finally:
546
+ # 执行体结束(成功/失败/异常)必须释放锁——锁的
547
+ # 生命周期 = 执行体生命周期。不释放则同 uid 后续派发永远探测失败。
548
+ release_lock(_lock_fd)
549
+
550
+
551
+ def _normalize_handler_result(result: Any) -> Tuple[bool, Dict[str, Any]]:
552
+ """Normalize handler return value to (success, metadata) tuple.
553
+
554
+ Accepted return types:
555
+ - None → success, no metadata
556
+ - bool → success/failure, no metadata
557
+ - dict → success, dict as metadata
558
+ - tuple(bool, dict) → explicit success/failure + metadata
559
+
560
+ Any other type (int, str, list, object, etc.) is treated as a handler
561
+ bug: returns failure with an error message so the job goes to DLQ
562
+ instead of being silently marked as successful.
563
+ """
564
+ if result is None:
565
+ return True, {}
566
+ if isinstance(result, bool):
567
+ return result, {}
568
+ if isinstance(result, dict):
569
+ # dict 元数据必须 JSON 可序列化。含 bytes/datetime/自定义
570
+ # 对象的 dict 会让 SQLite commit_job_success 失败 → _CommitCrashSignal
571
+ # → 无限重启循环。此处预检,坏 dict 直接进 DLQ。
572
+ # allow_nan=False 与 ctx.spawn/enqueue 的序列化预检对齐——默认
573
+ # allow_nan=True 会让 float('nan')/float('inf') 通过预检,产出非标准
574
+ # JSON "NaN"/"Infinity" 落盘,读回 NaN 污染 wall/cursor 计算。
575
+ try:
576
+ dumps(result)
577
+ except (TypeError, ValueError) as e:
578
+ logger.error(
579
+ f"Handler returned dict with non-JSON-serializable metadata: {e}. "
580
+ f"Job will be marked as failed."
581
+ )
582
+ return False, {"error": f"invalid result metadata: not JSON-serializable: {e}"}
583
+ return True, result
584
+ if isinstance(result, tuple) and len(result) == 2:
585
+ if not isinstance(result[0], bool) or not isinstance(result[1], dict):
586
+ logger.error(
587
+ f"Handler returned invalid tuple: expected (bool, dict), got "
588
+ f"({type(result[0]).__name__}, {type(result[1]).__name__}). Job will be marked as failed."
589
+ )
590
+ return False, {"error": "invalid handler return tuple: expected (bool, dict)"}
591
+ try:
592
+ dumps(result[1])
593
+ except (TypeError, ValueError) as e:
594
+ logger.error(
595
+ f"Handler returned tuple with non-JSON-serializable metadata: {e}. "
596
+ f"Job will be marked as failed."
597
+ )
598
+ return False, {"error": f"invalid result metadata: not JSON-serializable: {e}"}
599
+ return result[0], result[1]
600
+ logger.error(
601
+ f"Handler returned unrecognized type: {type(result).__name__}. "
602
+ f"Expected None, bool, dict, or tuple(bool, dict). "
603
+ f"Job will be marked as failed."
604
+ )
605
+ return False, {"error": f"invalid handler return type: {type(result).__name__}"}
606
+
607
+
608
+ def _decode_ipc_result(
609
+ res: dict, p, job: Job, ipc_dir: Optional[str] = None
610
+ ) -> "ExecutionResult":
611
+ """解析子进程通过结果文件回传的结果字典。
612
+
613
+ 复用原 ``execute()`` 中的解析逻辑(含 output 校验)。调用方需保证
614
+ ``res`` 是一个含 ``status`` 键的完整结果 dict。
615
+
616
+ 输出存在性校验读落盘的 ``{uid}.outputs.jsonl``——handler 声明的
617
+ 输出在子进程内立即落盘,主进程不依赖 Manager RPC。``ipc_dir``
618
+ 提供时校验;consume_stale_result 路径(无 in-flight)可传 ipc_dir
619
+ 完成同样校验。
620
+ """
621
+ success = False
622
+ result_meta: Dict[str, Any] = {}
623
+ retry_requested = False
624
+ retry_error: Optional[str] = None
625
+ new_jobs: List[Job] = []
626
+ cursor_updates: Dict[str, str] = {}
627
+ resource_suspensions: List[Tuple[str, float]] = []
628
+ lock_conflict = False
629
+ interrupted = False
630
+
631
+ if isinstance(res, dict) and "status" in res:
632
+ if res["status"] == "success":
633
+ # schema 防御——read_result_file 只保证「能解析成 JSON dict」,
634
+ # 不保证键完整。缺 raw_result 的损坏/外来文件若直接 res["raw_result"]
635
+ # 会抛 KeyError 崩掉整个 run(违背「宁可重跑,不可崩」契约)。
636
+ if "raw_result" not in res:
637
+ logger.error(f"Corrupt result file for {job.uid}: missing 'raw_result' key")
638
+ result_meta = {"error": "CORRUPT_RESULT_FILE: missing raw_result"}
639
+ else:
640
+ # 解码/归一化段的任何意外异常(哨兵碰撞、
641
+ # 损坏文件的边角形状)不得穿透 drain 崩掉整个 run——
642
+ # 兑现本模块「宁可重跑,不可崩」契约,统一转失败结果。
643
+ try:
644
+ raw_result = _decode_raw_result(res["raw_result"])
645
+ success, result_meta = _normalize_handler_result(raw_result)
646
+ except Exception as e: # noqa: BLE001——防御矩阵最后一级
647
+ success = False
648
+ result_meta = {
649
+ "error": f"CORRUPT_RESULT_FILE: decode failed: {e}",
650
+ _KEY_TRACEBACK: traceback.format_exc(),
651
+ }
652
+ logger.error(f"Result decode failed for {job.uid}: {e}")
653
+ # 单个坏子任务 dict(如 handler 构造 Job 后篡改 resources)
654
+ # 不应使整个 run 崩溃——逐个防御,坏条目记为失败而非上抛。
655
+ new_jobs = []
656
+ for jd in res.get("new_jobs") or []:
657
+ try:
658
+ new_jobs.append(Job.from_dict(jd))
659
+ except (KeyError, TypeError, ValueError) as e:
660
+ success = False
661
+ result_meta = {
662
+ "error": f"invalid spawned job dict: {e}",
663
+ _KEY_TRACEBACK: traceback.format_exc(),
664
+ }
665
+ logger.error(f"Malformed spawned job in {job.uid}: {e}")
666
+ break
667
+ if success:
668
+ # cursor_updates/resource_suspensions 类型校验——损坏文件
669
+ # 中它们可能是 None/list 等错误形状,下游 _apply_result 迭代时
670
+ # AttributeError/TypeError 崩 run。
671
+ _cu = res.get("cursor_updates", {})
672
+ _rs = res.get("resource_suspensions", [])
673
+ if isinstance(_cu, dict):
674
+ # 仅校验顶层 dict 不够——值类型未校验时,损坏/敌意
675
+ # 文件可注入非 str 值(如 int),state.cursors 与磁盘
676
+ # 值类型不一致(内存 int、磁盘 str),且 discovery 的
677
+ # _decode_seen_set 遇非 str 走 json.loads(int) → TypeError
678
+ # → 退化空集合 → 整段重扫。逐值校验:非 str/None 记为失败。
679
+ valid_cu = {}
680
+ cu_ok = True
681
+ for k, v in _cu.items():
682
+ if isinstance(k, str) and (v is None or isinstance(v, str)):
683
+ valid_cu[k] = v
684
+ else:
685
+ success = False
686
+ result_meta = {
687
+ "error": f"invalid cursor_updates value: {k!r}={v!r}",
688
+ }
689
+ cu_ok = False
690
+ break
691
+ if cu_ok:
692
+ cursor_updates = valid_cu
693
+ else:
694
+ success = False
695
+ result_meta = {"error": f"invalid cursor_updates type: {type(_cu).__name__}"}
696
+ if isinstance(_rs, list):
697
+ # 只校验顶层 list 不够——元素形状未校验时,
698
+ # pipeline._apply_result 的 ``for r_name, secs in ...``
699
+ # 解包崩溃(元素为 int/str/单元素 list 时抛
700
+ # ValueError/TypeError,崩掉整个 run)。逐元素防御:
701
+ # 非法形状 → 记为失败而非上抛(宁可 DLQ,不可崩 run)。
702
+ valid_rs = []
703
+ for item in _rs:
704
+ if (isinstance(item, (list, tuple)) and len(item) == 2
705
+ and isinstance(item[0], str)
706
+ and isinstance(item[1], (int, float))
707
+ and not isinstance(item[1], bool)):
708
+ valid_rs.append((item[0], float(item[1])))
709
+ else:
710
+ success = False
711
+ result_meta = {
712
+ "error": f"invalid resource_suspension entry: {item!r}",
713
+ }
714
+ break
715
+ if valid_rs:
716
+ resource_suspensions = valid_rs
717
+ else:
718
+ success = False
719
+ result_meta = {"error": f"invalid resource_suspensions type: {type(_rs).__name__}"}
720
+ elif res["status"] == "retry":
721
+ retry_requested = True
722
+ retry_error = res.get("error") # 提取 RetryError message
723
+ # 框架内部锁冲突(LOCK_CONFLICT——孤儿持锁导致
724
+ # 本 worker 拿锁超时)是**结构化信号**而非业务错误:
725
+ # worker 写端写 `lock_conflict` 字段、判定端只读该字段,
726
+ # 业务 RetryError 消息恰好以 "LOCK_CONFLICT"
727
+ # 开头时不再被误判为零计数重试(会走正常计数退避 + 覆盖
728
+ # _last_retry_error)。
729
+ lock_conflict = bool(res.get("lock_conflict", False))
730
+ elif res["status"] == "interrupted":
731
+ # worker 被 Ctrl+C/SIGINT/SIGTERM 中断——不是
732
+ # 业务失败。retry_requested + 结构化 interrupted 标记:消费端
733
+ # (completion)零计数短退避回队(不烧 max_retries 预算、不进
734
+ # DLQ、不级联),兑现 README「仅进行中 job requeue」承诺。
735
+ retry_requested = True
736
+ retry_error = res.get("error")
737
+ interrupted = True
738
+ elif res["status"] == "fatal":
739
+ success = False
740
+ result_meta = {
741
+ "error": res.get("error", "FATAL (no message)"),
742
+ _KEY_TRACEBACK: res.get(_KEY_TRACEBACK),
743
+ "fatal": True
744
+ }
745
+ logger.error(f"Fatal error in {job.uid}:\n{res.get(_KEY_TRACEBACK, '')}")
746
+ else:
747
+ success = False
748
+ # 未知 status 时 res["error"] 可能缺失,用 .get 防御。
749
+ result_meta = {
750
+ "error": res.get("error", f"unknown status {res['status']!r}"),
751
+ _KEY_TRACEBACK: res.get(_KEY_TRACEBACK),
752
+ }
753
+ logger.error(f"Worker Crashed for {job.uid}:\n{res.get(_KEY_TRACEBACK, '')}")
754
+ elif p.exitcode is not None and p.exitcode != 0:
755
+ success = False
756
+ result_meta = {"error": f"PROCESS_CRASH_EXITCODE_{p.exitcode}"}
757
+ else:
758
+ result_meta = {"error": "NO_IPC_RESULT"}
759
+
760
+ if success and ipc_dir is not None:
761
+ # 输出存在性校验——读落盘 outputs.jsonl(handler 声明的输出),
762
+ # 无 mp.Manager 单点与 RPC 开销。
763
+ # kind=="cache" 的临时文件**跳过存在性校验**
764
+ # (原子产出的 .part 已被 os.replace 到最终路径,校验必然失败)。
765
+ for out_path, _, kind in read_outputs(ipc_dir, job.uid):
766
+ if kind == "cache":
767
+ continue
768
+ if not Path(out_path).exists():
769
+ logger.error(f"Verification failed for {job.uid}: Missing output -> {out_path}")
770
+ success = False
771
+ result_meta = {"error": f"Missing output {out_path}"}
772
+ break
773
+
774
+ return ExecutionResult(
775
+ success=success,
776
+ result_meta=result_meta,
777
+ retry_requested=retry_requested,
778
+ retry_error=retry_error,
779
+ new_jobs=new_jobs,
780
+ cursor_updates=cursor_updates,
781
+ resource_suspensions=resource_suspensions,
782
+ lock_conflict=lock_conflict,
783
+ interrupted=interrupted,
784
+ )
785
+
786
+
787
+ @dataclass
788
+ class ExecutionResult:
789
+ """Outcome of a single multiprocessing job execution."""
790
+ success: bool = False
791
+ result_meta: Dict[str, Any] = field(default_factory=dict)
792
+ retry_requested: bool = False
793
+ retry_error: Optional[str] = None # RetryError message from last retry
794
+ new_jobs: List[Job] = field(default_factory=list)
795
+ cursor_updates: Dict[str, str] = field(default_factory=dict)
796
+ resource_suspensions: List[Tuple[str, float]] = field(default_factory=list)
797
+ # 框架内部锁冲突结构化标记(worker 拿锁超时)——worker
798
+ # 写端写 `lock_conflict` 字段、判定端(_decode_ipc_result)读该字段、
799
+ # _apply_result 消费该字段,全链路不依赖 retry_error 字符串前缀(业务
800
+ # RetryError 消息撞 "LOCK_CONFLICT" 前缀不再被误判为零计数重试)。
801
+ lock_conflict: bool = False
802
+ # worker 被中断(Ctrl+C/SIGINT/SIGTERM 前台进程组信号)
803
+ # 的结构化标记——消费端零计数短退避回队,不烧 max_retries 预算、不进
804
+ # DLQ、不级联下游(README「仅进行中 job requeue」承诺)。
805
+ interrupted: bool = False
806
+ # 本次失败是否将走重试(而非进 DLQ)。由 _apply_result 在决定去向
807
+ # 后填充——钩子据此区分「可恢复的瞬态失败」与「终局失败(DLQ)」。
808
+ # 默认 None 表示调用方未显式设置(_apply_result 总是显式赋值)。
809
+ going_to_retry: Optional[bool] = None
810
+
811
+
812
+ @dataclass
813
+ class JobHandle:
814
+ """一个 in-flight 子进程的句柄。
815
+
816
+ 由 ``submit()`` 创建,传给 ``drain()`` 轮询,完成后从 in-flight 集合移除。
817
+ ``deadline`` 基于 ``time.monotonic()``,用于超时判定。
818
+ ``result_file`` / ``signals_file`` 是落盘 IPC 路径——
819
+ 结果文件存在 = 子进程已写入完成(原子 rename),信号文件追加 suspend。
820
+ ``incarnation``:本次执行代的唯一标识 ``{run_id}.{seq}``,
821
+ drain 只轮询 ``{uid}.{incarnation}.result.json``——崩溃后遗留的孤儿
822
+ 进程用旧 incarnation 写文件,本 handle 永远看不见它。
823
+ """
824
+ uid: str
825
+ process: Any # mp.Process
826
+ deadline: float # time.monotonic() + timeout
827
+ timeout: float # 原始 timeout 值,用于构造错误信息
828
+ job: Job
829
+ ipc_dir: str # 结果/信号文件所在目录
830
+ incarnation: Optional[str] = None # fencing 执行代标识
831
+
832
+
833
+ class MultiprocessingExecutor:
834
+ """Runs handlers in isolated subprocesses (non-blocking, concurrent).
835
+
836
+ 结果经文件落盘(``{uid}.{incarnation}.result.json`` 原子
837
+ rename),``drain()`` 只 ``os.path.exists`` 轮询——无 mp.Queue 的伪阻塞点。
838
+ """
839
+
840
+ def __init__(self, mp_ctx: Any = None, ipc_dir: Optional[str] = None):
841
+ self._mp_ctx = mp_ctx or mp
842
+ self.ipc_dir = ipc_dir
843
+
844
+ # 单个 handle 的资源清理 ------------------------------------------
845
+
846
+ # join 收割超时(秒):D-state(不可中断睡眠,如 NFS/网盘挂起 IO)
847
+ # 进程无法被 SIGKILL 终止,join 会无限阻塞主循环。
848
+ # 超时后放弃收割:进程留作僵尸由 OS 收养,主循环不被拖死。
849
+ _JOIN_REAP_TIMEOUT: float = 5.0
850
+
851
+ @staticmethod
852
+ def _finalize_process(p) -> None:
853
+ """清理单个子进程:kill + join + close,释放资源。
854
+
855
+ kill 与 join 分属独立 try/except——若进程在
856
+ ``is_alive()`` 与 ``kill()`` 之间自然退出,kill 抛
857
+ ``ProcessLookupError`` 时不得跳过整个清理块(否则留下僵尸
858
+ 进程);无论 kill 是否成功都必须 ``join()`` 收割。
859
+
860
+ 收割超时保护:join 用 ``_JOIN_REAP_TIMEOUT`` 加界——D-state 进程
861
+ (不可中断睡眠,NFS/网盘输出目录挂起 IO 的常见状态)SIGKILL
862
+ 无效,``p.join()`` 会无限阻塞主循环(看门狗承诺击穿)。超时后
863
+ 放弃收割(进程由 init 收养),不阻塞。
864
+ """
865
+ try:
866
+ if p.is_alive():
867
+ p.kill()
868
+ except Exception:
869
+ pass
870
+ try:
871
+ p.join(timeout=MultiprocessingExecutor._JOIN_REAP_TIMEOUT)
872
+ except Exception:
873
+ pass
874
+ try:
875
+ _p_close = getattr(p, 'close', None)
876
+ if _p_close is not None:
877
+ _p_close()
878
+ except Exception:
879
+ pass
880
+
881
+ # 异步派发 --------------------------------------------------------
882
+
883
+ def submit(self, handler_func: Callable, job: "Job", ctx: "TaskContext",
884
+ timeout: float, ipc_dir: Optional[str] = None) -> JobHandle:
885
+ """启动子进程执行 handler,立即返回 JobHandle(不阻塞)。
886
+
887
+ ``_mp_worker_wrapper`` 在子进程中把结果原子写盘(文件名带
888
+ ctx.incarnation),``drain()`` 负责轮询回收。
889
+ """
890
+ ipc_dir = ipc_dir or self.ipc_dir or os.environ.get(_RESULT_DIR_ENV)
891
+ if not ipc_dir:
892
+ raise ValueError("ipc_dir is required for executor.submit()")
893
+ Path(ipc_dir).mkdir(parents=True, exist_ok=True)
894
+ ctx.ipc_dir = ipc_dir # 供 suspend_resource 追加信号文件
895
+ incarnation = getattr(ctx, "incarnation", None)
896
+ p = None
897
+ try:
898
+ p = self._mp_ctx.Process(
899
+ target=_mp_worker_wrapper, args=(handler_func, job, ctx, ipc_dir)
900
+ )
901
+ p.start()
902
+ except Exception:
903
+ # start 抛异常时清理已创建的资源
904
+ if p is not None:
905
+ self._finalize_process(p)
906
+ raise
907
+ deadline = time.monotonic() + timeout
908
+ return JobHandle(
909
+ uid=job.uid,
910
+ process=p,
911
+ deadline=deadline,
912
+ timeout=timeout,
913
+ job=job,
914
+ ipc_dir=ipc_dir,
915
+ incarnation=incarnation,
916
+ )
917
+
918
+ # 非阻塞收集 ------------------------------------------------------
919
+
920
+ def reap_completed(
921
+ self, handles: List[JobHandle]
922
+ ) -> List[Tuple[JobHandle, ExecutionResult]]:
923
+ """非阻塞扫描所有 in-flight handle,返回本次已完成的 (handle, result)。
924
+
925
+ 对每个 handle(只轮询结果文件,无管道阻塞):
926
+ 1. 结果文件存在 → 读取解析 → 清理 → 完成。
927
+ 2. 文件不存在 → 检查进程:
928
+ - 进程已死 → 构造 CRASH/NO_IPC 结果(超时 kill 前曾尝试读文件)。
929
+ - 进程存活且超时 → kill+join,构造 TIMEOUT。
930
+ - 进程存活且未超时 → 跳过(仍在运行)。
931
+
932
+ 未完成的 handle 不在返回列表中,调用方继续持有。
933
+ """
934
+ completed: List[Tuple[JobHandle, ExecutionResult]] = []
935
+
936
+ for handle in handles:
937
+ now = time.monotonic()
938
+ p = handle.process
939
+ # 只轮询**当前 incarnation** 的结果文件路径——
940
+ # 崩溃后遗留的孤儿进程写的是旧 incarnation 文件名,本 handle
941
+ # 永不看见(防止「孤儿结果被误认为新子进程结果而 kill 新子进程」)。
942
+ res_path = result_path(handle.ipc_dir, handle.uid, handle.incarnation)
943
+
944
+ # 1. 尝试读完整结果文件(存在即完成——原子 rename 保证完整)
945
+ res = read_result_file(res_path) if res_path.exists() else None
946
+
947
+ if res is not None:
948
+ completed.append((handle, self._collect_outcome(handle, res)))
949
+ continue
950
+
951
+ # 2. 进程已死(无结果文件)——is_alive 内部经 waitpid(WNOHANG)
952
+ # 已收割已退出进程并设置 returncode,无需先 join:join 对活进程
953
+ # 会真实阻塞满 timeout(100 个 in-flight 时每轮 drain 纯耗
954
+ # 100ms),对已退出进程则收割已由 is_alive 完成、纯冗余。
955
+ if not p.is_alive():
956
+ # exists 检查与进程退出之间存在 TOCTOU——结果文件可能在
957
+ # is_alive 判定前刚被原子 rename(handler 成功但进程尚未
958
+ # 退出)。与超时路径(kill 后重读)对称,死亡分支同样重读
959
+ # 一次,避免成功 job 被误判 NO_IPC_RESULT 而永久 DLQ +
960
+ # 输出被删。
961
+ res = read_result_file(res_path) if res_path.exists() else None
962
+ completed.append((handle, self._collect_outcome(handle, res)))
963
+ continue
964
+
965
+ # 3. 进程存活,检查是否超时
966
+ if now > handle.deadline:
967
+ # 先给短暂优雅退出窗口(0.5s),随后 kill——主循环不被
968
+ # 阻塞式 join 拖慢。
969
+ p.join(timeout=0.5)
970
+ if p.is_alive():
971
+ # is_alive 与 kill 之间进程可能自然退出,裸 kill 抛
972
+ # ProcessLookupError 会崩主循环——与 _finalize_process 的
973
+ # 防护同款,此处 try/except 兜底。
974
+ try:
975
+ p.kill()
976
+ except Exception:
977
+ pass
978
+ # kill 后**不再**在此 join——收割统一
979
+ # 由 _collect_outcome 的 finally(_finalize_process)执行:
980
+ # D-state 进程 SIGKILL 无效,重复 join 最坏让主循环阻塞
981
+ # 2×_JOIN_REAP_TIMEOUT(10s),违背「挂起 job 不阻塞其他
982
+ # job」承诺。kill 后立即重读结果文件是安全的(原子 rename
983
+ # 保证 final 文件要么全有要么全无,无部分写入)。
984
+ is_timeout = True
985
+ else:
986
+ # 进程在 0.5s 优雅窗口内**自行**退出(非被 kill)——
987
+ # 先查 exitcode:非 0 表示崩溃(PROCESS_CRASH 分类),
988
+ # timeout_is_transient 只作用于真超时(被 kill 的挂起
989
+ # 进程),自崩进程是确定性错误,重试无益。
990
+ is_timeout = (p.exitcode == 0 or p.exitcode is None)
991
+ # kill 后结果文件可能刚写入(handler 在超时前完成)→ 再读一次
992
+ res = read_result_file(res_path) if res_path.exists() else None
993
+ completed.append(
994
+ (handle, self._collect_outcome(handle, res, is_timeout=is_timeout))
995
+ )
996
+ # else: 进程存活且未超时 → 跳过
997
+
998
+ return completed
999
+
1000
+ def _collect_outcome(
1001
+ self, handle: JobHandle, res: Optional[dict], *, is_timeout: bool = False
1002
+ ) -> ExecutionResult:
1003
+ """收敛 drain 三段同构尾部:解析结果 → 收割进程 → 清理 IPC 文件。
1004
+
1005
+ 正常完成/死亡重读/超时 kill 后重读三段的差异只有 res 从哪来与
1006
+ 失败结果怎么构造(is_timeout 标记)——统一在此完成。重读的
1007
+ 时序契约(TOCTOU/kill 后重读)由调用方 drain 在传参前保证,
1008
+ 本方法只负责「拿到 res 后的收敛尾部」。
1009
+
1010
+ 资源防泄漏:解析/构造失败结果放 try,收割与清理放
1011
+ finally——_decode_ipc_result/_build_terminal_failure 抛未预期
1012
+ 异常时(如损坏结果文件触发未防御的异常类型),进程与 IPC 文件
1013
+ 仍被清理,异常穿透 drain 后也不会泄漏子进程(宁可重跑,不可崩)。
1014
+
1015
+ 信号防丢失:清理前消费信号文件——handler 写入 suspend 信号后
1016
+ job 超时/立即崩溃时,该 entry 即刻离开 in-flight,主循环的
1017
+ apply_pending_signals 永远轮不到它,而下方 cleanup_ipc_files 会删除
1018
+ 信号文件——限流信息静默丢失。清理前读取并把挂起折叠进
1019
+ ``ExecutionResult.resource_suspensions``,由 completion.apply_result
1020
+ 统一应用并即时持久化(幂等 max 语义,重复应用无害)。
1021
+ """
1022
+ p = handle.process
1023
+ result: Optional[ExecutionResult] = None
1024
+ try:
1025
+ if res is not None:
1026
+ result = _decode_ipc_result(res, p, handle.job, handle.ipc_dir)
1027
+ else:
1028
+ result = self._build_terminal_failure(p, handle, is_timeout=is_timeout)
1029
+ finally:
1030
+ self._finalize_process(p)
1031
+ # 清理前最后一读(try 块抛异常时 result 为 None,仅告警不阻断清理)
1032
+ try:
1033
+ pending_signals = read_signals(handle.ipc_dir, handle.uid)
1034
+ if pending_signals:
1035
+ if result is not None:
1036
+ result.resource_suspensions = (
1037
+ list(result.resource_suspensions) + list(pending_signals))
1038
+ logger.info(
1039
+ f"Salvaged {len(pending_signals)} suspend signal(s) "
1040
+ f"from {handle.uid} before IPC cleanup"
1041
+ )
1042
+ else:
1043
+ logger.warning(
1044
+ f"Suspend signals on {handle.uid} discarded: "
1045
+ f"outcome construction failed"
1046
+ )
1047
+ except Exception as e:
1048
+ logger.warning(f"Failed to salvage signals for {handle.uid}: {e}")
1049
+ cleanup_ipc_files(handle.ipc_dir, handle.uid, handle.incarnation)
1050
+ return result
1051
+
1052
+ def consume_stale_result(self, uid: str, job: Job) -> Optional[ExecutionResult]:
1053
+ """崩溃恢复:检查 ipc_dir 中该 uid 的残留结果文件并消费。
1054
+
1055
+ 上次 run 主进程 SIGKILL/OOM/断电 崩溃时,子进程可能已写好结果文件但
1056
+ 主进程未及 commit。本方法在**派发新子进程之前**由 ``_dispatch_job``
1057
+ 调用,把这类残留结果直接解析出来(消费),从而:
1058
+ - 避免「新子进程已启动、却被旧结果文件误判完成而 kill」的双重执行窗口;
1059
+ - 不浪费已完成的执行(at-least-once 语义下可省一次无谓重跑)。
1060
+
1061
+ 与 ``drain()`` 的区别:drain 按 JobHandle 轮询 in-flight 进程的结果
1062
+ 文件(当前 incarnation 路径);本方法**不依赖任何进程句柄**,按 uid
1063
+ 枚举任意 incarnation 的残留文件(旧 run 的子进程
1064
+ 可能在崩溃后以孤儿身份继续运行并写入其旧 incarnation 路径——若文件
1065
+ 已存在则消费它,若孤儿仍在运行(文件未写)则返回 None 照常派发)。
1066
+
1067
+ Returns:
1068
+ ExecutionResult: 解析后的残留结果;若无残留文件(或文件损坏/
1069
+ 非结果格式)返回 ``None``,调用方照常派发新子进程。
1070
+ """
1071
+ res_paths = _iter_stale_result_paths(self.ipc_dir, uid)
1072
+ # 只消费 final 结果文件(.result.json)——.tmp 变体
1073
+ # 表示孤儿 worker **仍在写**(dump/fsync 未完成)。消费 .tmp 会读到
1074
+ # 部分 JSON(read_result_file 返回 None)后 unlink 正在写的文件 →
1075
+ # 孤儿 os.replace(tmp, final) 抛 FileNotFoundError → 写 error 结果 →
1076
+ # 下次派发消费 error → **成功执行的 job 被虚假 DLQ**。.tmp 的存在
1077
+ # 意味着孤儿活着持锁,交给 probe_lock 的 defer 路径处理(孤儿死后
1078
+ # 自愈,rename 完成后再消费 final)。
1079
+ res_paths = [p for p in res_paths if p.name.endswith(_RESULT_SUFFIX)]
1080
+ if not res_paths:
1081
+ return None
1082
+ # 消费即删:防止下次 run 重复提交同一残留结果(任意变体都清)。
1083
+ # 多个变体并存时(多次崩溃遗留)取最新的——孤儿是最新执行体。
1084
+ # 决胜键 (st_mtime_ns, incarnation_seq):ns 精度 + 单调递增序号
1085
+ # 保证同刻冲突时取最新执行代(秒级 mtime 在同秒内多个变体时
1086
+ # 排序不稳定,可能消费**较旧执行代**的结果)。
1087
+ def _freshness_key(p):
1088
+ st = p.stat()
1089
+ try:
1090
+ seq = int(p.name.removesuffix(_RESULT_SUFFIX).rsplit(".", 1)[-1])
1091
+ except (ValueError, IndexError):
1092
+ seq = -1
1093
+ return (st.st_mtime_ns, seq)
1094
+
1095
+ res_path = max(res_paths, key=_freshness_key)
1096
+ res = read_result_file(res_path)
1097
+ for p in res_paths:
1098
+ try:
1099
+ p.unlink()
1100
+ except OSError:
1101
+ pass
1102
+ # 损坏/非标准结果(无 status 键)视为无残留:宁可重跑,不可误判。
1103
+ if not isinstance(res, dict) or "status" not in res:
1104
+ logger.warning(
1105
+ f"Discarding stale result file for {uid}: not a valid result dict: {res!r}"
1106
+ )
1107
+ return None
1108
+ return _decode_ipc_result(res, None, job, self.ipc_dir)
1109
+
1110
+ @staticmethod
1111
+ def _build_terminal_failure(
1112
+ p, handle: JobHandle, *, is_timeout: bool
1113
+ ) -> ExecutionResult:
1114
+ """构造进程终止(崩溃/超时)但无结果文件时的 ExecutionResult。"""
1115
+ exitcode = p.exitcode
1116
+ retry_requested = False
1117
+ retry_error: Optional[str] = None
1118
+ if is_timeout:
1119
+ if getattr(handle.job, "timeout_is_transient", False):
1120
+ # 该 job 声明超时是瞬态(如可能长跑的 discovery 扫描)
1121
+ # 「没跑完」不等于「确定性失败」,按 retry 处理:
1122
+ # 指数退避重试,达 max_retries 才 DLQ(避免超时即判死)。
1123
+ return ExecutionResult(
1124
+ success=False,
1125
+ retry_requested=True,
1126
+ retry_error=f"TIMEOUT ({handle.timeout}s)",
1127
+ )
1128
+ # 超时:优先用 TIMEOUT 错误(即使 exitcode 非零,也是被 kill 导致)
1129
+ result_meta = {"error": f"TIMEOUT ({handle.timeout}s)"}
1130
+ elif exitcode is not None and exitcode < 0:
1131
+ # 非超时路径的负 exitcode = 进程死于信号
1132
+ # (-9=SIGKILL——OS OOM killer 与外部 kill 共用、-11=SIGSEGV
1133
+ # 等)。环境性瞬态故障(内存压力高峰过去即可成功)默认走正常
1134
+ # 退避重试(消耗 max_retries 预算,达上限才 DLQ),meta 富化
1135
+ # 结构化信号名;与既有 timeout_is_transient 开关对称。
1136
+ try:
1137
+ sig_name = signal.Signals(-exitcode).name
1138
+ except (ValueError, AttributeError):
1139
+ sig_name = f"SIGNO{-exitcode}"
1140
+ result_meta = {
1141
+ "error": f"PROCESS_CRASH_EXITCODE_{exitcode}",
1142
+ "signal": sig_name,
1143
+ "oom_hint": -exitcode == int(signal.SIGKILL),
1144
+ }
1145
+ retry_requested = True
1146
+ retry_error = f"PROCESS_SIGNAL_DEATH: killed by {sig_name} ({exitcode})"
1147
+ logger.warning(
1148
+ f"Worker for {handle.uid} died by {sig_name}; will retry with backoff"
1149
+ )
1150
+ elif exitcode is not None and exitcode != 0:
1151
+ result_meta = {"error": f"PROCESS_CRASH_EXITCODE_{exitcode}"}
1152
+ else:
1153
+ result_meta = {"error": "NO_IPC_RESULT"}
1154
+
1155
+ return ExecutionResult(
1156
+ success=False,
1157
+ result_meta=result_meta,
1158
+ retry_requested=retry_requested,
1159
+ retry_error=retry_error,
1160
+ )
1161
+
1162
+ # 异常退出时的强制清理 --------------------------------------------
1163
+
1164
+ def cleanup(self, handles: List[JobHandle]) -> None:
1165
+ """主循环异常退出时 kill + join 所有残留 in-flight 子进程。
1166
+
1167
+ 用于 KeyboardInterrupt / RuntimeError 等场景,释放资源避免泄漏。
1168
+ 不构造 ExecutionResult(这些 job 由调用方 requeue 回队列)。
1169
+ """
1170
+ for handle in handles:
1171
+ try:
1172
+ self._finalize_process(handle.process)
1173
+ cleanup_ipc_files(handle.ipc_dir, handle.uid)
1174
+ except Exception as e:
1175
+ logger.error(f"Error cleaning up in-flight job {handle.uid}: {e}")
1176
+
1177
+ def finalize_processes(self, handles: List[JobHandle]) -> None:
1178
+ """只 kill + join 残留子进程,**不删 IPC 文件**。
1179
+
1180
+ ``cleanup`` 的「kill + cleanup_ipc_files 一体」在 abort 消费结果
1181
+ 路径存在 TOCTOU 窗口:``_abort_in_flight`` 先分类(读结果
1182
+ 文件判 done/pending),随后 kill——分类读到无结果(归 pending)与
1183
+ kill 之间 worker 可能恰好完成 ``write_result_atomic``(结果文件
1184
+ 此刻才出现)。若 kill 后立即 ``cleanup_ipc_files`` 删结果文件,
1185
+ 刚写好的成功结果被清掉 → 成功 job 被误判未完成 → requeue →
1186
+ 重启必重跑,非幂等副作用被重复执行。
1187
+
1188
+ 调用方时序契约(先 kill 再清理):本方法先只做进程收割
1189
+ (kill + join + close,``_finalize_process`` 内部逐段 try/except
1190
+ 防护),调用方随后**重查**结果文件(把 kill 后新出现的 done 移出
1191
+ pending),再对剩余 pending 执行 ``cleanup_ipc_files`` ——此时
1192
+ kill/join 已停止写入,不再有新结果出现,无竞态窗口。
1193
+
1194
+ 与 ``cleanup`` 的分工:``cleanup``(kill+清理一体)用于不消费
1195
+ 结果的路径(如 ``_dispatch_job`` 的失败分支);本方法用于 abort
1196
+ 消费路径(先 kill、重查结果、再清理)。
1197
+ """
1198
+ for handle in handles:
1199
+ try:
1200
+ self._finalize_process(handle.process)
1201
+ except Exception as e:
1202
+ logger.error(f"Error finalizing in-flight job {handle.uid}: {e}")