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,426 @@
1
+ """派发机器:派发预检与 submit 编排。
2
+
3
+ 五关顺序即契约(顺序即时序约束):dedup → dep-failed → no-handler →
4
+ orphan-probe → stale-restore。依赖经 RunContext(``self._ctx``)注入,
5
+ 经 ``self._failure``/``self._completion`` 复用失败机器与完成机器,
6
+ 不反向引用 TaskLite。
7
+ """
8
+
9
+ import logging
10
+ import random
11
+ import time
12
+ import traceback
13
+ from typing import List, Optional, Tuple, TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from .runtime import RunContext
17
+ from .failure import FailureMachine
18
+ from .completion import CompletionMachine
19
+
20
+ from ..error_codes import (
21
+ ERR_DISPATCH_FAILURE as _ERR_DISPATCH_FAILURE,
22
+ ERR_JOB_DEPENDENCY as _ERR_JOB_DEPENDENCY,
23
+ ERR_NO_HANDLER as _ERR_NO_HANDLER,
24
+ ERR_PAYLOAD_VALIDATION as _ERR_PAYLOAD_VALIDATION,
25
+ )
26
+ from ..exceptions import _CommitCrashSignal, _JobTerminated
27
+ from ..models.context import TaskContext
28
+ from ..models.job import Job
29
+ from .runtime import RT_BACKOFF_UNTIL, RT_BACKOFF_WALL_DEADLINE
30
+ from .executor import JobHandle
31
+ from .inflight import InFlightJob
32
+ from .retry import rerun_skips
33
+ from ..utils.ipc import inputs_path, outputs_path, signals_path
34
+ from ..utils.lockfile import probe_lock
35
+ from ..utils.validation import validate_payload
36
+
37
+ logger = logging.getLogger("tasklite")
38
+
39
+
40
+ class DispatchMachine:
41
+ """派发预检 + 资源 acquire + 子进程 submit 的编排器。"""
42
+
43
+ def __init__(
44
+ self, ctx: "RunContext", failure: "FailureMachine", completion: "CompletionMachine"
45
+ ) -> None:
46
+ self._ctx = ctx
47
+ self._failure = failure
48
+ self._completion = completion
49
+
50
+ def _reject_and_commit(
51
+ self, uid: str, job_dict: dict, meta: dict, *,
52
+ count_as: str = "failed",
53
+ ) -> None:
54
+ """拒绝 job 的统一出口:commit 失败 → 三连收敛 → 级联 → 钩子。
55
+
56
+ 三处拒绝路径(依赖失败 / no-handler / payload 校验失败)共用本方法,
57
+ 消除重复的 commit + apply_failed + cascade_fail + fire_hook 样板。
58
+
59
+ 正常返回 = 处理完成(commit 成功或 3-strike DLQ 成功);
60
+ _CommitCrashSignal 穿透上抛 = 后端环境故障(由 run_loop 崩溃处理)。
61
+ """
62
+ committed = self._ctx.backend.commit_job_failure(uid, meta)
63
+ if committed:
64
+ self._failure.apply_failed(uid, meta, count_as=count_as)
65
+ self._failure.cascade_fail(uid)
66
+ self._ctx.fire_job_completed(uid, meta, False, False)
67
+ return
68
+ # commit 失败 → 3-strike / 崩溃路径。commit_failed_crash 要么抛
69
+ # _JobTerminated(3-strike DLQ 成功,job 已终结),要么抛
70
+ # _CommitCrashSignal(后端环境故障,需崩溃重启)。
71
+ try:
72
+ self._failure.commit_failed_crash(
73
+ uid, meta.get("error", "unknown"), job_dict,
74
+ )
75
+ except _JobTerminated:
76
+ # 3-strike DLQ 成功——job 已终结,正常返回即可
77
+ return
78
+ # _CommitCrashSignal 继承 BaseException,不被上方 except 捕获,
79
+ # 自动穿透上抛到 run_loop 的崩溃处理分支。
80
+
81
+ def dispatch_dedup(self, state, uid: str, job_dict: dict) -> bool:
82
+ """派发预检关 1——去重(is_known 命中 → rerun 策略 → skip/放行)。
83
+
84
+ 返回 True = 已处理(job 被 skip 或放行后本关终结);返回 False
85
+ 表示未命中(调用方继续后续预检关)。统一 is_known 谓词;
86
+ rerun 策略豁免 every_run/on_failure 的 wall/failed 命中。
87
+ """
88
+ # Dedup check (job already completed or failed since queue was loaded)
89
+ # 统一 is_known 谓词。pop 自 queue 的 uid
90
+ # 不可能在 queue/in-flight(已出队)。命中时对后端做 commit_skip——
91
+ # 否则磁盘条目残留,每次 run 重复 pop→skip→drift。
92
+ # rerun 策略豁免——every_run/on_failure 任务命中
93
+ # wall/failed 时**放行重跑**(不 skip;磁盘残留由后续 commit 清理)。
94
+ if state.is_known(uid):
95
+ wall_hit = uid in state.wall
96
+ failed_hit = uid in state.failed
97
+ if rerun_skips(
98
+ job_dict, wall_hit=wall_hit, failed_hit=failed_hit,
99
+ wall_meta=state.wall.get(uid),
100
+ ):
101
+ self._ctx.stats["skipped"] += 1
102
+ committed = self._ctx.backend.commit_skip(uid)
103
+ if not committed:
104
+ # commit_skip 终态模型:skip 命中 = uid 已有
105
+ # wall/failed 终态,此处只是清理磁盘残留。commit_skip 失败
106
+ # 是环境故障——走「requeue + 崩溃」契约,**绝不走 3-strike
107
+ # DLQ**(那会把 wall 成功记录翻转成失败)。下一次 run 的
108
+ # 加载期过滤或 commit_skip 重试消化残留。
109
+ self._failure.commit_skip_crash(uid, job_dict)
110
+ # 与 dep-failed/no-handler 分支同款:
111
+ # _commit_skip_crash 契约上永不正常返回;fall-through 仅当
112
+ # 契约被破坏时可达——fail-loud 优于静默「装作已处理」。
113
+ raise AssertionError(
114
+ "_commit_skip_crash unexpectedly returned normally"
115
+ )
116
+ else:
117
+ # pop_job 对 rerun 任务把 uid 加入
118
+ # _rerun_active_uids(豁免集合),skip 成功路径配对
119
+ # discard——否则豁免集合泄漏并永久弱化 DEBUG 互斥断言。
120
+ # 与 dep-failure/no-handler 直接 commit 路径的 discard
121
+ # 对称;对未注册 uid 是 no-op。
122
+ state.unregister_in_flight(uid)
123
+ return True
124
+ return False
125
+
126
+
127
+ def dispatch_dep_failed(self, uid: str, job_dict: dict, pending_dep_failure: str) -> bool:
128
+ """派发预检关 2——依赖失败(父任务已进 DLQ → 本任务 JOB_DEPENDENCY)。
129
+
130
+ 返回 True = 已处理(依赖失败直接 commit,含级联下游 + 钩子);
131
+ 仅当 pending_dep_failure 非空时调用。commit 失败走 3-strike / 崩溃路径。
132
+ """
133
+ if pending_dep_failure is not None:
134
+ logger.warning(f"SKIP: {uid} (Dependency {pending_dep_failure} failed)")
135
+ fail_meta = {"error": _ERR_JOB_DEPENDENCY,
136
+ "failed_dependency": pending_dep_failure}
137
+ # 依赖父失败的级联下游计入 cascade_failed 而非 failed,
138
+ # 保证「真实业务失败率」统计不被级联稀释。
139
+ self._reject_and_commit(
140
+ uid, job_dict, fail_meta, count_as="cascade_failed",
141
+ )
142
+ return True
143
+ return False
144
+
145
+
146
+ def dispatch_no_handler(self, uid: str, job_dict: dict, task_type: str) -> bool:
147
+ """派发预检关 3——handler 未注册(NO_HANDLER 直接 DLQ)。
148
+
149
+ 返回 True = 已处理;仅当 task_type 未注册时调用。
150
+ """
151
+ if task_type not in self._ctx.handlers:
152
+ logger.error(f"No handler for: {task_type}")
153
+ self._reject_and_commit(
154
+ uid, job_dict, {"error": _ERR_NO_HANDLER},
155
+ )
156
+ return True
157
+ return False
158
+
159
+
160
+ def dispatch_orphan_probe(self, state, uid: str, job_dict: dict) -> bool:
161
+ """派发预检关 4——孤儿探测(probe 先于 restore)。
162
+
163
+ 主进程仅探测(非阻塞试锁)。锁被占 = 同 uid 孤儿 worker 仍持锁
164
+ → requeue + 短退避(上限 ~1s 防热循环)本轮不派发。
165
+ probe 必须**先于** _restore_stale_result 与残留声明清理——
166
+ 孤儿存活时提前 return,绝不删孤儿实时声明。返回 True = 已处理
167
+ (孤儿存活 defer);否则调用方继续 restore/submit。
168
+ """
169
+ # 主进程仅探测——非阻塞试锁,成功即释放。
170
+ # 锁生命周期 = 执行体生命周期:主进程崩溃不释放 worker 的锁,
171
+ # 探测失败 = 同 uid 孤儿 worker 仍持锁 → requeue + 短退避
172
+ # (上限 ~1s 防热循环)本轮 defer,下轮孤儿死后正常执行。
173
+ # probe 必须**先于** restore 与声明清理——
174
+ # 孤儿存活时提前 return,绝不删孤儿实时声明;probe 通过后
175
+ # restore 消费孤儿残留结果 → 不派发(无双跑)。
176
+ if not probe_lock(self._ctx.ipc_dir, uid):
177
+ logger.warning(
178
+ f"Deferring {uid}: orphan execution body still holds lock; "
179
+ f"requeue with short backoff."
180
+ )
181
+ self._ctx.stats["deferred_orphan"] += 1
182
+ # 与 retry 路径(_apply_result)对称写
183
+ # wall_deadline——只写 monotonic _backoff_until 的话,崩溃持久化后
184
+ # 重启 monotonic 归零 → 残留旧值被调度器误判为未来退避(阻塞数小时)。
185
+ # wall_deadline 由 _run_body 加载换算为 monotonic,跨崩溃安全。
186
+ # 抖动防热循环:compute_backoff(1,1,1) 恒 ∈ [0.75,1.25] 截顶到
187
+ # 1.0 即 [0.75,1.0] 常量抖动——与重试计数无关,直接写意图。
188
+ delay = random.uniform(0.75, 1.0)
189
+ rt = job_dict.setdefault("runtime", {})
190
+ rt[RT_BACKOFF_UNTIL] = time.monotonic() + delay
191
+ rt[RT_BACKOFF_WALL_DEADLINE] = time.time() + delay
192
+ state.requeue_jobs([job_dict], front=True)
193
+ return True
194
+ return False
195
+
196
+
197
+ def dispatch_job(self, sched) -> Optional[InFlightJob]:
198
+ """Pop job → 预检查 → acquire 资源 → submit 到子进程。直接操作 self._ctx.state。
199
+
200
+ 返回 entry。entry 为 None 表示 job 走了「不走子进程」路径
201
+ (dedup/依赖失败/no-handler/payload 校验失败),已直接 commit 并返回。
202
+ entry 非 None 表示已 submit,需由 ``_complete_job`` 处理结果。
203
+ """
204
+ state = self._ctx.state
205
+ runnable_idx = sched.runnable_idx
206
+ # kind 显式表达调度契约——"dep_failed" 表示 runnable_idx 指向
207
+ # dep-failed 兜底位置(pending_dep_failure 携带失败依赖),"runnable"
208
+ # 表示真正可运行的 job。消费方按 kind 走分支。
209
+ pending_dep_failure = sched.pending_dep_failure if sched.kind == "dep_failed" else None
210
+
211
+ job_dict = state.pop_job(runnable_idx)
212
+ job = Job.from_dict(job_dict)
213
+ uid = job.uid
214
+ # 预检五关以调用顺序表达时序契约,每关返回 True=已处理。
215
+ # 关 1-4(dedup/dep-failed/no-handler/orphan-probe)+ 关 5(stale-restore)。
216
+ if self.dispatch_dedup(state, uid, job_dict):
217
+ return None
218
+ if pending_dep_failure is not None:
219
+ if self.dispatch_dep_failed(uid, job_dict, pending_dep_failure):
220
+ return None
221
+ if job.task_type not in self._ctx.handlers:
222
+ if self.dispatch_no_handler(uid, job_dict, job.task_type):
223
+ return None
224
+ if self.dispatch_orphan_probe(state, uid, job_dict):
225
+ return None
226
+ # 关 5:stale-restore(崩溃残留消费)
227
+
228
+ # 崩溃恢复:派发子进程前先消费该 uid 的残留结果文件
229
+ # (上次 run 崩溃前已执行完成但未 commit)。有残留 → 直接提交,不派发。
230
+ # restore 在 probe **之后**——probe 通过意味着
231
+ # 无存活孤儿(锁生命周期 = 执行体生命周期,孤儿已死才会释放锁),
232
+ # 此时残留结果文件若存在必然来自已死执行的孤儿,消费它即吸收
233
+ # 「孤儿已完成但主进程崩溃未 commit」的执行成果,不派发(无双跑)。
234
+ if self._completion.restore_stale_result(uid, job, job_dict):
235
+ return None
236
+
237
+ # 崩溃残留声明文件污染——崩溃时序「drain 消费
238
+ # 结果后 _complete_job finally 前 SIGKILL」残留 outputs.jsonl,重派发
239
+ # 时新 worker **append** 声明 → [旧声明, 新声明] → 成功路径存在性
240
+ # 校验读到旧声明 → "Missing output" 假 DLQ。此处 _restore_stale_result
241
+ # 已返回 False(无残留结果可消费,只剩崩溃残留声明)→ submit 前
242
+ # 清残留;与 _complete_job finally 的防御一致(try/except OSError,
243
+ # FileNotFound 忽略),正常路径无残留时幂等 no-op。
244
+ # 清理块**必须在 probe 之后**——probe 失败(孤儿
245
+ # 存活)时已提前 return,绝不会走到这里删孤儿实时声明;probe 通过
246
+ # + restore 无果 = 无存活执行体且无残留结果,声明文件只可能是
247
+ # 已死执行留下的崩溃残留。
248
+ for _stale_decl in (
249
+ outputs_path(self._ctx.ipc_dir, uid), inputs_path(self._ctx.ipc_dir, uid),
250
+ # signals 文件同属已死执行的崩溃残留——上一执行
251
+ # 体写了 suspend 信号但主进程未及消费即崩,残留信号会在此刻
252
+ # (新 incarnation 派发前)被 apply_pending_signals 误应用到本轮
253
+ # 上下文(资源挂起与新执行体无关)。随声明文件一并清理。
254
+ signals_path(self._ctx.ipc_dir, uid),
255
+ ):
256
+ try:
257
+ if _stale_decl.exists():
258
+ _stale_decl.unlink()
259
+ except OSError:
260
+ pass
261
+
262
+ # 合并 handler 默认 resources(确保 handler 注册前入队的
263
+ # job 也能拿到默认资源)。合并结果落在局部
264
+ # dict,**不回写 Job 对象**——与 runtime.inject_worker_resource
265
+ # 同纪律:Job 是调用方资产,派发侧不得改变其字段(否则 requeue/
266
+ # 复用同一 Job 实例时默认资源被永久烤入并随 to_dict 扩散)。
267
+ merged_resources = dict(job.resources)
268
+ handler_default_resources = self._ctx.handlers[job.task_type].default_resources
269
+ if handler_default_resources:
270
+ merged_resources = {**handler_default_resources, **merged_resources}
271
+
272
+ # Acquire resources (transactional: release on partial failure)
273
+ # acquire 循环必须在 try 块内——若第 N 个资源 acquire 抛异常
274
+ # (负值/NaN/自定义 Resource 校验失败),except 处理器会释放已 acquire
275
+ # 的前 N-1 个资源,避免永久泄漏。
276
+ acquired: List[Tuple[str, float]] = []
277
+ handle: Optional[JobHandle] = None
278
+ try:
279
+ for res_name, amount in merged_resources.items():
280
+ self._ctx.resources[res_name].acquire(amount)
281
+ acquired.append((res_name, amount))
282
+
283
+ # Payload validation
284
+ _handler_entry = self._ctx.handlers[job.task_type]
285
+ _payload_schema = _handler_entry.payload_schema
286
+ if _payload_schema is not None:
287
+ _errors = validate_payload(job.payload, _payload_schema)
288
+ if _errors:
289
+ logger.error(f"Payload validation failed for {uid}: {_errors}")
290
+ # 校验失败不走子进程,release 已 acquire 的资源后直接 commit
291
+ self._completion.release_acquired(acquired)
292
+ fail_meta = {"error": _ERR_PAYLOAD_VALIDATION, "details": _errors}
293
+ self._reject_and_commit(uid, job_dict, fail_meta)
294
+ return None
295
+ # Build context + submit (non-blocking)
296
+ # 增量 uid 索引(PipelineState._wall_uids/_failed_uids)避免
297
+ # 每次派发 ``set(state.wall.keys())`` 的 O(W) 重建;这里是单线程
298
+ # 派发路径,活引用在 submit 内立即 pickle 为子进程快照。
299
+ wall_keys = state.wall_uids
300
+ failed_keys = state.failed_uids
301
+ # 输出声明走落盘 outputs.jsonl——handler 子进程内声明的
302
+ # 输出经落盘文件传回主进程。
303
+ # fencing:分配本 job 的执行代标识(run_id.seq)。
304
+ # seq 每次 submit 递增——同 uid 重试再派发也获得新 incarnation,
305
+ # 与上次尝试的结果文件隔离(旧尝试的残留不被本次 drain 看见)。
306
+ self._ctx.dispatch_seq += 1
307
+ incarnation = f"{self._ctx.run_id}.{self._ctx.dispatch_seq}"
308
+ # 注册表快照契约:per-pipeline 瞬态异常注册表快照随 ctx pickle
309
+ # 下发——分类决策在子进程,注册表必须显式传递(不可依赖父进程
310
+ # 作用域,更不存在模块级可变全局)。
311
+ ctx = TaskContext(
312
+ job, wall_keys, failed_keys, dict(state.cursors),
313
+ output_root=self._ctx.output_root,
314
+ ipc_dir=self._ctx.ipc_dir, incarnation=incarnation,
315
+ transient_registry=self._ctx.transient_registry.snapshot(),
316
+ # 资源名注册集快照随 ctx 下发——
317
+ # suspend_resource 对未注册名 fail-loud(typo 不静默失效)。
318
+ resource_names=frozenset(self._ctx.resources),
319
+ )
320
+
321
+ logger.info(f"RUN: {uid}")
322
+ job_start = time.monotonic()
323
+ handle = self._ctx.executor.submit(
324
+ self._ctx.handlers[job.task_type].func, job, ctx, job.timeout,
325
+ ipc_dir=self._ctx.ipc_dir,
326
+ )
327
+
328
+ entry = InFlightJob(
329
+ uid=uid,
330
+ job_dict=job_dict,
331
+ job=job,
332
+ acquired=acquired,
333
+ handle=handle,
334
+ job_start=job_start,
335
+ )
336
+ # 在 return 前注册到 _in_flight,避免 return 后到调用方注册之间
337
+ # 命中 KeyboardInterrupt 导致子进程泄漏 + 资源泄漏 + job 丢失。
338
+ self._ctx.in_flight[uid] = entry
339
+ # 同步登记到 state 的 in-flight 集合(统一 is_known 事实源)
340
+ state.register_in_flight(uid)
341
+ return entry
342
+
343
+ except _CommitCrashSignal:
344
+ # _CommitCrashSignal 继承 BaseException——「commit 失败需崩溃」
345
+ # 的信号不会被 except Exception 兜底误吞。re-raise 让它穿透到
346
+ # run_loop 的崩溃处理分支(已 requeue 当前 job,不在此二次处理)。
347
+ raise
348
+ except KeyboardInterrupt:
349
+ logger.warning(f"Pipeline interrupted while dispatching {uid}.")
350
+ if uid in self._ctx.in_flight:
351
+ # entry 已注册到 _in_flight:此处不清理/不 requeue/不 release,
352
+ # 全部交给 _run_loop 的 _abort_in_flight 统一处理,
353
+ # 避免对同一 entry 二次释放资源、二次 requeue 同一作业。
354
+ raise
355
+ if handle is not None:
356
+ self._ctx.executor.cleanup([handle])
357
+ self._completion.release_acquired(acquired)
358
+ state.requeue_jobs([job_dict], front=True)
359
+ # 不在此 save_queue:内存此刻缺其他 in-flight 作业,
360
+ # 交给 _run_loop 的 _save_queue_crash_safe 合并磁盘真相后统一保存。
361
+ raise
362
+ except Exception as e:
363
+ logger.error(f"Error dispatching job {uid}: {e}\n{traceback.format_exc()}")
364
+ if handle is not None:
365
+ self._ctx.executor.cleanup([handle])
366
+ self._completion.release_acquired(acquired)
367
+ # dispatch 阶段失败(submit 的 pickle/启动报错、
368
+ # 资源 acquire 校验失败):确定性失败(如不可 pickle 的
369
+ # lambda handler)若只 requeue + 崩溃会触发**无限重启循环**。
370
+ # 独立 `_dispatch_failures` 计数——不复用
371
+ # `_commit_failures`(混用计数的话,dispatch 失败几次后任意一次
372
+ # commit 失败即达阈值进 DLQ,错误码 COMMIT_FAILURE_DLQ 误导
373
+ raw_rt = job_dict.get("runtime")
374
+ if not isinstance(raw_rt, dict):
375
+ raw_rt = {}
376
+ job_dict["runtime"] = raw_rt
377
+ rt = raw_rt
378
+ failures = rt.get("_dispatch_failures", 0) + 1
379
+ rt["_dispatch_failures"] = failures
380
+ if failures >= self._ctx.commit_failure_dlq_threshold:
381
+ logger.critical(
382
+ f"Dispatch failed {failures} times for {uid} ({e}); "
383
+ f"treating as deterministic bad input (e.g. unpickleable "
384
+ f"handler), sending to DLQ."
385
+ )
386
+ committed = self._ctx.backend.commit_job_failure(
387
+ uid, {"error": _ERR_DISPATCH_FAILURE,
388
+ "failures": failures, "detail": str(e)[:200]},
389
+ )
390
+ if committed:
391
+ # 与下方 requeue 分支对称——DLQ 分支
392
+ # return 前也移除 entry:若 entry 已注册(register_in_flight
393
+ # 的 DEBUG 断言失败时可达),残留的 entry 会在后续 drain
394
+ # 中被当 in-flight 处理(死 handle → 二次 _complete_job →
395
+ # 重复 DLQ / 断言崩)。未注册时 pop/unregister 均安全。
396
+ self._ctx.in_flight.pop(uid, None)
397
+ # dispatch 失败达阈值视为业务侧确定性坏输入,下游自动级联跳过。
398
+ self._failure.apply_failed(
399
+ uid, {"error": _ERR_DISPATCH_FAILURE,
400
+ "failures": failures, "detail": str(e)[:200]}
401
+ )
402
+ self._failure.cascade_fail(uid)
403
+ # dispatch 3-strike 终态触发钩子——
404
+ # 承诺「每个 job 终结时钩子恰好调用一次」(否则监控
405
+ # 漏报该类失败)。与 _commit_failed_crash /
406
+ # payload 校验等直接 commit 路径对称。
407
+ self._ctx.fire_job_completed(
408
+ uid, {"error": _ERR_DISPATCH_FAILURE,
409
+ "failures": failures, "detail": str(e)[:200]},
410
+ False, False,
411
+ )
412
+ return
413
+ # DLQ 也失败(环境故障)→ 走 crash 路径
414
+ # 与 KeyboardInterrupt 分支对称——若 entry 已
415
+ # 注册到 _in_flight(仅 register_in_flight 的 DEBUG 断言失败时
416
+ # 可达,此时状态已损坏),requeue 后 raise 会被 _run_loop 的
417
+ # _abort_in_flight 对该 entry **二次 requeue**(内存队列重复 uid)。
418
+ # 先移除 entry(未注册时 pop/unregister 均安全),requeue 交给
419
+ # 下方统一执行。
420
+ self._ctx.in_flight.pop(uid, None)
421
+ state.unregister_in_flight(uid)
422
+ state.requeue_jobs([job_dict], front=True)
423
+ # 不在此 save_queue:内存此刻缺其他 in-flight 作业,
424
+ # 交给 _run_loop 的 _save_queue_crash_safe 合并磁盘真相后统一保存。
425
+ raise
426
+