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
tasklite/engine/retry.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""重试与退避的纯逻辑。
|
|
2
|
+
|
|
3
|
+
与 pipeline 的边界:本模块不含任何状态——输入输出皆为纯值。退避计算
|
|
4
|
+
(``compute_backoff``)与 rerun 策略判定(``rerun_skips``/``input_changed``)
|
|
5
|
+
是调度循环的纯逻辑部分。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("tasklite")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compute_backoff(
|
|
19
|
+
retries: int,
|
|
20
|
+
backoff_base: float = 2.0,
|
|
21
|
+
backoff_max: float = 300.0,
|
|
22
|
+
) -> float:
|
|
23
|
+
"""Compute exponential backoff delay with ±25% jitter.
|
|
24
|
+
|
|
25
|
+
Formula: min(base * 2^(retries-1), backoff_max) * [0.75, 1.25]
|
|
26
|
+
|
|
27
|
+
Returns a non-negative delay in seconds.
|
|
28
|
+
- ``retries <= 0`` returns 0.0 immediately (no delay for first attempt).
|
|
29
|
+
- Negative retries are treated as 0 with a warning.
|
|
30
|
+
- NaN/Inf inputs for backoff_base/backoff_max fall back to defaults (2.0/300.0).
|
|
31
|
+
"""
|
|
32
|
+
if retries < 0:
|
|
33
|
+
logger.warning(f"compute_backoff called with negative retries={retries}, treating as 0")
|
|
34
|
+
return 0.0
|
|
35
|
+
if retries == 0:
|
|
36
|
+
return 0.0
|
|
37
|
+
# 防御:NaN/Inf 输入 fallback 到默认值(Job.__init__ 已校验,此处防御直接调用)
|
|
38
|
+
if not math.isfinite(backoff_base) or backoff_base < 0:
|
|
39
|
+
backoff_base = 2.0
|
|
40
|
+
if not math.isfinite(backoff_max) or backoff_max < 0:
|
|
41
|
+
backoff_max = 300.0
|
|
42
|
+
# retries 很大时 2**(retries-1) 会溢出 float
|
|
43
|
+
# (OverflowError: int too large to convert to float,retries>=~1025),
|
|
44
|
+
# 直接炸掉重试路径。封顶指数(2**60 远高于任何合理的 backoff_max,
|
|
45
|
+
# 随后的 min(..., backoff_max) 兜底),保持公式语义不变。
|
|
46
|
+
exp = min(retries - 1, 60)
|
|
47
|
+
base_delay = backoff_base * (2 ** exp)
|
|
48
|
+
delay = min(base_delay, backoff_max)
|
|
49
|
+
jitter = random.uniform(-0.25, 0.25) * delay
|
|
50
|
+
return max(0.0, delay + jitter)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def input_changed(wall_meta: dict) -> bool:
|
|
54
|
+
"""on_input_change:wall 里的输入指纹与当前文件重新 stat 比对。
|
|
55
|
+
|
|
56
|
+
任一文件输入 size/mtime_ns 变化(或文件消失、
|
|
57
|
+
或 wall 无历史指纹)→ 视为变化(True)→ 重跑。URI 声明不参与
|
|
58
|
+
比对(变更检测默认关闭)。
|
|
59
|
+
|
|
60
|
+
纯静态逻辑(无 self 依赖;比对只依赖 wall 历史指纹与磁盘现状)。
|
|
61
|
+
"""
|
|
62
|
+
prev_inputs = wall_meta.get("inputs") if isinstance(wall_meta, dict) else None
|
|
63
|
+
# meta["inputs"] 脏数据防御——非 list(dict/str)按无历史指纹处理,
|
|
64
|
+
# 避免 for 迭代出 str 后 entry.get AttributeError 在加载期崩溃。
|
|
65
|
+
if not isinstance(prev_inputs, list):
|
|
66
|
+
return True # 无历史指纹/损坏 → 首次 on_input_change,视为变化
|
|
67
|
+
# per-element 防御——损坏的 wall 指纹若为 ``[非 dict]``
|
|
68
|
+
# (如 [42, "str"]),``entry.get`` 会 AttributeError 崩掉 run(加载期
|
|
69
|
+
# 判定 on_input_change 时)。非 dict 元素视为损坏条目,整个按无历史
|
|
70
|
+
# 指纹处理(返回 True = 视为变化重跑,fail-safe)。
|
|
71
|
+
for entry in prev_inputs:
|
|
72
|
+
if not isinstance(entry, dict):
|
|
73
|
+
return True
|
|
74
|
+
if entry.get("kind") == "uri":
|
|
75
|
+
continue
|
|
76
|
+
path = entry.get("path")
|
|
77
|
+
if not path:
|
|
78
|
+
continue
|
|
79
|
+
if not isinstance(path, str):
|
|
80
|
+
# path 为 dict/list 等真值但非 str 时,
|
|
81
|
+
# os.stat(path) 抛 TypeError(不被 except OSError 捕获)→ 穿透
|
|
82
|
+
# run 崩溃。视为损坏条目 → 返回 True(变化重跑,fail-safe)。
|
|
83
|
+
return True
|
|
84
|
+
size = entry.get("size")
|
|
85
|
+
mtime_ns = entry.get("mtime_ns")
|
|
86
|
+
if size is None or mtime_ns is None:
|
|
87
|
+
return True # 历史指纹缺失(stat 失败过)→ 视为变化
|
|
88
|
+
try:
|
|
89
|
+
st = os.stat(path)
|
|
90
|
+
except OSError:
|
|
91
|
+
return True # 文件消失 → 变化
|
|
92
|
+
if st.st_size != size or st.st_mtime_ns != mtime_ns:
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def rerun_skips(
|
|
98
|
+
job_dict: dict, *, wall_hit: bool, failed_hit: bool,
|
|
99
|
+
wall_meta: Optional[dict] = None,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""rerun 策略决定 wall/failed 命中是否拦截(True=跳过,False=放行重跑)。
|
|
102
|
+
|
|
103
|
+
策略只豁免 **wall/failed 集合**的拦截;queue/in-flight
|
|
104
|
+
永远算数(同一轮内不重复派发/并发双跑)。调用方必须先确认命中来自
|
|
105
|
+
wall/failed(而非 queue/in-flight),再传入对应的 wall_hit/failed_hit。
|
|
106
|
+
|
|
107
|
+
策略矩阵(None/缺键同按 "never"——未指定哨兵语义):
|
|
108
|
+
- None / "never":wall/failed 都拦截(现状语义);
|
|
109
|
+
- "on_failure":仅 failed 拦截豁免(重跑),wall 仍拦截;
|
|
110
|
+
- "every_run":wall/failed 都豁免(重跑);
|
|
111
|
+
- "on_input_change":failed 命中豁免;wall 命中比对输入指纹
|
|
112
|
+
(``input_changed``)——变则豁免(重跑),
|
|
113
|
+
不变则拦截。需要调用方传入 ``wall_meta``(wall 中的旧指纹)。
|
|
114
|
+
"""
|
|
115
|
+
# None(未指定哨兵)与缺键同按 "never" 处理
|
|
116
|
+
rerun = job_dict.get("rerun") or "never"
|
|
117
|
+
if rerun == "every_run":
|
|
118
|
+
return False
|
|
119
|
+
if rerun == "on_failure":
|
|
120
|
+
return not failed_hit # failed 命中豁免;wall 命中拦截
|
|
121
|
+
if rerun == "on_input_change":
|
|
122
|
+
if failed_hit:
|
|
123
|
+
return False
|
|
124
|
+
if wall_hit:
|
|
125
|
+
return not input_changed(wall_meta or {})
|
|
126
|
+
return True
|
|
127
|
+
return True # never
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_discovery_rerun(
|
|
131
|
+
job_dict: dict, task_type: str, discovery_rerun: dict,
|
|
132
|
+
) -> bool:
|
|
133
|
+
"""discovery 默认 rerun 注入单点。
|
|
134
|
+
|
|
135
|
+
Job.rerun=None 哨兵区分「未指定」(注入 ``set_discovery_rerun`` 登记
|
|
136
|
+
的默认策略)与「显式指定」(含显式 "never",一律尊重——不区分的话
|
|
137
|
+
显式 never 会被默认策略静默覆盖)。enqueue 与 spawn 两条路径都必须
|
|
138
|
+
经本函数注入。
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
bool: 是否发生了注入。
|
|
142
|
+
"""
|
|
143
|
+
disc_rerun = discovery_rerun.get(task_type)
|
|
144
|
+
if disc_rerun and job_dict.get("rerun") is None:
|
|
145
|
+
job_dict["rerun"] = disc_rerun
|
|
146
|
+
return True
|
|
147
|
+
return False
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""一次 run() 的运行上下文(RunContext)——一次 run 内跨职责共享的
|
|
2
|
+
可变运行态真相源。
|
|
3
|
+
|
|
4
|
+
把「一次 run 内跨职责共享的可变运行态」从 TaskLite 宿主属性收敛为
|
|
5
|
+
独立的 ``RunContext``:调度循环 / 派发 / 完成 / 失败机器从同一个
|
|
6
|
+
``RunContext`` 取依赖,不依赖宿主类杂散属性。
|
|
7
|
+
|
|
8
|
+
归属划分:
|
|
9
|
+
- 常驻服务引用(构造注入,跨 run 存活):``backend``/``scheduler``/
|
|
10
|
+
``resources``/``handlers``/``executor``/``ipc_dir``/``output_root``/
|
|
11
|
+
``transient_registry``/``discovery_rerun``。
|
|
12
|
+
- 每-run 可变运行态(``run()`` 建立):``state``(PipelineState)、
|
|
13
|
+
``in_flight``、``run_id``/``dispatch_seq``(fencing)、``stop_mode``
|
|
14
|
+
(停机状态机,单枚举)、``stats``(计数)、episode 态
|
|
15
|
+
``dep_grace_*``/``deadlock_gap_rounds``。
|
|
16
|
+
- 钩子单一出口:``fire_job_completed``(异常隔离 + hook_errors 计数)。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import enum
|
|
20
|
+
import logging
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Tuple, Union
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from ..backend.base import AbstractStateBackend
|
|
28
|
+
from .scheduler import Scheduler
|
|
29
|
+
from ..models.resource import Resource
|
|
30
|
+
from ..models.handler import HandlerEntry
|
|
31
|
+
from .executor import SubprocessExecutor
|
|
32
|
+
from ..models.exceptions_registry import TransientExceptionRegistry
|
|
33
|
+
|
|
34
|
+
from .failure import (
|
|
35
|
+
COMMIT_FAILURE_DLQ_THRESHOLD,
|
|
36
|
+
DEADLOCK_GAP_MAX_ROUNDS,
|
|
37
|
+
DEP_GRACE_SECONDS,
|
|
38
|
+
)
|
|
39
|
+
from ..models.state import PipelineState
|
|
40
|
+
from ..utils.jsonutil import dumps
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger("tasklite")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class EpisodeState:
|
|
47
|
+
"""一次 run 内跨轮累计的治理状态(死锁宽限与缺口升级跟踪)。"""
|
|
48
|
+
|
|
49
|
+
dep_grace_deadline: Optional[float] = None
|
|
50
|
+
dep_grace_missing: Optional[frozenset] = None
|
|
51
|
+
deadlock_gap_rounds: int = 0
|
|
52
|
+
|
|
53
|
+
def reset(self) -> None:
|
|
54
|
+
"""重置所有 episode 状态。"""
|
|
55
|
+
self.dep_grace_deadline = None
|
|
56
|
+
self.dep_grace_missing = None
|
|
57
|
+
self.deadlock_gap_rounds = 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StopMode(enum.Enum):
|
|
61
|
+
"""停机状态机三态。
|
|
62
|
+
|
|
63
|
+
- NONE:正常运行;
|
|
64
|
+
- DRAINING:请求优雅停机——不派发新 job,等 in-flight 自然完成后退出
|
|
65
|
+
(stop / 首次 SIGTERM/SIGINT);
|
|
66
|
+
- ABORTING:强制停机——分类消费在途任务(已完成者照常提交),进行中
|
|
67
|
+
者 kill + 清半成品 + requeue(stop(force=True) / 二次信号)。
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
NONE = "none"
|
|
71
|
+
DRAINING = "draining"
|
|
72
|
+
ABORTING = "aborting"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# 内部 worker 资源名:每个 job 默认占用 1 个 worker 槽位(放 runtime
|
|
76
|
+
# 避免 dispatch/completion 反向 import pipeline)。
|
|
77
|
+
WORKER_RESOURCE = "__workers__"
|
|
78
|
+
|
|
79
|
+
# 资源 suspend 状态在 meta 表的存储键({资源名: wall-clock 截止时刻})。
|
|
80
|
+
META_RESOURCE_SUSPENDS = "resource_suspends"
|
|
81
|
+
|
|
82
|
+
# 运行统计初始值(放 runtime 避免循环 import;hook_errors/deferred_orphan
|
|
83
|
+
# 是运行期动态键,预置后累加不依赖拼写正确)。
|
|
84
|
+
# job_dict["runtime"] 框架寄生键(常量集中管理:字符串字面量散落各处
|
|
85
|
+
# 易拼错且无 IDE 提示,统一常量并集中于此)。下划线前缀=框架内部命名空间,
|
|
86
|
+
# 业务不得读写。
|
|
87
|
+
RT_BACKOFF_UNTIL = "_backoff_until" # monotonic 退避截止(内存态)
|
|
88
|
+
RT_BACKOFF_WALL_DEADLINE = "_backoff_wall_deadline" # wall-clock 退避截止(持久化)
|
|
89
|
+
RT_COMMIT_FAILURES = "_commit_failures" # commit 失败 3-strike 计数
|
|
90
|
+
|
|
91
|
+
EMPTY_STATS = {"completed": 0, "failed": 0, "retried": 0, "skipped": 0,
|
|
92
|
+
"hook_errors": 0, "deferred_orphan": 0, "interrupted_reruns": 0,
|
|
93
|
+
# 因上游失败被级联阻断的下游数(JOB_DEPENDENCY)——与真正
|
|
94
|
+
# 执行失败的 failed 分开统计,DLQ 规模不被无辜下游膨胀。
|
|
95
|
+
"cascade_failed": 0}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TaskStats(dict):
|
|
99
|
+
"""运行统计的 dict 子类(仍是 dict,但提供类型化只读属性)。
|
|
100
|
+
|
|
101
|
+
``stats["completed"]`` 与 ``stats.completed`` 两种写法并存(属性
|
|
102
|
+
改善可读性与 IDE 提示);值始终为 int。
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self) -> None:
|
|
106
|
+
super().__init__(EMPTY_STATS)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def completed(self) -> int:
|
|
110
|
+
return self["completed"]
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def failed(self) -> int:
|
|
114
|
+
return self["failed"]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def retried(self) -> int:
|
|
118
|
+
return self["retried"]
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def skipped(self) -> int:
|
|
122
|
+
return self["skipped"]
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def hook_errors(self) -> int:
|
|
126
|
+
return self["hook_errors"]
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def deferred_orphan(self) -> int:
|
|
130
|
+
return self["deferred_orphan"]
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def interrupted_reruns(self) -> int:
|
|
134
|
+
return self["interrupted_reruns"]
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def cascade_failed(self) -> int:
|
|
138
|
+
return self["cascade_failed"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def inject_worker_resource(job_dict: dict) -> None:
|
|
142
|
+
"""给 job_dict 的 resources 注入默认 worker 槽位(若未显式声明)。
|
|
143
|
+
|
|
144
|
+
这样 ``__workers__`` 进入 job_dict 的 resources,scheduler 的
|
|
145
|
+
``can_acquire`` 检查和 DispatchMachine 的 acquire/release 自然处理它。
|
|
146
|
+
用户可显式声明 ``resources={"__workers__": 2.0}`` 占多槽位。
|
|
147
|
+
|
|
148
|
+
``Job.to_dict()`` 返回的 ``resources`` 是原 Job 对象的引用,这里必须
|
|
149
|
+
用 ``dict(...)`` 拷贝后替换,避免污染传入的 Job 对象。
|
|
150
|
+
"""
|
|
151
|
+
resources = dict(job_dict.get("resources", {}))
|
|
152
|
+
if WORKER_RESOURCE not in resources:
|
|
153
|
+
resources[WORKER_RESOURCE] = 1.0
|
|
154
|
+
job_dict["resources"] = resources
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class RunContext:
|
|
158
|
+
"""一次 run() 的运行上下文:跨职责共享的可变运行态容器。"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
name: str,
|
|
164
|
+
backend: "AbstractStateBackend",
|
|
165
|
+
scheduler: "Scheduler",
|
|
166
|
+
resources: Dict[str, "Resource"],
|
|
167
|
+
handlers: Dict[str, "HandlerEntry"],
|
|
168
|
+
executor: "SubprocessExecutor",
|
|
169
|
+
ipc_dir: str,
|
|
170
|
+
output_root: Optional[Union[str, "Path"]],
|
|
171
|
+
on_run_start: Optional[Callable[[], None]],
|
|
172
|
+
on_job_completed: Optional[Callable[[str, dict, bool, bool], None]],
|
|
173
|
+
on_run_end: Optional[Callable[[str], None]],
|
|
174
|
+
transient_registry: "TransientExceptionRegistry",
|
|
175
|
+
discovery_rerun: Dict[str, str],
|
|
176
|
+
fatal_exceptions: Optional[Tuple[type, ...]] = None,
|
|
177
|
+
transient_exceptions: Optional[Tuple[type, ...]] = None,
|
|
178
|
+
dep_grace_seconds: Optional[float] = None,
|
|
179
|
+
commit_failure_dlq_threshold: Optional[int] = None,
|
|
180
|
+
deadlock_gap_max_rounds: Optional[int] = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
# ── 常驻服务引用(构造注入,跨 run 存活)──────────────────
|
|
183
|
+
self.name = name
|
|
184
|
+
self.backend = backend
|
|
185
|
+
self.scheduler = scheduler
|
|
186
|
+
self.resources = resources
|
|
187
|
+
self.handlers = handlers
|
|
188
|
+
self.executor = executor
|
|
189
|
+
self.ipc_dir = ipc_dir
|
|
190
|
+
self.output_root = output_root
|
|
191
|
+
self.transient_registry = transient_registry
|
|
192
|
+
# per-pipeline 异常分类覆盖(None=用 exceptions 模块默认元组)。
|
|
193
|
+
# 与瞬态注册表同纪律:快照随 ctx pickle 下发子进程,分类决策
|
|
194
|
+
# 不读模块级可变全局。
|
|
195
|
+
self.fatal_exceptions: Optional[tuple] = (
|
|
196
|
+
tuple(fatal_exceptions) if fatal_exceptions is not None else None)
|
|
197
|
+
self.transient_exceptions: Optional[tuple] = (
|
|
198
|
+
tuple(transient_exceptions) if transient_exceptions is not None else None)
|
|
199
|
+
self.discovery_rerun = discovery_rerun
|
|
200
|
+
# 引擎阈值可配(支持外部配置:None=沿用 failure.py 模块默认)——
|
|
201
|
+
# 不同业务对「依赖宽限时长 / commit 崩溃容忍度 / 死锁观察轮数」
|
|
202
|
+
# 的合理值差异很大,不应硬编码。
|
|
203
|
+
self.dep_grace_seconds: float = (
|
|
204
|
+
float(dep_grace_seconds) if dep_grace_seconds is not None else DEP_GRACE_SECONDS)
|
|
205
|
+
self.commit_failure_dlq_threshold: int = (
|
|
206
|
+
int(commit_failure_dlq_threshold)
|
|
207
|
+
if commit_failure_dlq_threshold is not None else COMMIT_FAILURE_DLQ_THRESHOLD)
|
|
208
|
+
self.deadlock_gap_max_rounds: int = (
|
|
209
|
+
int(deadlock_gap_max_rounds)
|
|
210
|
+
if deadlock_gap_max_rounds is not None else DEADLOCK_GAP_MAX_ROUNDS)
|
|
211
|
+
# ── 钩子(单一出口 fire_job_completed/fire_run_end,异常隔离)──
|
|
212
|
+
self.on_run_start = on_run_start
|
|
213
|
+
self.on_job_completed = on_job_completed
|
|
214
|
+
self.on_run_end = on_run_end
|
|
215
|
+
# ── 每-run 可变运行态(run 建立)────────────────────
|
|
216
|
+
self.state: Optional[PipelineState] = None
|
|
217
|
+
self.in_flight: Dict[str, Any] = {}
|
|
218
|
+
self.run_id: Optional[str] = None
|
|
219
|
+
self.dispatch_seq: int = 0
|
|
220
|
+
# 停机状态机:单枚举——双独立 bool 可组合出非法状态(force 而未
|
|
221
|
+
# 请求停机),枚举从类型上保证状态合法且互斥。
|
|
222
|
+
self.stop_mode: StopMode = StopMode.NONE
|
|
223
|
+
self.stats: TaskStats = TaskStats()
|
|
224
|
+
# episode 态(一次 run 内跨轮累计,独立容器存储)
|
|
225
|
+
self.episode: EpisodeState = EpisodeState()
|
|
226
|
+
# on_run_end 幂等标志——run 的 finally 与 LoopRunner 都可能触发,
|
|
227
|
+
# 保证整个 run 只调用一次。
|
|
228
|
+
self._run_end_fired = False
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def dep_grace_deadline(self) -> Optional[float]:
|
|
232
|
+
return self.episode.dep_grace_deadline
|
|
233
|
+
|
|
234
|
+
@dep_grace_deadline.setter
|
|
235
|
+
def dep_grace_deadline(self, value: Optional[float]) -> None:
|
|
236
|
+
self.episode.dep_grace_deadline = value
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def dep_grace_missing(self) -> Optional[frozenset]:
|
|
240
|
+
return self.episode.dep_grace_missing
|
|
241
|
+
|
|
242
|
+
@dep_grace_missing.setter
|
|
243
|
+
def dep_grace_missing(self, value: Optional[frozenset]) -> None:
|
|
244
|
+
self.episode.dep_grace_missing = value
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def deadlock_gap_rounds(self) -> int:
|
|
248
|
+
return self.episode.deadlock_gap_rounds
|
|
249
|
+
|
|
250
|
+
@deadlock_gap_rounds.setter
|
|
251
|
+
def deadlock_gap_rounds(self, value: int) -> None:
|
|
252
|
+
self.episode.deadlock_gap_rounds = value
|
|
253
|
+
|
|
254
|
+
def reset_episode(self) -> None:
|
|
255
|
+
"""重置 episode 态(新 run 开始时调用)。"""
|
|
256
|
+
self.episode.reset()
|
|
257
|
+
|
|
258
|
+
def set_state(self, state: PipelineState) -> None:
|
|
259
|
+
"""装载本次 run 的 PipelineState(``_run_body`` 加载修复后调用)。"""
|
|
260
|
+
self.state = state
|
|
261
|
+
|
|
262
|
+
def persist_resource_suspends_now(self) -> None:
|
|
263
|
+
"""把资源挂起截止**即时**持久化到 meta 表。
|
|
264
|
+
|
|
265
|
+
挂起的应用点(completion.apply_result /
|
|
266
|
+
recovery.apply_pending_signals)调用本方法即时落盘——仅靠 run
|
|
267
|
+
收尾持久化的话,kill -9/OOM/断电时运行中累计的挂起全部丢失,
|
|
268
|
+
重启后全速重打正在限流本机的 API(README 主打的崩溃安全场景
|
|
269
|
+
失效)。幂等 UPSERT,成本一次小事务,仅在确有挂起时写入。
|
|
270
|
+
RecoveryMachine.persist_resource_suspends(run 收尾单点)保留为
|
|
271
|
+
薄转发以兼容既有调用面与既有契约测试。
|
|
272
|
+
|
|
273
|
+
suspend_until/next_available 是 monotonic 时钟(系统重启归零),
|
|
274
|
+
换算为 wall-clock 截止(time.time + 剩余秒),加载时反向换算;
|
|
275
|
+
全部无挂起时写空映射清除旧数据。失败 error 级日志(与
|
|
276
|
+
last_run_id 的 fail-loud 策略对齐——静默丢失正是要消除的)。
|
|
277
|
+
"""
|
|
278
|
+
now = time.monotonic()
|
|
279
|
+
wall_now = time.time()
|
|
280
|
+
deadlines = {}
|
|
281
|
+
for res_name, res in self.resources.items():
|
|
282
|
+
# 统一协议访问器 suspended_until(防新增 Resource 实现
|
|
283
|
+
# 静默漏持久化)
|
|
284
|
+
deadline = res.suspended_until()
|
|
285
|
+
if deadline is None:
|
|
286
|
+
continue
|
|
287
|
+
remaining = deadline - now
|
|
288
|
+
if remaining <= 0:
|
|
289
|
+
continue
|
|
290
|
+
deadlines[res_name] = wall_now + remaining
|
|
291
|
+
try:
|
|
292
|
+
self.backend.set_meta(META_RESOURCE_SUSPENDS, dumps(deadlines))
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.error(f"Failed to persist resource suspends to meta: {e}")
|
|
295
|
+
|
|
296
|
+
def fire_job_completed(
|
|
297
|
+
self, uid: str, meta: Dict[str, Any], success: bool, going_to_retry: bool,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""钩子单一出口:所有 job 终结路径都经此触发。
|
|
300
|
+
|
|
301
|
+
钩子按不可信代码对待:抛异常 catch + ``stats["hook_errors"]`` 计数,
|
|
302
|
+
绝不影响主循环。``on_job_completed is None`` 时直接返回。
|
|
303
|
+
"""
|
|
304
|
+
if self.on_job_completed is None:
|
|
305
|
+
return
|
|
306
|
+
try:
|
|
307
|
+
self.on_job_completed(uid, meta, success, going_to_retry)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.warning(f"on_job_completed hook raised for {uid}: {e}")
|
|
310
|
+
self.stats["hook_errors"] += 1
|
|
311
|
+
|
|
312
|
+
def reset_run_end_fired(self) -> None:
|
|
313
|
+
"""重置 on_run_end 幂等标志(新 run 开始时由宿主 pipeline 调用)。
|
|
314
|
+
|
|
315
|
+
幂等标志是 RunContext 的私有运行态——跨模块直写私有属性属
|
|
316
|
+
违规访问,经本方法收敛为公开出口。
|
|
317
|
+
"""
|
|
318
|
+
self._run_end_fired = False
|
|
319
|
+
|
|
320
|
+
def fire_run_end(self, reason: str) -> None:
|
|
321
|
+
"""on_run_end 钩子单一出口(run 正常/中断/崩溃统一触发,幂等)。"""
|
|
322
|
+
if self._run_end_fired:
|
|
323
|
+
return
|
|
324
|
+
self._run_end_fired = True
|
|
325
|
+
if self.on_run_end is None:
|
|
326
|
+
return
|
|
327
|
+
try:
|
|
328
|
+
self.on_run_end(reason)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.warning(f"on_run_end hook raised: {e}")
|
|
331
|
+
self.stats["hook_errors"] += 1
|