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,313 @@
1
+ """TaskContext for handler execution in tasklite."""
2
+
3
+ import logging
4
+ import math
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Tuple, Union
8
+
9
+ from .job import Job
10
+ from ..utils.ipc import append_input, append_output, append_signal
11
+ from ..utils.jsonutil import dumps
12
+
13
+ logger = logging.getLogger("tasklite")
14
+
15
+
16
+ class TaskContext:
17
+ """Context object passed to handlers, providing spawn, output, cursor, and resource APIs."""
18
+
19
+ def __init__(
20
+ self,
21
+ job: Job,
22
+ wall_keys: set,
23
+ failed_keys: set,
24
+ cursors: Dict[str, str],
25
+ output_root: Optional[Path] = None,
26
+ ipc_dir: Optional[str] = None,
27
+ incarnation: Optional[str] = None,
28
+ transient_registry: Tuple = (),
29
+ resource_names: Optional[Union[frozenset, set]] = None,
30
+ ):
31
+ self.job = job
32
+ self.new_jobs: List[Job] = []
33
+ self.resource_suspensions: List[Tuple[str, float]] = []
34
+ self._wall_keys = wall_keys
35
+ self._failed_keys = failed_keys
36
+ self._cursors = cursors
37
+ self.cursor_updates: Dict[str, str] = {}
38
+ # 已注册资源名快照——suspend_resource 据此 fail-loud。
39
+ # None(直接构造 ctx 的旧测试路径)表示不校验;生产派发路径必传。
40
+ self._resource_names: Optional[frozenset] = (
41
+ frozenset(resource_names) if resource_names is not None else None
42
+ )
43
+ # output_root 支持多根(跨盘输出场景)——list 时
44
+ # 路径属于**任一**根即通过沙盒校验。
45
+ self.output_root = output_root
46
+ # 注册表快照契约:瞬态异常注册表快照随 ctx pickle 下发子进程——
47
+ # spawn 子进程不继承父进程函数作用域的注册,分类决策在子进程发生,
48
+ # 快照在此携带、worker 入口重放(见 executor._mp_worker_wrapper)。
49
+ self.transient_registry = tuple(transient_registry)
50
+ # suspend 信号走落盘文件({ipc_dir}/{uid}.signals.jsonl)——
51
+ # 进程被 kill 后文件仍在,信号不丢。
52
+ self.ipc_dir = ipc_dir
53
+ # fencing:当前执行代标识 {run_id}.{seq},由 _dispatch_job
54
+ # 在 submit 前写入。worker 写结果文件时用它构造带 incarnation 的
55
+ # 文件名——崩溃后孤儿进程的旧 incarnation 文件不被新 run 看见。
56
+ self.incarnation = incarnation
57
+
58
+ def spawn(self, job: Job) -> None:
59
+ """Enqueue a child job.
60
+
61
+ 在子进程内立即预检 JSON 可序列化性(与 enqueue 的序列化预检对齐)。
62
+ 坏 payload 在此抛 ValueError → handler 异常 → 父作业进入 DLQ,
63
+ 而不是在 commit 阶段才崩掉整个 run。
64
+ """
65
+ try:
66
+ # allow_nan=False —— 默认 allow_nan=True 会让
67
+ # float('inf')/float('nan') 通过预检,产出非标准 JSON "Infinity"。
68
+ dumps(job.to_dict())
69
+ except (TypeError, ValueError) as e:
70
+ raise ValueError(
71
+ f"Payload for spawned job {job.uid} is not JSON-serializable: {e}"
72
+ ) from e
73
+ self.new_jobs.append(job)
74
+
75
+ def declare_output(self, path: Union[str, Path], cleanup_on_fail: bool = True, *, sandbox: bool = True) -> str:
76
+ """Declare an output file. Validates path sandbox if output_root is set.
77
+
78
+ Automatically creates parent directories. On job failure (or retry),
79
+ declared outputs with ``cleanup_on_fail=True`` are deleted to prevent
80
+ half-written files from persisting.
81
+
82
+ ``sandbox=False`` 显式豁免路径沙盒(跨盘输出等
83
+ 无法归入任何 output_root 的场景)——放弃越界保护,与
84
+ ``output_root=None`` 同语义,但逐路径声明而非全局关闭。
85
+
86
+ Returns:
87
+ The **resolved absolute path** that the framework will verify/clean —
88
+ handler 应用返回值写文件(而非原始参数),保证写入位置与校验/
89
+ 清理位置一致(相对路径按 output_root 重定位,原始
90
+ 相对字符串按 CWD 写会与校验脱节 → 误判 Missing output)。
91
+ """
92
+ return self._declare(path, cleanup_on_fail, sandbox, "output")
93
+
94
+ def declare_cache(self, path: Union[str, Path], *, sandbox: bool = True) -> str:
95
+ """Declare a **temporary/cache** file.
96
+
97
+ 语义:任务结束时(无论成败)该文件**不应存在**。
98
+ - 成功路径:**跳过存在性校验**(原子产出的 ``.part`` 已被
99
+ ``os.replace`` 到最终路径,校验必然失败),并 best-effort 尝试
100
+ 删除(rename 已发生则 no-op);
101
+ - 失败/中止路径:删除半成品。
102
+
103
+ 标准用法(原子产出模式):``tmp = ctx.declare_cache("./v.mp4.part")``
104
+ 写 tmp → 自校验 → ``os.replace(tmp, final)``;最终路径用
105
+ ``ctx.declare_output("./v.mp4")`` 声明(成功校验存在 + 失败清理)。
106
+
107
+ 路径沙盒与 ``declare_output`` 相同:``sandbox=False`` 显式豁免
108
+ (跨盘缓存等无法归入任何 output_root 的场景)——放弃越界保护,
109
+ 逐路径声明而非全局关闭。
110
+ 返回解析后的规范绝对路径。
111
+ """
112
+ return self._declare(path, True, sandbox, "cache")
113
+
114
+ def _declare(self, path: Union[str, Path], cleanup_on_fail: bool, sandbox: bool, kind: str) -> str:
115
+ """declare_output/declare_cache 的共享实现(消除重复)。
116
+
117
+ kind 为 "output" 或 "cache"——仅影响 append_output 落盘的声明
118
+ kind(成功路径的校验/清理行为差异由 executor 侧按 kind 分发)。
119
+ """
120
+ raw = str(path)
121
+ if "\x00" in raw:
122
+ raise ValueError(f"Output path contains a null byte: {raw!r}")
123
+
124
+ resolved = self._resolve_path(raw, sandbox)
125
+ parent = Path(resolved).parent
126
+ parent.mkdir(parents=True, exist_ok=True)
127
+ # 输出声明立即落盘({ipc_dir}/{uid}.outputs.jsonl)——主进程的
128
+ # 输出校验与失败清理从落盘文件读,不依赖 mp.Manager 共享 list
129
+ # (Manager 是独立 server 进程的隐性单点 + 每次声明一次 RPC 往返)。
130
+ # handler 崩溃/kill 后声明仍可读取(与 signals 同款语义)。
131
+ if self.ipc_dir is not None:
132
+ try:
133
+ append_output(self.ipc_dir, self.job.uid, resolved, cleanup_on_fail, kind=kind)
134
+ except OSError:
135
+ pass # 落盘失败静默:声明丢失只影响失败清理,不影响执行
136
+ # 返回解析后的规范绝对路径,handler 应使用返回值写文件
137
+ return resolved
138
+
139
+ def declare_input(self, path: Union[str, Path]) -> str:
140
+ """声明一个**输入文件**——记录 + 采集指纹,返回规范绝对路径。
141
+
142
+ 注意:本方法**没有** ``sandbox`` 参数,这是有意设计——输入声明只记录
143
+ 路径与 stat 指纹,框架不写该文件,因此不存在输出沙盒/清理语义;
144
+ 与 ``declare_output/declare_cache`` 的路径沙盒无关。
145
+
146
+ 指纹 ``{path, size, mtime_ns}``(stat 调用,微秒级,不用内容 hash
147
+ 避免大文件读盘)。任务成功时框架把输入清单写入 wall meta
148
+ (``meta["inputs"]``)——排障/审计/种子化脚本都能回答「这个任务
149
+ 当时基于什么输入跑出来的」。
150
+
151
+ 与 ``rerun="on_input_change"`` 联动:下次 enqueue 时框架对
152
+ wall 中的旧指纹重新 stat 比对,任一文件 size/mtime_ns 变化 → 重跑;
153
+ 不变 → 跳过(on_input_change 的变更检测只针对文件输入;URI 默认
154
+ 不检测,见 ``declare_input_uri``)。
155
+
156
+ 指纹在**声明时**采集(handler 要读的输入此刻应已存在);stat 失败
157
+ (文件不存在等)记录 path 但不带指纹(on_input_change 比对时视为
158
+ 变化 → 重跑,避免误跳过)。
159
+ """
160
+ raw = str(path)
161
+ resolved = self._resolve_path(raw, sandbox=False)
162
+ entry: dict = {"path": resolved, "kind": "file"}
163
+ try:
164
+ st = os.stat(resolved)
165
+ entry["size"] = st.st_size
166
+ entry["mtime_ns"] = st.st_mtime_ns
167
+ except OSError:
168
+ pass # 输入缺失:记录 path,指纹留空(on_input_change 视为变化)
169
+ if self.ipc_dir is not None:
170
+ try:
171
+ append_input(self.ipc_dir, self.job.uid, entry)
172
+ except OSError:
173
+ pass # 只捕获 OSError:编程错误不静默吞
174
+ return resolved
175
+
176
+ def declare_input_uri(self, url: Union[str, Path], uri_fingerprint: Optional[str] = None) -> str:
177
+ """声明一个**输入 URI**——仅记录(可追溯),返回 url 字符串。
178
+
179
+ ``uri_fingerprint`` 可选:业务侧提供的指纹字符串(如 ETag/Last-
180
+ Modified),落盘供审计。**不参与 on_input_change 比对**——URI 的
181
+ 内容是否变化需网络请求(ETag/Last-Modified),框架不在派发前免费
182
+ 检查(文档注明:URI 变更检测留给业务,可把指纹拼进 job_id 或由
183
+ 业务自建 checker)。
184
+ """
185
+ if uri_fingerprint is not None and not isinstance(uri_fingerprint, str):
186
+ raise TypeError(
187
+ f"uri_fingerprint must be a str or None, "
188
+ f"got {type(uri_fingerprint).__name__}"
189
+ )
190
+ url = str(url)
191
+ entry: dict = {"path": url, "kind": "uri"}
192
+ if uri_fingerprint is not None:
193
+ entry["uri_fingerprint"] = uri_fingerprint
194
+ if self.ipc_dir is not None:
195
+ try:
196
+ append_input(self.ipc_dir, self.job.uid, entry)
197
+ except OSError:
198
+ pass # 只捕获 OSError:子进程内编程错误不静默吞
199
+ return url
200
+
201
+ def _resolve_path(self, raw: str, sandbox: bool) -> str:
202
+ """解析声明路径为规范绝对路径(沙盒校验 + 相对重定位共用逻辑)。"""
203
+ if self.output_root is not None and sandbox:
204
+ p = Path(raw)
205
+ roots = self.output_root if isinstance(self.output_root, (list, tuple)) else [self.output_root]
206
+ if not p.is_absolute():
207
+ # 相对路径按**第一个**根重定位(兼容语义)
208
+ p = roots[0] / p
209
+ # REQ-10: Path sandbox — reject paths outside every output_root
210
+ resolved_path = Path(os.path.abspath(str(p)))
211
+ try:
212
+ resolved_path = resolved_path.resolve()
213
+ except (OSError, RuntimeError):
214
+ logger.debug(f"Could not resolve output path '{raw}', using abspath fallback.")
215
+ if not any(resolved_path.is_relative_to(r) for r in roots):
216
+ raise ValueError(
217
+ f"Output path '{raw}' resolves outside output_root "
218
+ f"{roots!r}"
219
+ )
220
+ return str(resolved_path)
221
+ # Always resolve parent directory if possible, fall back to absolute
222
+ p = Path(raw)
223
+ try:
224
+ if p.parent.exists():
225
+ return str(p.resolve())
226
+ return os.path.abspath(str(p)) # 折叠 ..,存储规范路径
227
+ except (OSError, RuntimeError):
228
+ return os.path.abspath(str(p))
229
+
230
+ def is_completed(self, uid: str) -> bool:
231
+ """Check if a job is already in wall 快照(已成功完成过的内容)。
232
+
233
+ Snapshot semantics(文档对齐):TaskContext 在**每个 job 派发时**
234
+ 构造,快照 = 该 job 派发时刻的 wall 集合——同一 run 内后派发的 job 能
235
+ 看到先前完成的任务(README 承诺语义)。不反映构造之后其他并发
236
+ in-flight job 的完成。
237
+ """
238
+ return uid in self._wall_keys
239
+
240
+ def is_failed(self, uid: str) -> bool:
241
+ """Check if a job has permanently failed (in the DLQ).
242
+
243
+ Snapshot semantics(文档对齐):同 is_completed——派发时刻快照,
244
+ 不反映构造之后其他并发 in-flight job 的失败。
245
+ """
246
+ return uid in self._failed_keys
247
+
248
+ def attempted_uids(self) -> frozenset:
249
+ """返回 wall∪failed 已见 uid 的只读快照。
250
+
251
+ discovery 的 on_missing(``wrappers/discovery.py``)需要「可迭代的已见
252
+ 集合」——本方法是唯一公开入口,不暴露 ``_wall_keys`` 等私有字段
253
+ (跨模块私有耦合,字段名变更会静默失效)。返回**快照**(frozenset)
254
+ 而非活引用:调用方改动
255
+ 返回值不影响框架内部集合(身份判定基础不被业务 mutate)。
256
+ 快照语义同 ``is_completed``/``is_failed``——构造时刻的 wall/failed 并集。
257
+ """
258
+ return frozenset(self._wall_keys) | frozenset(self._failed_keys)
259
+
260
+ def get_cursor(self, key: str) -> Optional[str]:
261
+ """Retrieve the value of a high-watermark cursor."""
262
+ return self._cursors.get(key)
263
+
264
+ def set_cursor(self, key: str, value: Optional[str]) -> None:
265
+ """Update a cursor. It will be committed atomically when the job succeeds.
266
+
267
+ Both key and value must be strings. Pass ``None`` as value to **delete**
268
+ the cursor(与后端 None-删除语义对齐)。
269
+ """
270
+ if not isinstance(key, str):
271
+ raise TypeError(f"cursor key must be str, got {type(key).__name__}")
272
+ if value is not None and not isinstance(value, str):
273
+ raise TypeError(f"cursor value must be str, got {type(value).__name__}")
274
+ self.cursor_updates[key] = value
275
+ if value is None:
276
+ self._cursors.pop(key, None)
277
+ else:
278
+ self._cursors[key] = value
279
+
280
+ def suspend_resource(self, resource_name: str, seconds: float) -> None:
281
+ """Request global suspension of a resource (e.g., for 429 rate limits).
282
+
283
+ API 边界即校验(与 set_cursor 的严格校验对称)——非数值/非有限/
284
+ 非正值在入口拒绝,而非延迟到 Resource._sanitize_suspend 静默忽略,
285
+ 避免调用方误以为 suspend 已生效。
286
+
287
+ 信号立即追加到 ``{ipc_dir}/{uid}.signals.jsonl``
288
+ (落盘 flush),即使 handler 随后崩溃/超时,限流信息也不丢失。
289
+ 同时保留 resource_suspensions list 供正常完成路径的结果文件携带
290
+ (去重由 suspend 的 max 语义保证)。
291
+ """
292
+ if not isinstance(resource_name, str) or not resource_name:
293
+ raise TypeError(f"resource_name must be a non-empty str, got {resource_name!r}")
294
+ # 未注册资源名 fail-loud——typo(如 "api_v1" vs
295
+ # "api")会静默追加,消费端 apply_result/apply_pending_signals
296
+ # 按 `in resources` 静默跳过 → 用户以为限流已生效、管线继续猛打
297
+ # 目标 API。生产派发路径携带注册集快照,入口即拒绝。
298
+ if self._resource_names is not None and resource_name not in self._resource_names:
299
+ raise ValueError(
300
+ f"Unknown resource {resource_name!r} (registered: "
301
+ f"{sorted(self._resource_names)}); suspend request ignored"
302
+ )
303
+ if not isinstance(seconds, (int, float)) or isinstance(seconds, bool):
304
+ raise TypeError(f"seconds must be a number, got {type(seconds).__name__} ({seconds!r})")
305
+ if not math.isfinite(seconds) or seconds <= 0:
306
+ raise ValueError(f"seconds must be finite and > 0, got {seconds!r}")
307
+ self.resource_suspensions.append((resource_name, seconds))
308
+ if self.ipc_dir is not None:
309
+ try:
310
+ append_signal(self.ipc_dir, self.job.uid, resource_name, seconds)
311
+ except OSError:
312
+ pass # 文件写失败静默丢弃,不影响 handler 执行
313
+ logger.info(f"Task requested global suspension of resource '{resource_name}' for {seconds}s.")
tasklite/models/job.py ADDED
@@ -0,0 +1,304 @@
1
+ """Job data model for tasklite."""
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from ..utils.lockfile import safe_uid_filename
8
+ from ..utils.validation import validate_resource_amounts
9
+
10
+ # uid 派生的 IPC 文件名(锁 / result / signals / outputs)
11
+ # 无截断——job_id/task_type 超长时文件名超 255 字节(EXT4 单文件名字节
12
+ # 上限)→ os.open ENAMETOOLONG → 派发侧异常未分类 → requeue + 退避/崩溃
13
+ # 重启 → livelock(job 永远无法执行且不进 DLQ)。入口按 safe_uid_filename
14
+ # 实际字节数(含转义膨胀)拒绝,fail-fast。
15
+ # 255 - 56 = 199:56 为最坏结果文件后缀 ``.{32-hex run_id}.{seq}.result.json``。
16
+ _MAX_SAFE_UID_BYTES = 199
17
+
18
+
19
+ @dataclass
20
+ class JobRuntimeState:
21
+ """Job 运行期内部边带状态(强类型结构化存储,替代散装字典)。"""
22
+
23
+ backoff_until: Optional[float] = None
24
+ backoff_wall_deadline: Optional[float] = None
25
+ commit_failures: int = 0
26
+ dispatch_failures: int = 0
27
+ last_retry_error: str = ""
28
+
29
+ def to_dict(self) -> Dict[str, Any]:
30
+ d: Dict[str, Any] = {}
31
+ if self.backoff_until is not None:
32
+ d["_backoff_until"] = self.backoff_until
33
+ if self.backoff_wall_deadline is not None:
34
+ d["_backoff_wall_deadline"] = self.backoff_wall_deadline
35
+ if self.commit_failures:
36
+ d["_commit_failures"] = self.commit_failures
37
+ if self.dispatch_failures:
38
+ d["_dispatch_failures"] = self.dispatch_failures
39
+ if self.last_retry_error:
40
+ d["_last_retry_error"] = self.last_retry_error
41
+ return d
42
+
43
+ @classmethod
44
+ def from_dict(cls, data: Optional[Dict[str, Any]]) -> "JobRuntimeState":
45
+ if not isinstance(data, dict):
46
+ return cls()
47
+ return cls(
48
+ backoff_until=data.get("_backoff_until") or data.get("backoff_until"),
49
+ backoff_wall_deadline=data.get("_backoff_wall_deadline") or data.get("backoff_wall_deadline"),
50
+ commit_failures=int(data.get("_commit_failures") or data.get("commit_failures") or 0),
51
+ dispatch_failures=int(data.get("_dispatch_failures") or data.get("dispatch_failures") or 0),
52
+ last_retry_error=str(data.get("_last_retry_error") or data.get("last_retry_error") or ""),
53
+ )
54
+
55
+
56
+ class Job:
57
+ """Represents a discrete unit of work in the pipeline."""
58
+
59
+ def __init__(
60
+ self,
61
+ task_type: str,
62
+ job_id: str,
63
+ payload: Optional[Dict[str, Any]] = None,
64
+ resources: Optional[Dict[str, float]] = None,
65
+ retries: int = 0,
66
+ max_retries: int = 3,
67
+ depends_on: Optional[List[str]] = None,
68
+ timeout: int = 3600,
69
+ backoff_base: float = 2.0,
70
+ backoff_max: float = 300.0,
71
+ timeout_is_transient: bool = False,
72
+ rerun: Optional[str] = None,
73
+ runtime: Optional[Dict[str, Any]] = None,
74
+ ):
75
+ """Initialize a Job.
76
+
77
+ Args:
78
+ task_type: Task type name (must match a registered handler). Must not
79
+ contain ``::`` (used as uid separator).
80
+ job_id: Unique job identifier within the task type. Must not contain ``::``.
81
+ payload: Business data dict passed to handler. Must be JSON-serializable.
82
+ resources: Resource amounts to acquire (overrides handler defaults).
83
+ retries: Current retry count (internal, managed by framework).
84
+ max_retries: Max retry attempts before DLQ. Must be >= 0.
85
+ depends_on: List of upstream job UIDs (``task_type::job_id``). Failed
86
+ parents cascade-block this job.
87
+ timeout: Execution timeout in seconds. Must be > 0.
88
+ backoff_base: Exponential backoff base in seconds. Must be finite and >= 0.
89
+ backoff_max: Backoff cap in seconds. Must be finite and >= 0.
90
+ timeout_is_transient: 超时是否视为瞬态失败(默认 False 保持
91
+ 保守语义)。True 时看门狗 TIMEOUT 按 retry 处理(指数退避重试,
92
+ 达 max_retries 才 DLQ),而非直接 Unknown → DLQ。适用于设计上
93
+ 可能长跑的 job 类型(如 discovery 的整页扫描)——「超时」的
94
+ 语义是「没跑完」,不等于「确定性失败」。
95
+ rerun: 跨会话重跑策略——「成功进 wall 是否算
96
+ 数」由它决定(**不改变 job_id 派生**,身份仍是 uid):
97
+ - None(默认,未指定哨兵):**未指定**——discovery
98
+ 任务注入 ``set_discovery_rerun`` 登记的默认策略;普通任务
99
+ 按 "never" 处理。与显式 "never" 的区别:显式值一律尊重,
100
+ 不再被 discovery 默认覆盖(保持 enqueue 与 spawn 行为一致)。
101
+ - "never":wall/failed 命中即跳过(现状语义);
102
+ - "on_failure":wall 命中跳过,failed 命中**重跑**
103
+ (网络误失败可自愈;成功即自动清 DLQ 残行);
104
+ - "every_run":wall/failed 命中都**重跑**(成功 REPLACE
105
+ wall 行、run_count+1)——discovery 扫描/orchestrator;
106
+ - "on_input_change":wall 命中比对输入指纹,failed 命中重跑。
107
+ 策略只作用于「wall/failed 拦截点」;queue/in-flight 永远
108
+ 算数(同一轮内不重复派发/并发双跑)。随 job_dict 持久化。
109
+ """
110
+ self.task_type = task_type
111
+ # 非 str task_type:下方 "::" in task_type 会抛原始 TypeError,且与 job_id 的
112
+ # str 归一化不对称,入口显式拒绝。
113
+ if not isinstance(task_type, str):
114
+ raise TypeError(
115
+ f"task_type must be a str, got {type(task_type).__name__} ({task_type!r})"
116
+ )
117
+ # job_id 拒绝非 str——str 强转会使数值与字符串
118
+ # 字面量静默碰撞(Job("t",1.0).uid == Job("t","1.0").uid == "t::1.0"),
119
+ # 混合数值/字符串 job_id 的管线静默合并不同任务(wall 去重吞掉后者)。
120
+ # 入口显式拒绝,与 task_type 对称。
121
+ if not isinstance(job_id, str):
122
+ raise TypeError(
123
+ f"job_id must be a str, got {type(job_id).__name__} ({job_id!r})"
124
+ )
125
+ self.job_id = job_id
126
+ # 校验 task_type/job_id 非空且不含 '::',避免 uid 的 'task::id' 分隔符碰撞
127
+ if not task_type:
128
+ raise ValueError(f"task_type must be a non-empty str, got {task_type!r}")
129
+ if not self.job_id:
130
+ raise ValueError(f"job_id must be a non-empty str, got {job_id!r}")
131
+ if "::" in task_type:
132
+ raise ValueError(f"task_type must not contain '::', got {task_type!r}")
133
+ if "::" in self.job_id:
134
+ raise ValueError(f"job_id must not contain '::', got {job_id!r}")
135
+ # uid 派生 IPC 文件名超 255 字节 → ENAMETOOLONG livelock,入口拒绝。
136
+ if len(safe_uid_filename(f"{task_type}::{job_id}").encode("utf-8")) > _MAX_SAFE_UID_BYTES:
137
+ raise ValueError(
138
+ f"task_type+job_id too long: uid-derived IPC filename would exceed "
139
+ f"filesystem name limit ({_MAX_SAFE_UID_BYTES} bytes max); got "
140
+ f"{task_type!r}::{job_id!r} — shorten to avoid ENAMETOOLONG livelock"
141
+ )
142
+ # 非 dict payload:to_dict/enqueue 预检均放行、直到子进程才崩,入口拒绝。
143
+ if payload is not None and not isinstance(payload, dict):
144
+ raise TypeError(
145
+ f"payload must be a dict or None, got {type(payload).__name__} ({payload!r})"
146
+ )
147
+ self.payload = dict(payload) if payload else {}
148
+ # resources 必须为 dict——str 等可迭代类型在下方
149
+ # ``for res_name, amount in self.resources.items`` 抛原始
150
+ # AttributeError(不可读),None 被 or {} 兜底但其他类型漏过。
151
+ # 与 payload 的 dict 校验对称,入口显式拒绝。
152
+ if resources is not None and not isinstance(resources, dict):
153
+ raise TypeError(
154
+ f"resources must be a dict or None, got {type(resources).__name__} ({resources!r})"
155
+ )
156
+ self.resources = resources or {}
157
+ # 数值校验单点化(与 pipeline.register_handler 的默认资源
158
+ # 校验共用 utils.validation.validate_resource_amounts)——负值/NaN/Inf
159
+ # 会在调度器 acquire 时抛 ValueError(若抛在 try 之外,部分资源
160
+ # 永久泄漏),且 NaN 会毒化 CapacityResource 的 used 账目导致 livelock。
161
+ # 构造时即拒绝(bool 是 int 子类,isinstance(True,(int,float)) 为
162
+ # True 会放行,故显式拒绝 bool)。
163
+ validate_resource_amounts(self.resources, "resources")
164
+ self.retries = retries
165
+ # retries 必须为 int——字符串等脏数据会在 _complete_job 的
166
+ # `job.retries >= job.max_retries` 比较处抛 TypeError,导致无限
167
+ # 崩溃重启循环(job 从磁盘恢复 → 再跑再崩,且无 DLQ 兜底)。
168
+ if not isinstance(retries, int) or isinstance(retries, bool):
169
+ raise TypeError(f"retries must be an int, got {type(retries).__name__} ({retries!r})")
170
+ # retries 下界校验:防止负数导致退避比较异常与无延迟重试,入口即拒绝。
171
+ if retries < 0:
172
+ raise ValueError(f"retries must be >= 0, got {retries}")
173
+ # max_retries 的 isinstance 检查必须在 < 0 之前——字符串脏数据
174
+ # 会先触发 "abc" < 0 的原始 TypeError,友好报错失效。
175
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool):
176
+ raise TypeError(
177
+ f"max_retries must be an int, got {type(max_retries).__name__} ({max_retries!r})"
178
+ )
179
+ if max_retries < 0:
180
+ raise ValueError(f"max_retries must be >= 0, got {max_retries}")
181
+ self.max_retries = max_retries
182
+ # 先校验外层类型再迭代——str 可迭代出 str,若直接 all(isinstance) 检查
183
+ # 会把 "parent::id" 静默肢解为字符列表,作业以 DEPENDENCY_DEADLOCK 死亡。
184
+ if depends_on is not None and not isinstance(depends_on, (list, tuple)):
185
+ raise TypeError(
186
+ f"depends_on must be a list of strings, got {type(depends_on).__name__}"
187
+ )
188
+ self.depends_on = list(depends_on) if depends_on is not None else []
189
+ if not all(isinstance(d, str) for d in self.depends_on):
190
+ raise TypeError(
191
+ f"depends_on must contain only strings, got {self.depends_on!r}"
192
+ )
193
+ self.timeout = timeout
194
+ # timeout 必须为有限正数——NaN 使 timeout <= 0 校验恒
195
+ # False 而漏过(NaN <= 0 为 False),deadline = monotonic + NaN = NaN,
196
+ # now > NaN 恒 False → 看门狗永不触发,挂死 job 永不 kill。
197
+ # bool 是 int 子类,同样拒绝(timeout=True 被接受为 1 秒)。
198
+ if not isinstance(timeout, (int, float)) or isinstance(timeout, bool):
199
+ raise TypeError(
200
+ f"timeout must be a number, got {type(timeout).__name__} ({timeout!r})"
201
+ )
202
+ if not math.isfinite(timeout) or timeout <= 0:
203
+ raise ValueError(f"timeout must be finite and > 0, got {timeout}")
204
+ # backoff_base/backoff_max 与 timeout 的校验对称——
205
+ # 仅 math.isfinite + < 0 不够:字符串 "2.0" 抛原始 TypeError
206
+ # (math.isfinite 不接收 str),True 被接受为 1.0(bool 是 int 子类)。
207
+ if not isinstance(backoff_base, (int, float)) or isinstance(backoff_base, bool):
208
+ raise TypeError(
209
+ f"backoff_base must be a number, got {type(backoff_base).__name__} ({backoff_base!r})"
210
+ )
211
+ if not math.isfinite(backoff_base) or backoff_base < 0:
212
+ raise ValueError(
213
+ f"backoff_base must be finite and non-negative, got {backoff_base}"
214
+ )
215
+ if not isinstance(backoff_max, (int, float)) or isinstance(backoff_max, bool):
216
+ raise TypeError(
217
+ f"backoff_max must be a number, got {type(backoff_max).__name__} ({backoff_max!r})"
218
+ )
219
+ if not math.isfinite(backoff_max) or backoff_max < 0:
220
+ raise ValueError(
221
+ f"backoff_max must be finite and non-negative, got {backoff_max}"
222
+ )
223
+ self.backoff_base = backoff_base
224
+ self.backoff_max = backoff_max
225
+ # 超时归类可配置。bool 类型校验——非 bool 值(如字符串)在
226
+ # executor 的 ``handle.job.timeout_is_transient`` 判断时被当作真值,
227
+ # 静默改变归类语义,入口拒绝。
228
+ if not isinstance(timeout_is_transient, bool):
229
+ raise TypeError(
230
+ f"timeout_is_transient must be a bool, got "
231
+ f"{type(timeout_is_transient).__name__} ({timeout_is_transient!r})"
232
+ )
233
+ self.timeout_is_transient = timeout_is_transient
234
+ # rerun 策略入口校验(fail-loud)——非法值会被静默当 "never" 处理。
235
+ # None 是「未指定」哨兵(可被 discovery 默认注入),
236
+ # 显式字符串一律尊重;仅拒绝未知字符串值。
237
+ if rerun is not None and rerun not in (
238
+ "never", "on_failure", "every_run", "on_input_change",
239
+ ):
240
+ raise ValueError(
241
+ f"rerun must be None (unspecified) or one of "
242
+ f"'never'/'on_failure'/'every_run'/'on_input_change', got {rerun!r}"
243
+ )
244
+ self.rerun = rerun
245
+ # 运行时边带状态(退避截止/3-strike 计数/最近重试错误)收敛到
246
+ # `runtime` 单一命名空间,随 job_dict 落盘持久化——序列化只此
247
+ # 一处,新增状态不会因散装下划线键漏写 to_dict 而丢失。
248
+ self.runtime = dict(runtime) if runtime else {}
249
+
250
+ def to_dict(self) -> dict:
251
+ """Serialize job to dictionary.
252
+
253
+ Returns shallow copies of payload/resources/depends_on to prevent
254
+ external mutation of internal state. ``runtime`` 子 dict 显式保留
255
+ ——边带状态集中序列化,不散装丢失。
256
+ """
257
+ return {
258
+ "task_type": self.task_type,
259
+ "job_id": self.job_id,
260
+ "payload": dict(self.payload),
261
+ "resources": dict(self.resources),
262
+ "retries": self.retries,
263
+ "max_retries": self.max_retries,
264
+ "depends_on": list(self.depends_on),
265
+ "timeout": self.timeout,
266
+ "backoff_base": self.backoff_base,
267
+ "backoff_max": self.backoff_max,
268
+ "timeout_is_transient": self.timeout_is_transient,
269
+ "rerun": self.rerun,
270
+ "runtime": dict(self.runtime),
271
+ }
272
+
273
+ @classmethod
274
+ def from_dict(cls, data: dict) -> "Job":
275
+ """Deserialize job from dictionary."""
276
+ return cls(
277
+ data["task_type"],
278
+ data["job_id"],
279
+ data.get("payload", {}),
280
+ data.get("resources", {}),
281
+ data.get("retries", 0),
282
+ data.get("max_retries", 3),
283
+ data.get("depends_on", []),
284
+ data.get("timeout", 3600),
285
+ data.get("backoff_base", 2.0),
286
+ data.get("backoff_max", 300.0),
287
+ data.get("timeout_is_transient", False),
288
+ data.get("rerun"), # 缺键/None = 未指定哨兵
289
+ # runtime 子 dict 显式保留——边带状态只存这一处。
290
+ data.get("runtime", {}),
291
+ )
292
+
293
+ @property
294
+ def uid(self) -> str:
295
+ """Return unique identifier for this job."""
296
+ return f"{self.task_type}::{self.job_id}"
297
+
298
+ def __eq__(self, other: object) -> bool:
299
+ if not isinstance(other, Job):
300
+ return NotImplemented
301
+ return self.uid == other.uid
302
+
303
+ def __hash__(self) -> int:
304
+ return hash(self.uid)