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,509 @@
1
+ """失败机器:3-strike / 级联 / 死锁归因的失败处理族。
2
+
3
+ 持有失败处理的七个方法——它们自成一个子语言(3-strike、级联、死锁归因),
4
+ 聚到一起后「每条 DLQ 路径是否触发钩子」「是否对称」一眼可查。
5
+ 保持单一出口:``fire_job_completed``(钩子出口,经 RunContext 访问)与
6
+ ``_complete_job`` 伪 entry 是既有收敛点,本模块不复制出口逻辑。
7
+
8
+ 设计:``FailureMachine`` **不持有宿主 pipeline 引用**,
9
+ 只依赖注入的 ``RunContext``(``self._ctx``)——state/backend/stats/
10
+ fire_job_completed/scheduler/episode 态均从上下文读取,避免
11
+ FailureMachine ↔ TaskLite 双向引用。TaskLite 保留同名薄
12
+ 转发方法(委托给 ``self._failure``),既有调用点不变。episode 状态
13
+ (``dep_grace_*``/``deadlock_gap_rounds``)由 RunContext 按 run 重置。
14
+
15
+ 注意:``_handle_deadlock`` 返回 ``should_break`` 被主循环
16
+ 消费;``_commit_failed_crash``/``_commit_bulk_failed_crash`` 抛
17
+ ``_JobTerminated``/``_CommitCrashSignal`` 需穿越回 pipeline 的 except 块
18
+ ——这些信号是 BaseException,跨对象传播无碍(捕获点仍在 pipeline)。
19
+ """
20
+
21
+ import logging
22
+ import time
23
+ from typing import List, Tuple, TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from .runtime import RunContext
27
+
28
+ from ..error_codes import (
29
+ ERR_COMMIT_FAILURE_DLQ,
30
+ ERR_DEADLOCK_GAP as _ERR_DEADLOCK_GAP,
31
+ ERR_DEPENDENCY_DEADLOCK as _ERR_DEPENDENCY_DEADLOCK,
32
+ ERR_JOB_DEPENDENCY as _ERR_JOB_DEPENDENCY,
33
+ ERR_MALFORMED_JOB as _ERR_MALFORMED_JOB,
34
+ ERR_RESOURCE_DEADLOCK as _ERR_RESOURCE_DEADLOCK,
35
+ )
36
+ from ..exceptions import _CommitCrashSignal, _JobTerminated
37
+ from ..models.job import Job
38
+ from ..models.state import uid_from_job_dict
39
+ from .deadlock import split_deadlock
40
+
41
+ logger = logging.getLogger("tasklite")
42
+
43
+ # 失败机器的模块级常量。
44
+ # pipeline._dispatch_job 的 dispatch 3-strike 也读 COMMIT_FAILURE_DLQ_THRESHOLD
45
+ # pipeline 经 `from .engine.failure import ...` 引用本常量。
46
+ COMMIT_FAILURE_DLQ_THRESHOLD = 3
47
+ DEP_GRACE_SECONDS = 60.0
48
+ DEADLOCK_GAP_MAX_ROUNDS = 5
49
+
50
+
51
+ class FailureMachine:
52
+ """失败处理族:3-strike 崩溃契约 / 级联 / 死锁归因 / 宽限。
53
+
54
+ 依赖**注入的 RunContext**(``self._ctx``)而非宿主 pipeline——
55
+ ``state`` 在 run() 时才装载,backend/scheduler/hooks/episode 态均为
56
+ 上下文常驻属性。
57
+ """
58
+
59
+ def __init__(self, ctx: "RunContext") -> None:
60
+ self._ctx = ctx
61
+
62
+ def _requeue_and_crash(self, uid: str, job_dict: dict, reason: str) -> None:
63
+ """单一出口:所有「commit 失败 → requeue 内存 + 崩溃」路径的收敛点。
64
+
65
+ 3-strike 的 DLQ 分支(``_commit_failed_crash``)与清理型终态
66
+ (``_commit_skip_crash``)最终都走到这里。前置条件:job 已不在
67
+ in-flight(调用方先 unregister),否则 ``_abort_in_flight`` 会二次
68
+ requeue 同一 uid。
69
+ """
70
+ self._ctx.state.unregister_in_flight(uid)
71
+ self._ctx.state.requeue_jobs([job_dict], front=True)
72
+ raise _CommitCrashSignal(
73
+ f"Backend commit returned False for {uid} ({reason}). "
74
+ f"On-disk queue preserved; crashing to avoid unbounded retry loop."
75
+ )
76
+
77
+ def commit_skip_crash(self, uid: str, job_dict: dict) -> None:
78
+ """``commit_skip`` 失败时的终态:**只 requeue + 崩溃,绝不写 DLQ**。
79
+
80
+ 状态模型边界:skip 命中意味着 uid 已有一个成功/失败终态
81
+ (wall 或 failed),本次只是清理磁盘队列里的残留条目——「清理失败」
82
+ 是环境故障,不是业务失败。若复用 ``_commit_failed_crash`` 的 3-strike,
83
+ 第 3 次会把 wall 里的成功记录翻转成 DLQ 失败(从未重跑过的任务被
84
+ 判死),违反「最终状态唯一 + 终态不可伪造」。正确的收敛是回到
85
+ 「wall∩queue 残留」这一可修复的崩溃前状态,由下次 ``_run_body``
86
+ 的加载期过滤或下次 ``commit_skip`` 重试消化。
87
+ """
88
+ self._requeue_and_crash(uid, job_dict, "commit_skip")
89
+
90
+ def commit_failed_crash(self, uid: str, reason: str, job_dict: dict) -> None:
91
+ """commit 返回 False 时:requeue 保持内存一致,然后崩溃。
92
+
93
+ 后端契约保证 on-disk 队列未变(popped job 仍在磁盘上)。
94
+ 将内存状态 requeue 到与磁盘一致后 raise _CommitCrashSignal,
95
+ 避免 commit 失败时无限重试(紧循环 → 对外部 API 的 DDoS)。
96
+
97
+ 崩溃循环防护:若同一 job 的 commit 已连续失败达
98
+ ``COMMIT_FAILURE_DLQ_THRESHOLD`` 次(记录于 job_dict 的
99
+ ``_commit_failures`` 计数并持久化),说明这是**确定性坏输入**
100
+ (如 handler 返回不可序列化 meta 但预检漏网)而非环境故障——
101
+ 继续 crash 只会无限重启循环。此时改走 DLQ,job 被标记失败而非
102
+ 永远重跑。
103
+
104
+ 控制流异常通道唯一化:本方法**永不正常返回**——DLQ 分支 commit
105
+ 成功后抛 ``_JobTerminated``(job 已终结需停止处理)而非返回
106
+ None。DLQ 分支若正常返回,调用方(no-handler/payload-validation
107
+ 等分支)在 ``_commit_failed_crash(...)`` 之后没有 return,
108
+ fall-through 继续 acquire + submit 已失败的 job(资源泄漏 +
109
+ 双重副作用)。调用方以 ``except _JobTerminated`` 承接,无需
110
+ 记忆检查返回值。
111
+ """
112
+ raw_rt = job_dict.get("runtime")
113
+ if not isinstance(raw_rt, dict):
114
+ raw_rt = {}
115
+ job_dict["runtime"] = raw_rt
116
+ rt = raw_rt
117
+ failures = rt.get("_commit_failures", 0) + 1
118
+ if failures >= self._ctx.commit_failure_dlq_threshold:
119
+ logger.critical(
120
+ f"Backend commit failed {failures} times for {uid} ({reason}); "
121
+ f"treating as deterministic bad input, sending to DLQ instead of crashing."
122
+ )
123
+ rt["_commit_failures"] = failures
124
+ committed = self._ctx.backend.commit_job_failure(
125
+ uid, {"error": ERR_COMMIT_FAILURE_DLQ,
126
+ "reason": reason,
127
+ "failures": failures}
128
+ )
129
+ if committed:
130
+ # 三连收敛——_mark_failed + stats + unregister
131
+ self.apply_failed(uid, {"error": ERR_COMMIT_FAILURE_DLQ,
132
+ "reason": reason, "failures": failures})
133
+ # 3-strike commit 失败 DLQ 终态触发钩子——
134
+ # on_job_completed 承诺「每个 job 终结」应覆盖此终态。
135
+ self._ctx.fire_job_completed(
136
+ uid, {"error": ERR_COMMIT_FAILURE_DLQ,
137
+ "reason": reason, "failures": failures},
138
+ False, False,
139
+ )
140
+ raise _JobTerminated(
141
+ f"Job {uid} terminated: {reason} after {failures} consecutive "
142
+ f"commit failures (deterministic bad input → DLQ)."
143
+ )
144
+ # DLQ 也失败(环境故障)→ 仍走 crash 路径
145
+ rt["_commit_failures"] = failures
146
+ self._requeue_and_crash(uid, job_dict, reason)
147
+
148
+ def mark_failed(self, uid: str, meta: dict) -> None:
149
+ """统一失败登记:清 wall 旧记录 + mark_failed。
150
+
151
+ rerun 任务(every_run/on_failure/on_input_change)重跑失败时,wall
152
+ 里可能有上次的成功记录——不清理则 uid 同时属于 wall 和 failed:
153
+ DEBUG 互斥断言崩、ctx.is_completed/is_failed 同时 True(身份语义被破坏)。
154
+ 本 helper 收敛所有 DLQ 登记路径(单条/批量/3-strike/级联/死锁),
155
+ 保证「最终状态唯一」。
156
+
157
+ 磁盘 wall 清理由 commit_job_failure /
158
+ commit_bulk_failure 的同一事务完成(原子、单出口、幂等)——此处
159
+ 只做内存镜像(pop wall + mark_failed);独立非原子调用
160
+ delete_wall 失败会磁盘 wall∩failed 并存。
161
+ """
162
+ state = self._ctx.state
163
+ state.mark_failed(uid, meta)
164
+
165
+ def apply_failed(self, uid: str, meta: dict, *, unregister: bool = True,
166
+ count_as: str = "failed") -> None:
167
+ """失败登记的内存尾段。
168
+
169
+ 收敛「_mark_failed + stats 计数 + unregister_in_flight」三连——
170
+ 分散复制易漏同步(漏 discard 豁免、漏 cascade、漏钩子、漏计数)。
171
+ ``cascade`` 与 ``fire_hook`` **留在调用点**——两者是有正确边界的
172
+ 语义决策而非复制漂移:业务失败级联 / commit 环境故障不级联;
173
+ 钩子两套触发位置(序列内直调 / ``_complete_job`` 尾部)均为合法出口。
174
+
175
+ ``unregister`` 边界:**批量路径(级联/死锁/批量
176
+ 3-strike)必须传 False**——这些 uid 仍在队列中(replace_queue 尚未
177
+ 执行),若此时 discard ``_rerun_active_uids`` 会破坏 rerun 任务的
178
+ 豁免集合 → DEBUG 互斥断言崩(rerun 任务合法 wall∩queue 无豁免)。
179
+ 豁免集合由 ``replace_queue`` 重建(被移除的 uid 自然脱离豁免)。
180
+
181
+ 身份契约:先 ``_mark_failed``(mark_failed)再 unregister——注销必须
182
+ 严格排在目标集合登记之后(身份非真空)。单条路径的 uid 已 pop 出队,
183
+ unregister 是 no-op(discard)但必要(discard 豁免集合)。
184
+ """
185
+ self.mark_failed(uid, meta)
186
+ # count_as:统计桶选择——"failed"=真正执行失败;"cascade_failed"=
187
+ # 因上游失败被阻断的下游(JOB_DEPENDENCY),单独计数使 DLQ 规模
188
+ # 与故障规模可区分(下游本身没有错)。
189
+ self._ctx.stats[count_as] += 1
190
+ if unregister:
191
+ self._ctx.state.unregister_in_flight(uid)
192
+
193
+ def commit_bulk_failed_crash(
194
+ self, reason: str, uids_metas: List[Tuple[str, dict]], queue_job_dicts: List[dict]
195
+ ) -> Tuple[List[dict], bool]:
196
+ """bulk commit 失败的 3-strike 处理。
197
+
198
+ 单条路径(``_commit_failed_crash``)对 commit 失败逐 job 计数、达
199
+ 阈值转 DLQ——bulk 路径(死锁/级联)与单条路径同等对待:
200
+
201
+ - ``_commit_failures`` 计数 +1(持久化于 job_dict,随队列落盘,
202
+ 重启后继续累计);
203
+ - 达阈值 → 尝试单条 ``commit_job_failure``(成功则内存 mark_failed、
204
+ 移出队列);未达阈值或 DLQ 也失败 → 保留在队列。
205
+
206
+ 返回 ``(remaining_queue, has_kept_affected)``。调用方须
207
+ ``replace_queue(remaining_queue)``;``has_kept_affected=True`` 时
208
+ 继续 raise ``_CommitCrashSignal``(计数已持久化,重启后累计,
209
+ 最终达阈值转 DLQ——无限循环被切断)。
210
+ """
211
+ affected = {uid for uid, _ in uids_metas}
212
+ meta_by_uid = dict(uids_metas)
213
+ remaining: List[dict] = []
214
+ has_kept_affected = False
215
+ for jd in queue_job_dicts:
216
+ uid = uid_from_job_dict(jd)
217
+ if uid not in affected:
218
+ remaining.append(jd)
219
+ continue
220
+ rt = jd.setdefault("runtime", {})
221
+ failures = rt.get("_commit_failures", 0) + 1
222
+ rt["_commit_failures"] = failures
223
+ if failures >= self._ctx.commit_failure_dlq_threshold:
224
+ committed = self._ctx.backend.commit_job_failure(uid, meta_by_uid[uid])
225
+ if committed:
226
+ # 批量 3-strike:uid 在队列中(replace_queue 未执行),
227
+ # 豁免集合由调用方 replace_queue 重建——不 unregister。
228
+ self.apply_failed(uid, meta_by_uid[uid], unregister=False)
229
+ # bulk 3-strike 单条 DLQ 终态触发钩子
230
+ self._ctx.fire_job_completed(uid, meta_by_uid[uid], False, False)
231
+ continue
232
+ logger.critical(
233
+ f"Bulk 3-strike: DLQ also failed for {uid} ({reason}); "
234
+ f"keeping in queue for next boot."
235
+ )
236
+ has_kept_affected = True
237
+ remaining.append(jd)
238
+ return remaining, has_kept_affected
239
+
240
+ def cascade_fail(self, failed_uid: str) -> None:
241
+ """父 job 失败后 O(1) 级联标记全部下游为依赖失败。
242
+
243
+ 沿 ``state.fail_cascade`` 的反向依赖索引一次性找到全部下游,逐条
244
+ commit_bulk_failure 到 DLQ 并同步内存。commit 失败走崩溃契约,
245
+ 绝不静默。
246
+
247
+ 复用点:``_dispatch_job`` 的 pending_dep_failure 路径 与
248
+ ``_apply_result`` 的 DLQ 失败路径。
249
+ """
250
+ state = self._ctx.state
251
+ cascade_uids = state.fail_cascade(failed_uid)
252
+ if not cascade_uids:
253
+ return
254
+ cascade_metas = [
255
+ (cuid, {"error": _ERR_JOB_DEPENDENCY, "failed_dependency": failed_uid})
256
+ for cuid in cascade_uids
257
+ ]
258
+ c_committed = self._ctx.backend.commit_bulk_failure(cascade_metas)
259
+ if c_committed:
260
+ for cuid, cmeta in cascade_metas:
261
+ # 级联批量:uid 在队列中,豁免集合由下方 replace_queue 重建
262
+ self.apply_failed(cuid, cmeta, unregister=False,
263
+ count_as="cascade_failed")
264
+ # 级联批量 DLQ 路径同样触发钩子——
265
+ # on_job_completed 承诺「每个 job 终结」应覆盖批量终态。
266
+ self._ctx.fire_job_completed(cuid, cmeta, False, False)
267
+ remaining = [jd for jd in state.queue
268
+ if uid_from_job_dict(jd) not in set(cascade_uids)]
269
+ state.replace_queue(remaining)
270
+ else:
271
+ # 3-strike:与死锁路径同款——逐 job 计数,
272
+ # 达阈值单条 DLQ,未达保留;保留则崩溃重启继续累计。
273
+ queue, kept = self.commit_bulk_failed_crash(
274
+ "commit_bulk_failure", cascade_metas, list(state.queue)
275
+ )
276
+ state.replace_queue(queue)
277
+ if kept:
278
+ raise _CommitCrashSignal(
279
+ f"Backend commit_bulk_failure returned False for "
280
+ f"{len(cascade_metas)} cascaded job(s) of {failed_uid}; "
281
+ f"{len(queue)} kept in queue with incremented "
282
+ f"_commit_failures (3-strike will DLQ them). "
283
+ f"Crashing to retry; on-disk queue preserved."
284
+ )
285
+
286
+ def _dependency_grace(self, missing_indices) -> bool:
287
+ """宽限:缺失依赖的 job 是否应等待而非立即 DLQ。
288
+
289
+ 宽限条件:队列中存在**可运行的候选 job**(依赖全部在 wall 或
290
+ 无依赖)——它一旦运行可能 spawn 出缺失的依赖(如转码项目的 scan
291
+ driver)。仅当全部 job 都在等缺失依赖、或其余 job 都是等待者
292
+ (依赖链尾,如「依赖缺失者的下游」)时才判死锁。
293
+ 防无限等待:宽限总时长上限(``DEP_GRACE_SECONDS``,monotonic),
294
+ 超时后不再宽限(DLQ + 醒目日志)。返回 True = 宽限,False = 判死锁。
295
+ """
296
+ missing_set = set(missing_indices)
297
+ state = self._ctx.state
298
+ # 宽限截止按 episode 重置——episode 判定必须用
299
+ # **uid 集合**而非索引集合:索引随队列位移变化(dispatch pop 前面的
300
+ # job 会前移后续索引),同索引不同身份会误判同 episode(B 组缺失 job
301
+ # 恰好占据 A 组解决后的同位置 → 集合相同 → 不重置 → 复用 A 组已
302
+ # 过期 deadline → 零宽限立即 DLQ)。uid 是稳定身份键。
303
+ missing_uids: set = set()
304
+ for i in missing_indices:
305
+ if 0 <= i < len(state.queue):
306
+ try:
307
+ missing_uids.add(Job.from_dict(state.queue[i]).uid)
308
+ except (KeyError, TypeError, ValueError):
309
+ pass # 畸形条目由 malformed 分支处理,此处跳过
310
+ if self._ctx.dep_grace_missing is not None and self._ctx.dep_grace_missing != missing_uids:
311
+ self._ctx.dep_grace_deadline = None
312
+ self._ctx.dep_grace_missing = frozenset(missing_uids)
313
+ for i, jd in enumerate(state.queue):
314
+ if i in missing_set:
315
+ continue
316
+ try:
317
+ # (性能优化):复用调度器内容键缓存而非裸
318
+ # Job.from_dict——死锁宽限阶段(min_wait=inf,全队列无
319
+ # 可运行)每轮两次全量反序列化(N=10 万 ≈ 240ms/轮)
320
+ job = self._ctx.scheduler.cached_job(jd)
321
+ except (KeyError, TypeError, ValueError):
322
+ continue
323
+ if all(dep in state.wall for dep in job.depends_on):
324
+ # 存在可运行候选(潜在 spawner)→ 宽限,等它 spawn 出依赖
325
+ now = time.monotonic()
326
+ if self._ctx.dep_grace_deadline is None:
327
+ self._ctx.dep_grace_deadline = now + self._ctx.dep_grace_seconds
328
+ logger.warning(
329
+ f"DEPENDENCY GRACE: {len(missing_indices)} job(s) waiting "
330
+ f"on missing deps; granting {self._ctx.dep_grace_seconds}s "
331
+ f"grace (runnable job(s) may spawn them)."
332
+ )
333
+ if now < self._ctx.dep_grace_deadline:
334
+ # 防忙循环:宽限等待期间给主循环喘息(候选 job 可能在退避/等资源)
335
+ time.sleep(0.5)
336
+ return True
337
+ logger.error(
338
+ f"DEPENDENCY GRACE EXPIRED: {len(missing_indices)} job(s) "
339
+ f"still waiting on missing deps after "
340
+ f"{self._ctx.dep_grace_seconds}s; treating as deadlock (DLQ)."
341
+ )
342
+ return False
343
+ # 无可运行候选(其余都是等待者/死锁类)→ 真死锁,无宽限
344
+ return False
345
+
346
+ def _deadlock_gap_or_escalate(self, log_prefix: str) -> bool:
347
+ """死锁分类缺口(环检测空 / 不可归因)的连续轮次升级逻辑。
348
+
349
+ 两处保守兜底(waiting_for_dependency 无环 / 无已知根因)共用——
350
+ 保守动作在冻结状态上「重试下一轮」是空的(队列/in-flight
351
+ 不变 → 分类结果必逐位相同),会永久挂起 + 日志刷屏。加连续轮次计数
352
+ (``_deadlock_gap_rounds``),达阈值升级整队列 DLQ(专属错误码
353
+ ``_ERR_DEADLOCK_GAP``),恢复终止性。
354
+
355
+ 返回 True = 已升级(调用方应构造整队列 uids_metas 走 bulk DLQ);
356
+ 返回 False = 未达阈值(调用方应 return False 退避重试)。
357
+ """
358
+ self._ctx.deadlock_gap_rounds += 1
359
+ if self._ctx.deadlock_gap_rounds < self._ctx.deadlock_gap_max_rounds:
360
+ logger.error(
361
+ f"{log_prefix}: refusing to fail the whole queue, retrying "
362
+ f"next round "
363
+ f"({self._ctx.deadlock_gap_rounds}/{self._ctx.deadlock_gap_max_rounds})."
364
+ )
365
+ time.sleep(0.5)
366
+ return False
367
+ logger.critical(
368
+ f"{log_prefix} persisted for {self._ctx.deadlock_gap_max_rounds} rounds; "
369
+ f"escalating to whole-queue DLQ ({_ERR_DEADLOCK_GAP})."
370
+ )
371
+ return True
372
+
373
+ def handle_deadlock(self, sched) -> bool:
374
+ """处理死锁:细粒度归因 + bulk_failure + cascade。原位操作 self._ctx.state。
375
+
376
+ 返回 should_break。True 表示终态(commit 失败或剩余队列空),主循环应退出。
377
+ """
378
+ state = self._ctx.state
379
+ if sched.malformed_indices:
380
+ # 畸形 job dict 优先处理 — 无法反序列化的 job 直接入 DLQ
381
+ logger.error(f"Deadlock: {len(sched.malformed_indices)} job(s) have malformed dict (unparseable).")
382
+ root = set(sched.malformed_indices)
383
+ uids_metas, remaining_queue = split_deadlock(
384
+ list(state.queue),
385
+ _ERR_MALFORMED_JOB,
386
+ extract_uid=uid_from_job_dict,
387
+ include=lambda idx, uid, root=root: idx in root,
388
+ )
389
+ elif sched.unknown_resource_indices:
390
+ logger.error(f"Deadlock: {len(sched.unknown_resource_indices)} job(s) reference unknown resource(s).")
391
+ root = set(sched.unknown_resource_indices)
392
+ uids_metas, remaining_queue = split_deadlock(
393
+ list(state.queue),
394
+ _ERR_RESOURCE_DEADLOCK,
395
+ extract_uid=lambda jd: Job.from_dict(jd).uid,
396
+ include=lambda idx, uid, root=root: idx in root,
397
+ )
398
+ elif sched.missing_dependency_indices:
399
+ # 宽限语义:缺失依赖的 job——若队列中还有
400
+ # 其他 job(可能是 spawner,未来会 spawn 出依赖)→ 宽限等待
401
+ # 而非立即 DLQ;仅当全部 job 都在等缺失依赖或宽限超时才判死锁。
402
+ if self._dependency_grace(sched.missing_dependency_indices):
403
+ return False # 宽限中:主循环继续(等 spawner 产出依赖)
404
+ logger.error(f"Deadlock: {len(sched.missing_dependency_indices)} job(s) have unresolvable (missing) dependencies.")
405
+ root = set(sched.missing_dependency_indices)
406
+ uids_metas, remaining_queue = split_deadlock(
407
+ list(state.queue),
408
+ _ERR_DEPENDENCY_DEADLOCK,
409
+ extract_uid=lambda jd: Job.from_dict(jd).uid,
410
+ include=lambda idx, uid, root=root: idx in root,
411
+ )
412
+ elif sched.impossible_resource_indices:
413
+ # 不可达资源(amount > capacity)→ 只失败肇事者,其他 job 继续。
414
+ # 置于 waiting_for_dependency(依赖环)之前:资源量不可达是确定的
415
+ # 根因,比"依赖环"归因更准确,且只失败肇事者、让被阻断者走 cascade。
416
+ logger.error(f"Deadlock: {len(sched.impossible_resource_indices)} job(s) request impossible resource amounts (exceeds capacity).")
417
+ root = set(sched.impossible_resource_indices)
418
+ uids_metas, remaining_queue = split_deadlock(
419
+ list(state.queue),
420
+ _ERR_RESOURCE_DEADLOCK,
421
+ extract_uid=lambda jd: Job.from_dict(jd).uid,
422
+ include=lambda idx, uid, root=root: idx in root,
423
+ )
424
+ elif sched.waiting_for_dependency:
425
+ # 依赖全在队列中但无法运行 → 依赖环。
426
+ # find_dependency_cycles 精确定位环内成员,只失败环内;环外
427
+ # job 保留(其依赖若在环内,会经 fail_cascade 在环成员 commit
428
+ # 失败后被级联标记)——整队列标 DEPENDENCY_DEADLOCK 会连带
429
+ # 杀死环外无关 job。
430
+ cycle_uids = set(state.find_dependency_cycles())
431
+ if not cycle_uids:
432
+ # 理论不可达(waiting_for_dependency 意味着
433
+ # 依赖在队列/在途但无法运行;in-flight 为空时依赖必在队列 →
434
+ # 图算法应找到环)。保守动作:记录 + 短退避,下一轮再判,
435
+ # 绝不整队列清空。保守动作在冻结状态上「重试下一轮」是空的
436
+ # (队列/in-flight 不变 → 分类结果必逐位相同),会永久挂起 +
437
+ # 日志刷屏。加连续轮次计数:达阈值升级为整队列 DLQ(专属
438
+ # 错误码),恢复终止性——保留「首轮不误杀」意图,又不让未知
439
+ # 死锁变成僵尸进程。(与不可归因分支共用 gap 升级逻辑)
440
+ escalated = self._deadlock_gap_or_escalate(
441
+ "Deadlock classification gap (waiting_for_dependency without cycle)"
442
+ )
443
+ if not escalated:
444
+ return False
445
+ uids_metas = [
446
+ (Job.from_dict(jd).uid,
447
+ {"error": _ERR_DEADLOCK_GAP, "root_cause": True})
448
+ for jd in state.queue
449
+ ]
450
+ remaining_queue = []
451
+ else:
452
+ logger.error(
453
+ f"Deadlock detected: dependency cycle among {len(cycle_uids)} job(s): "
454
+ f"{sorted(cycle_uids)}"
455
+ )
456
+ uids_metas, remaining_queue = split_deadlock(
457
+ list(state.queue),
458
+ _ERR_DEPENDENCY_DEADLOCK,
459
+ extract_uid=lambda jd: Job.from_dict(jd).uid,
460
+ include=lambda idx, uid, roots=cycle_uids: uid in roots,
461
+ )
462
+ else:
463
+ # 理论不可达(scheduler 的分类链应覆盖全部死锁
464
+ # 归因;未来新增归因类别若漏进分类链,整队列 DLQ 会误杀全部
465
+ # 在途任务)。保守动作:记录 + 短退避,下一轮再判。同环空分支
466
+ # ——连续轮次计数,达阈值升级为整队列 DLQ(专属错误码),
467
+ # 防永久挂起。(与环空分支共用 gap 升级逻辑)
468
+ escalated = self._deadlock_gap_or_escalate(
469
+ "Deadlock: unclassifiable deadlock (no known root cause)"
470
+ )
471
+ if not escalated:
472
+ return False
473
+ uids_metas = [
474
+ (Job.from_dict(jd).uid,
475
+ {"error": _ERR_DEADLOCK_GAP, "root_cause": True})
476
+ for jd in state.queue
477
+ ]
478
+ remaining_queue = []
479
+
480
+ committed = self._ctx.backend.commit_bulk_failure(uids_metas)
481
+ if committed:
482
+ # 成功分类并 DLQ 落地 → 重置分类缺口计数(保守分支未达
483
+ # 阈值时保持累计,达阈值升级后 run 随即终止,计数随 run 重建)。
484
+ self._ctx.deadlock_gap_rounds = 0
485
+ for uid, meta in uids_metas:
486
+ # 死锁批量:uid 在队列中,豁免集合由下方 replace_queue 重建
487
+ self.apply_failed(uid, meta, unregister=False)
488
+ # 死锁批量 DLQ 终态触发钩子——
489
+ # on_job_completed 承诺「每个 job 终结」应覆盖批量终态。
490
+ self._ctx.fire_job_completed(uid, meta, False, False)
491
+ state.replace_queue(remaining_queue)
492
+ return not remaining_queue
493
+ # 3-strike:逐 job 计数,达阈值转 DLQ,未达保留。
494
+ # 计数持久化于 job_dict(_commit_failures),重启后继续累计——
495
+ # 持久性 DB 故障下不无限崩溃循环。
496
+ queue, kept = self.commit_bulk_failed_crash(
497
+ "commit_bulk_failure", uids_metas, list(state.queue)
498
+ )
499
+ state.replace_queue(queue)
500
+ if kept:
501
+ raise _CommitCrashSignal(
502
+ f"Backend commit_bulk_failure returned False for "
503
+ f"{len(uids_metas)} deadlock job(s); {len(queue)} kept in queue "
504
+ f"with incremented _commit_failures (3-strike will DLQ them). "
505
+ f"Crashing to retry; on-disk queue preserved."
506
+ )
507
+ # 全部死锁 job 已达阈值且单条 DLQ 成功 → 终局,不崩溃
508
+ return not queue
509
+
@@ -0,0 +1,25 @@
1
+ """in-flight job 数据类(dispatch / completion / recovery 三机器共享)。"""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, Optional, Tuple
5
+
6
+ from ..models.job import Job
7
+ from .executor import JobHandle
8
+
9
+
10
+ @dataclass
11
+ class InFlightJob:
12
+ """一个已派发到子进程、尚未 commit 的 job 的上下文。
13
+
14
+ 由 DispatchMachine 创建,传给 CompletionMachine 处理结果。
15
+ ``acquired`` 记录已 acquire 的资源,``complete_job`` 的 finally 块释放。
16
+ ``handle is None`` 表示伪 entry(崩溃恢复/abort 消费路径),此时
17
+ ``expect_in_flight=False``、``acquired=[]``。
18
+ """
19
+
20
+ uid: str
21
+ job_dict: dict
22
+ job: Job
23
+ acquired: List[Tuple[str, float]]
24
+ handle: Optional[JobHandle]
25
+ job_start: Optional[float]