pavoz 0.3.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.
- pavoz/__init__.py +66 -0
- pavoz/__main__.py +6 -0
- pavoz/_id.py +16 -0
- pavoz/checkpoint.py +340 -0
- pavoz/cli.py +367 -0
- pavoz/dag.py +186 -0
- pavoz/runtime.py +727 -0
- pavoz/state.py +425 -0
- pavoz/storage.py +92 -0
- pavoz/storage_loader.py +75 -0
- pavoz/testing.py +123 -0
- pavoz/types.py +65 -0
- pavoz-0.3.0.dist-info/METADATA +235 -0
- pavoz-0.3.0.dist-info/RECORD +17 -0
- pavoz-0.3.0.dist-info/WHEEL +4 -0
- pavoz-0.3.0.dist-info/entry_points.txt +2 -0
- pavoz-0.3.0.dist-info/licenses/LICENSE +21 -0
pavoz/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""pavoz — 通用流程编排库 (micro/in-process workflow engine).
|
|
2
|
+
|
|
3
|
+
独立于任何业务系统与模型/存储服务: DAG (静态声明) + Runtime (顺序执行 +
|
|
4
|
+
per-node retry + absolute deadline) + Checkpoint (断点续跑) + TestPipe (回归).
|
|
5
|
+
循环 (质量收敛等) 留在 stage 函数内用普通 Python 表达. 存储/外部调用/任务表
|
|
6
|
+
全部 Protocol, 由业务侧注入. core 零第三方依赖.
|
|
7
|
+
|
|
8
|
+
公开 API:
|
|
9
|
+
DAG / @dag.stage(depends_on, retries, timeout)
|
|
10
|
+
Runtime().run(dag, task_id=None, initial_state=..., resume=False)
|
|
11
|
+
task_id 省略 → 自动 UUID4; run_id 每次 run 自动生成 (resume 复用)
|
|
12
|
+
run_stage(dag, task_id, stage_name) — 从 cp 重建输入单 stage 重放 (v0.5.1)
|
|
13
|
+
fork_run(dag, task_id, from_stage=..., overrides=...) — 历史节点取输入改装回
|
|
14
|
+
续跑: 前序复用, from_stage 起用 overrides 重跑, 原 cp 不动 (v0.6)
|
|
15
|
+
TestPipe(dag).mock("s_x", lambda state: {...}).run()
|
|
16
|
+
TestPipe.replay_from(cp, dag) — 真实 cp 已完成 stage 用历史 delta mock 回归 (v0.5.1)
|
|
17
|
+
StorageBackend / FileStorage / CheckpointStore
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .checkpoint import Checkpoint, CheckpointMismatchError, CheckpointStore, workflow_hash
|
|
21
|
+
from .dag import DAG, CycleError, Stage, UnknownDepError
|
|
22
|
+
from .runtime import CallResult, Ctx, Runtime
|
|
23
|
+
from .state import (
|
|
24
|
+
ReadOnlyStateView,
|
|
25
|
+
StateConflictError,
|
|
26
|
+
apply_overrides,
|
|
27
|
+
merge_overrides,
|
|
28
|
+
parse_set_args,
|
|
29
|
+
parse_set_file,
|
|
30
|
+
)
|
|
31
|
+
from .storage import FileStorage, StorageBackend
|
|
32
|
+
from .storage_loader import PavozStorageError, load_storage
|
|
33
|
+
from .testing import TestPipe
|
|
34
|
+
from .types import FatalError, RetryableError, RunResult, StageError
|
|
35
|
+
|
|
36
|
+
__version__ = "0.3.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DAG",
|
|
40
|
+
"CallResult",
|
|
41
|
+
"Checkpoint",
|
|
42
|
+
"CheckpointMismatchError",
|
|
43
|
+
"CheckpointStore",
|
|
44
|
+
"Ctx",
|
|
45
|
+
"CycleError",
|
|
46
|
+
"FatalError",
|
|
47
|
+
"FileStorage",
|
|
48
|
+
"ReadOnlyStateView",
|
|
49
|
+
"RetryableError",
|
|
50
|
+
"RunResult",
|
|
51
|
+
"Runtime",
|
|
52
|
+
"Stage",
|
|
53
|
+
"StageError",
|
|
54
|
+
"PavozStorageError",
|
|
55
|
+
"StateConflictError",
|
|
56
|
+
"StorageBackend",
|
|
57
|
+
"TestPipe",
|
|
58
|
+
"UnknownDepError",
|
|
59
|
+
"__version__",
|
|
60
|
+
"apply_overrides",
|
|
61
|
+
"load_storage",
|
|
62
|
+
"merge_overrides",
|
|
63
|
+
"parse_set_args",
|
|
64
|
+
"parse_set_file",
|
|
65
|
+
"workflow_hash",
|
|
66
|
+
]
|
pavoz/__main__.py
ADDED
pavoz/_id.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ID generation helpers.
|
|
2
|
+
|
|
3
|
+
pavoz requires Python 3.12+ where uuid.uuid4 is the canonical
|
|
4
|
+
UUID generator. uuid.uuid7 is 3.14+; we use uuid4 to keep the
|
|
5
|
+
3.12 floor. Uniqueness is the requirement — time-ordering is not.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
__all__ = ["new_id"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def new_id() -> str:
|
|
15
|
+
"""Generate a new unique ID (UUID4 canonical string form, 36 chars)."""
|
|
16
|
+
return str(uuid.uuid4())
|
pavoz/checkpoint.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Checkpoint: per-run 持久化 + workflow hash + mismatch 拒 resume.
|
|
2
|
+
|
|
3
|
+
- 每次 runtime.run() 一个 run_id (UUID4); checkpoint 存 runs/{task_id}/{run_id}/checkpoint.
|
|
4
|
+
- 指针文件 runs/{task_id}/latest 记 {"run_id": ...} — load_latest 靠它
|
|
5
|
+
(uuid4 字典序 ≠ 时间序, 不能拿 list_keys 末位当 "最新").
|
|
6
|
+
- 同 task_id 可有多个 run_id 的 checkpoint (original / resume / replay),
|
|
7
|
+
resume 复用同一 run_id.
|
|
8
|
+
- run_id 必填 — 无 legacy task 级单 cp lane (旧 key runs/{task_id}/checkpoint
|
|
9
|
+
已 orphan). run_id="" 仅在 load_compatible 作 "加载最新" 哨兵.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .dag import DAG
|
|
20
|
+
from .state import apply_overrides
|
|
21
|
+
from .storage import StorageBackend
|
|
22
|
+
|
|
23
|
+
__all__ = ["Checkpoint", "CheckpointMismatchError", "CheckpointStore", "workflow_hash"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def workflow_hash(dag: DAG) -> str:
|
|
27
|
+
"""DAG 结构的稳定指纹: stage 名 + 依赖 + retries + timeout.
|
|
28
|
+
|
|
29
|
+
任何影响执行语义的结构改动 → hash 变 → 旧 checkpoint 拒 resume.
|
|
30
|
+
只改 stage 函数体 (fn) 不改结构 → hash 不变 (resume 兼容, fn 重新 import 即新代码).
|
|
31
|
+
"""
|
|
32
|
+
rows = []
|
|
33
|
+
for name in dag.topo_order():
|
|
34
|
+
s = dag.stages[name]
|
|
35
|
+
rows.append(f"{name}:dep={','.join(s.depends_on)}:retry={s.retries}:to={s.timeout}")
|
|
36
|
+
raw = "\n".join(rows)
|
|
37
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CheckpointMismatchError(Exception):
|
|
41
|
+
"""checkpoint 的 DAG hash ≠ 当前 DAG hash. 结构变了, 不能续跑."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Checkpoint:
|
|
46
|
+
"""一次 runtime.run() (一个 run_id) 的持久化状态.
|
|
47
|
+
|
|
48
|
+
v0.8 (2026-09-06, user: 发布前向前看, 旧数据记录可弃): **state 不落盘** —
|
|
49
|
+
全量 state 是 stage_deltas + initial_state 的确定性函数 (_rebuild_state),
|
|
50
|
+
双份落盘 = 2x 体积 (真实业务 cp 2.8MB 量级, state 冗余约占一半). 序列化层
|
|
51
|
+
只存增量 + 元数据; state 变 property (load 后惰性重建).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
task_id: str
|
|
55
|
+
run_id: str
|
|
56
|
+
dag_name: str
|
|
57
|
+
workflow_hash: str
|
|
58
|
+
stage_statuses: dict[str, str]
|
|
59
|
+
done_stages: list[str] # 按完成顺序
|
|
60
|
+
# v0.1.1: state key → producer stage. 链式覆盖判定用.
|
|
61
|
+
producers: dict[str, str] = field(default_factory=dict)
|
|
62
|
+
# v0.5.1 (M2): 每 stage 的原始 return delta (按完成序) + run 的初始 state.
|
|
63
|
+
initial_state: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
stage_deltas: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
65
|
+
# v0.7: stage 完成 epoch ts — 恢复侧内容过期 (TTL) gate 判定用.
|
|
66
|
+
stage_ts: dict[str, float] = field(default_factory=dict)
|
|
67
|
+
# v0.8: fork_run 的 overrides — fork cp 专用 (前序 keep 之上最后 merge).
|
|
68
|
+
# 曾靠全量 state 落盘持久化; state 不落盘后 overrides 必须显式存, 否则
|
|
69
|
+
# fork resume 时 rebuild 丢 overrides → 从 from_stage 重跑用旧输入 (bug).
|
|
70
|
+
fork_overrides: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def state(self) -> dict[str, Any]:
|
|
74
|
+
"""全量 merge 后 state — 从 initial_state + stage_deltas 惰性重建.
|
|
75
|
+
|
|
76
|
+
不落盘 (v0.8 瘦身), 每次访问现算 (纯内存 dict merge, 2MB 级 ~10ms).
|
|
77
|
+
"""
|
|
78
|
+
return self._rebuild_state()[0]
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"task_id": self.task_id,
|
|
83
|
+
"run_id": self.run_id,
|
|
84
|
+
"dag_name": self.dag_name,
|
|
85
|
+
"workflow_hash": self.workflow_hash,
|
|
86
|
+
"stage_statuses": self.stage_statuses,
|
|
87
|
+
"done_stages": self.done_stages,
|
|
88
|
+
"producers": self.producers,
|
|
89
|
+
"initial_state": self.initial_state,
|
|
90
|
+
"stage_deltas": self.stage_deltas,
|
|
91
|
+
"stage_ts": self.stage_ts,
|
|
92
|
+
"fork_overrides": self.fork_overrides,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_dict(cls, d: dict) -> Checkpoint:
|
|
97
|
+
# run_id 必填 (旧无 run_id 数据已 hard cut orphan); 以后加新字段一律 .get 默认 (A7)
|
|
98
|
+
return cls(
|
|
99
|
+
task_id=d["task_id"],
|
|
100
|
+
run_id=d["run_id"],
|
|
101
|
+
dag_name=d["dag_name"],
|
|
102
|
+
workflow_hash=d["workflow_hash"],
|
|
103
|
+
stage_statuses=d["stage_statuses"],
|
|
104
|
+
done_stages=d["done_stages"],
|
|
105
|
+
producers=dict(d.get("producers") or {}),
|
|
106
|
+
initial_state=dict(d.get("initial_state") or {}),
|
|
107
|
+
stage_deltas=dict(d.get("stage_deltas") or {}),
|
|
108
|
+
stage_ts=dict(d.get("stage_ts") or {}),
|
|
109
|
+
fork_overrides=dict(d.get("fork_overrides") or {}),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def rebuild_state(self) -> dict[str, Any]:
|
|
113
|
+
"""重建所有已完成 stage merge 后的 state (给未完成的 stage 当执行前 state).
|
|
114
|
+
|
|
115
|
+
重放"原始 run 失败/中断的 stage"用 — 顺序执行 + 首败即停下,
|
|
116
|
+
未完成 stage 的依赖必已全部完成, 全量 merge done deltas 即其执行前 state.
|
|
117
|
+
|
|
118
|
+
Returns: 新 dict (不 mutate).
|
|
119
|
+
"""
|
|
120
|
+
return self.rebuild_state_and_producers()[0]
|
|
121
|
+
|
|
122
|
+
def rebuild_state_and_producers(self) -> tuple[dict[str, Any], dict[str, str]]:
|
|
123
|
+
"""重建全量 merge 后的 (state, producers) — producers 为执行时点值.
|
|
124
|
+
|
|
125
|
+
未完成但依赖已完成的 stage 重放用 (run_stage): 与 rebuild_state 同一次
|
|
126
|
+
遍历收集 producers (initial keys → "<init>"; merged delta 的 keys → 其 stage).
|
|
127
|
+
"""
|
|
128
|
+
return self._rebuild_state()
|
|
129
|
+
|
|
130
|
+
def rebuild_state_before(self, stage_name: str) -> dict[str, Any]:
|
|
131
|
+
"""重建 stage 执行前的 state = initial + 已完成且在该 stage 前的 deltas.
|
|
132
|
+
|
|
133
|
+
顺序按 done_stages 完成序 merge (dict.update) — chain-overwrite 由
|
|
134
|
+
完成顺序天然处理 (后完成者覆盖先完成者). 要求 stage_name 的依赖已全完成
|
|
135
|
+
(即 stage_name in done_stages 或它的 depends_on ⊆ done_stages) —
|
|
136
|
+
否则它之前的 delta 不齐, raise KeyError.
|
|
137
|
+
|
|
138
|
+
Returns: 新 dict (不 mutate).
|
|
139
|
+
"""
|
|
140
|
+
return self.rebuild_state_and_producers_before(stage_name)[0]
|
|
141
|
+
|
|
142
|
+
def rebuild_state_and_producers_before(
|
|
143
|
+
self, stage_name: str
|
|
144
|
+
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
145
|
+
"""重建 stage 执行前的 (state, producers) — producers 按执行时点重建.
|
|
146
|
+
|
|
147
|
+
cp.producers 是终态 (chain-overwrite 后指向最后写者); 重放中间 stage 的
|
|
148
|
+
覆盖判定必须用"该 stage 执行时点"的 producer — 这里随 state 重建同步
|
|
149
|
+
收集: initial keys → "<init>", 已 merge delta (stage X) 的 keys → X.
|
|
150
|
+
"""
|
|
151
|
+
if stage_name not in self.done_stages and stage_name not in self.stage_deltas:
|
|
152
|
+
raise KeyError(
|
|
153
|
+
f"stage '{stage_name}' 不在 cp 的完成记录里 "
|
|
154
|
+
f"(done_stages={self.done_stages}). 无法重建其执行前 state."
|
|
155
|
+
)
|
|
156
|
+
return self._rebuild_state(stop_at=stage_name)
|
|
157
|
+
|
|
158
|
+
def _rebuild_state(self, stop_at: str | None = None) -> tuple[dict[str, Any], dict[str, str]]:
|
|
159
|
+
"""initial + done_stages 完成序 deltas 的 merge + 执行时点 producers.
|
|
160
|
+
|
|
161
|
+
stop_at 命中断 (不含). Returns: (state, producers) — producers 记录每个
|
|
162
|
+
merged delta 的 keys → 其 stage; initial keys → "<init>".
|
|
163
|
+
"""
|
|
164
|
+
rebuilt = dict(self.initial_state)
|
|
165
|
+
producers = {k: "<init>" for k in self.initial_state}
|
|
166
|
+
for done in self.done_stages:
|
|
167
|
+
if done == stop_at:
|
|
168
|
+
break
|
|
169
|
+
delta = self.stage_deltas.get(done)
|
|
170
|
+
if delta:
|
|
171
|
+
rebuilt.update(delta)
|
|
172
|
+
for k in delta:
|
|
173
|
+
producers[k] = done
|
|
174
|
+
# v0.8: fork overrides 最后 merge (覆盖前序 keep 产物; producer 标 <fork>)
|
|
175
|
+
# v0.9 深合并: v0.8 浅 update 会把嵌套 dict 的兄弟键抹掉 (resume 重建
|
|
176
|
+
# 才是 stage 实际看到的 state — 与 runtime.fork_run 应用语义保持一致)
|
|
177
|
+
if stop_at is None and self.fork_overrides:
|
|
178
|
+
rebuilt = apply_overrides(rebuilt, self.fork_overrides)
|
|
179
|
+
for k in self.fork_overrides:
|
|
180
|
+
producers[k] = "<fork>"
|
|
181
|
+
return rebuilt, producers
|
|
182
|
+
|
|
183
|
+
def state_stats(self, top_n: int = 10) -> dict:
|
|
184
|
+
"""state 体积观测 (#36): 各 key json 体积 top N + 总构成. 调试大 state 用."""
|
|
185
|
+
import json as _json
|
|
186
|
+
|
|
187
|
+
def _size(o: Any) -> int:
|
|
188
|
+
try:
|
|
189
|
+
return len(_json.dumps(o, ensure_ascii=False, default=str))
|
|
190
|
+
except (TypeError, ValueError): # 不可序列化 → 记 -1
|
|
191
|
+
return -1
|
|
192
|
+
|
|
193
|
+
_sizes = {k: _size(v) for k, v in self.state.items()}
|
|
194
|
+
_top = sorted(_sizes.items(), key=lambda kv: kv[1], reverse=True)[:top_n]
|
|
195
|
+
return {
|
|
196
|
+
"state_keys": len(self.state),
|
|
197
|
+
"state_total_bytes": sum(_sizes.values()),
|
|
198
|
+
"initial_state_bytes": _size(self.initial_state),
|
|
199
|
+
"deltas_bytes": _size(self.stage_deltas),
|
|
200
|
+
"done_stages": list(self.done_stages),
|
|
201
|
+
"top_keys": [{"key": k, "bytes": v} for k, v in _top],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class CheckpointStore:
|
|
206
|
+
"""Per-task, per-run checkpoint 持久化.
|
|
207
|
+
|
|
208
|
+
Key format: runs/{task_id}/{run_id}/checkpoint (run_id 必填)
|
|
209
|
+
+ 指针 runs/{task_id}/latest = {"run_id": ...}.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, storage: StorageBackend):
|
|
213
|
+
self.storage = storage
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _key(task_id: str, run_id: str) -> str:
|
|
217
|
+
"""run 级 key: runs/{task_id}/{run_id}/checkpoint."""
|
|
218
|
+
return f"runs/{task_id}/{run_id}/checkpoint"
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _latest_key(task_id: str) -> str:
|
|
222
|
+
"""最新 run 指针文件 key. 内容 = {"run_id": "<latest>"}."""
|
|
223
|
+
return f"runs/{task_id}/latest"
|
|
224
|
+
|
|
225
|
+
def save(self, cp: Checkpoint) -> None:
|
|
226
|
+
self.storage.put(self._key(cp.task_id, cp.run_id), cp.to_dict())
|
|
227
|
+
# 指针: 永远指向最后保存的 run (uuid4 字典序 ≠ 时间序, 不能靠排序).
|
|
228
|
+
self.storage.put(self._latest_key(cp.task_id), {"run_id": cp.run_id})
|
|
229
|
+
|
|
230
|
+
def load(self, task_id: str, run_id: str) -> Checkpoint | None:
|
|
231
|
+
"""读指定 run 的 checkpoint."""
|
|
232
|
+
d = self.storage.get(self._key(task_id, run_id))
|
|
233
|
+
return Checkpoint.from_dict(d) if d else None
|
|
234
|
+
|
|
235
|
+
def list_runs(self, task_id: str) -> list[str]:
|
|
236
|
+
"""列 task 的所有 run_id (字典序, UUID4 下 ≠ 时间序).
|
|
237
|
+
|
|
238
|
+
调试/清理用. 不含指针文件 — resume 一律走 load_latest (指针),
|
|
239
|
+
不要拿排序当 "最新".
|
|
240
|
+
"""
|
|
241
|
+
prefix = f"runs/{task_id}/"
|
|
242
|
+
keys = self.storage.list_keys(prefix)
|
|
243
|
+
run_ids: list[str] = []
|
|
244
|
+
for k in keys:
|
|
245
|
+
tail = k[len(prefix):]
|
|
246
|
+
if tail.endswith("/checkpoint"):
|
|
247
|
+
run_ids.append(tail[: -len("/checkpoint")])
|
|
248
|
+
return sorted(run_ids)
|
|
249
|
+
|
|
250
|
+
def load_latest(self, task_id: str) -> Checkpoint | None:
|
|
251
|
+
"""按指针文件加载该 task 最新 run 的 checkpoint.
|
|
252
|
+
|
|
253
|
+
指针指向的 run 已被删 → load 返 None (边界: caller 收到 "无 checkpoint").
|
|
254
|
+
"""
|
|
255
|
+
d = self.storage.get(self._latest_key(task_id))
|
|
256
|
+
if d is None:
|
|
257
|
+
return None
|
|
258
|
+
return self.load(task_id, d["run_id"])
|
|
259
|
+
|
|
260
|
+
def delete(self, task_id: str, run_id: str) -> None:
|
|
261
|
+
"""删指定 run 的 checkpoint.
|
|
262
|
+
|
|
263
|
+
指针不随删除更新 (A2): 删的恰是指针指向的 run → 后续 load_latest
|
|
264
|
+
读到已删 run → 返 None → caller 收到明确 "无 checkpoint".
|
|
265
|
+
"""
|
|
266
|
+
self.storage.delete(self._key(task_id, run_id))
|
|
267
|
+
|
|
268
|
+
def prune(self, task_id: str, *, keep_last: int, dry_run: bool = False) -> dict:
|
|
269
|
+
"""按保留数修剪旧 run 的 checkpoint (R1, 2026-09-10 ai-research PR).
|
|
270
|
+
|
|
271
|
+
语义:
|
|
272
|
+
- 排序 = max(stage_ts) (run 最后活动时间, v0.7 epoch); 无 stage_ts 的 run
|
|
273
|
+
视为最旧. 最新 keep_last 个保留; **latest 指针指向的 run 无条件保留**
|
|
274
|
+
(resume/fork 基线), 指针文件本身不动.
|
|
275
|
+
- 被删 run 的后续行为 = 既有边界: load(task, rid)→None, load_latest 在
|
|
276
|
+
指针指向被删 run 时→None (本实现不会让指针悬空), fork-run 报
|
|
277
|
+
"无 checkpoint, 不能 fork".
|
|
278
|
+
- 删除以 key 为粒度 (StorageBackend.delete), 跨 task 不触碰.
|
|
279
|
+
- approx_bytes = 重新序列化 JSON 的字节数 (近似值, dry-run 预估用).
|
|
280
|
+
- FileStorage 只删 key 文件, 可能残留空目录 (0 字节, 无碍).
|
|
281
|
+
|
|
282
|
+
不做: max_runs_per_task 自动修剪 (引擎不替客户决定保留策略,
|
|
283
|
+
v0.4 storage adapters REVERSED 同一先例); 跨 task GC / 引用计数.
|
|
284
|
+
"""
|
|
285
|
+
if keep_last < 0:
|
|
286
|
+
raise ValueError(f"keep_last 必须 >= 0, got {keep_last}")
|
|
287
|
+
run_ids = self.list_runs(task_id)
|
|
288
|
+
latest_d = self.storage.get(self._latest_key(task_id))
|
|
289
|
+
latest_run = (latest_d or {}).get("run_id")
|
|
290
|
+
|
|
291
|
+
def _mtime(rid: str) -> tuple[float, str]:
|
|
292
|
+
cp = self.load(task_id, rid)
|
|
293
|
+
ts = max(cp.stage_ts.values()) if cp and cp.stage_ts else 0.0
|
|
294
|
+
return (ts, rid)
|
|
295
|
+
|
|
296
|
+
ordered = sorted(run_ids, key=_mtime) # 旧 → 新
|
|
297
|
+
keep = set(ordered[-keep_last:]) if keep_last > 0 else set()
|
|
298
|
+
if latest_run:
|
|
299
|
+
keep.add(latest_run)
|
|
300
|
+
victims = [r for r in ordered if r not in keep]
|
|
301
|
+
|
|
302
|
+
deleted: list[dict] = []
|
|
303
|
+
freed = 0
|
|
304
|
+
for rid in victims:
|
|
305
|
+
raw = self.storage.get(self._key(task_id, rid))
|
|
306
|
+
size = len(json.dumps(raw).encode("utf-8")) if raw else 0
|
|
307
|
+
if not dry_run:
|
|
308
|
+
self.delete(task_id, rid)
|
|
309
|
+
deleted.append({"run_id": rid, "approx_bytes": size})
|
|
310
|
+
freed += size
|
|
311
|
+
kept = [r for r in ordered if r in keep]
|
|
312
|
+
return {
|
|
313
|
+
"task_id": task_id,
|
|
314
|
+
"dry_run": dry_run,
|
|
315
|
+
"protected_latest": latest_run,
|
|
316
|
+
"kept": kept,
|
|
317
|
+
"deleted": deleted,
|
|
318
|
+
"approx_bytes_freed": freed,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
def load_compatible(self, task_id: str, run_id: str, dag: DAG) -> Checkpoint | None:
|
|
322
|
+
"""加载 + 校验 workflow hash. mismatch → CheckpointMismatchError.
|
|
323
|
+
|
|
324
|
+
run_id="" → 加载该 task 最新 run (load_latest 哨兵).
|
|
325
|
+
"""
|
|
326
|
+
if run_id:
|
|
327
|
+
cp = self.load(task_id, run_id)
|
|
328
|
+
else:
|
|
329
|
+
cp = self.load_latest(task_id)
|
|
330
|
+
if cp is None:
|
|
331
|
+
return None
|
|
332
|
+
cur_hash = workflow_hash(dag)
|
|
333
|
+
if cp.workflow_hash != cur_hash:
|
|
334
|
+
raise CheckpointMismatchError(
|
|
335
|
+
f"task {task_id} run {cp.run_id[:8]} checkpoint 的 DAG hash {cp.workflow_hash} "
|
|
336
|
+
f"≠ 当前 DAG hash {cur_hash}. DAG 结构变了, 不能 resume."
|
|
337
|
+
f"(只改 stage 函数体不影响 hash; 改依赖/retries/timeout 会). "
|
|
338
|
+
f"如需强制重跑: 删 checkpoint 或 Runtime(..., resume=False)"
|
|
339
|
+
)
|
|
340
|
+
return cp
|