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,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