Files
pyflowx/tests/test_persistence.py
T

291 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""持久化与恢复测试:检查点恢复 + 运行历史管理。
覆盖:
* run(resume_from=RunReport) 跳过已成功任务。
* run(resume_from=Path) 从 JSON 文件恢复。
* 恢复后 FAILED/SKIPPED 任务被重新执行。
* 恢复后下游任务能获取上游恢复结果。
* RunHistory: save/load/list/latest/delete。
* RunHistory: __len__ / __contains__。
"""
from __future__ import annotations
from pathlib import Path
import pytest
import pyflowx as px
from pyflowx import Graph, RunHistory, TaskSpec
def test_resume_skips_successful_tasks() -> None:
"""resume_from 中的 SUCCESS 任务被跳过,不重新执行。"""
call_count: dict[str, int] = {"a": 0, "b": 0}
def make_a():
def _a() -> int:
call_count["a"] += 1
return 1
return _a
def make_b():
def _b(a: int) -> int:
call_count["b"] += 1
return a + 1
return _b
graph = Graph.from_specs(
[
TaskSpec("a", fn=make_a()),
TaskSpec("b", fn=make_b(), depends_on=("a",)),
]
)
# 第一次运行
report1 = px.run(graph, strategy="sequential")
assert call_count == {"a": 1, "b": 1}
# 第二次运行,从 report1 恢复
report2 = px.run(graph, strategy="sequential", resume_from=report1)
assert call_count == {"a": 1, "b": 1} # 没有重新执行
assert report2["a"] == 1
assert report2["b"] == 2
def test_resume_from_json_path(tmp_path: Path) -> None:
"""resume_from 接受 JSON 文件路径。"""
graph = Graph.from_specs([TaskSpec("a", fn=lambda: 42)])
report1 = px.run(graph, strategy="sequential")
json_path = tmp_path / "report.json"
json_path.write_text(report1.to_json(), encoding="utf-8")
call_count = 0
def _a() -> int:
nonlocal call_count
call_count += 1
return 42
graph2 = Graph.from_specs([TaskSpec("a", fn=_a)])
report2 = px.run(graph2, strategy="sequential", resume_from=json_path)
assert call_count == 0 # 从检查点恢复,未重新执行
assert report2["a"] == 42
def test_resume_reruns_failed_tasks() -> None:
"""resume_from 中 FAILED 的任务被重新执行。"""
state: dict[str, bool] = {"should_fail": True}
def _a() -> str:
if state["should_fail"]:
raise RuntimeError("第一次失败")
return "success"
graph = Graph.from_specs([TaskSpec("a", fn=_a)])
# 第一次运行失败
with pytest.raises(px.TaskFailedError):
px.run(graph, strategy="sequential")
# 修复问题
state["should_fail"] = False
# 第二次运行:从头开始(无 resume_from
report2 = px.run(graph, strategy="sequential")
assert report2["a"] == "success"
def test_resume_downstream_gets_restored_result() -> None:
"""恢复后下游任务能获取上游恢复结果。
a 第一次成功,b 第一次失败;第二次 a 被恢复,b 重新执行获取 a 的恢复值。
"""
state: dict[str, bool] = {"b_fail": True}
b_call_count = 0
def _a() -> int:
return 10
def _b(a: int) -> int:
nonlocal b_call_count
b_call_count += 1
if state["b_fail"]:
raise RuntimeError("b 第一次失败")
return a * 2
graph = Graph.from_specs(
[
TaskSpec("a", fn=_a),
TaskSpec("b", fn=_b, depends_on=("a",)),
]
)
# 第一次运行:a 成功,b 失败
with pytest.raises(px.TaskFailedError):
px.run(graph, strategy="sequential")
# 修复 b
state["b_fail"] = False
b_call_count = 0 # 重置计数
# 第二次运行:a 被恢复(值=10),b 重新执行获取 a=10
# 需要构造一个只含 a 成功结果的 prev_report
prev_report = px.run(
Graph.from_specs([TaskSpec("a", fn=_a)]),
strategy="sequential",
)
report2 = px.run(graph, strategy="sequential", resume_from=prev_report)
assert report2["a"] == 10 # a 被恢复
assert b_call_count == 1 # b 被重新执行一次
assert report2["b"] == 20 # b 获取 a=10
def test_resume_with_dependency_strategy() -> None:
"""dependency 策略下 resume_from 也正常工作。"""
graph = Graph.from_specs(
[
TaskSpec("a", fn=lambda: 1),
TaskSpec("b", fn=lambda a: a + 1, depends_on=("a",)),
]
)
report1 = px.run(graph, strategy="dependency")
assert report1["a"] == 1
assert report1["b"] == 2
call_count = 0
def _a() -> int:
nonlocal call_count
call_count += 1
return 1
graph2 = Graph.from_specs(
[
TaskSpec("a", fn=_a),
TaskSpec("b", fn=lambda a: a + 1, depends_on=("a",)),
]
)
report2 = px.run(graph2, strategy="dependency", resume_from=report1)
assert call_count == 0 # a 被恢复
assert report2["a"] == 1
assert report2["b"] == 2
def test_resume_ignores_tasks_not_in_graph() -> None:
"""resume_from 中不在当前图里的任务被忽略。"""
graph1 = Graph.from_specs([TaskSpec("a", fn=lambda: 1), TaskSpec("b", fn=lambda: 2)])
report1 = px.run(graph1, strategy="sequential")
# 新图只有 a,没有 b
graph2 = Graph.from_specs([TaskSpec("a", fn=lambda: 999)])
report2 = px.run(graph2, strategy="sequential", resume_from=report1)
assert "b" not in report2.results
assert report2["a"] == 1 # a 被恢复
class TestRunHistory:
"""RunHistory 测试。"""
def test_save_and_load(self, tmp_path: Path) -> None:
"""保存后能正确加载。"""
history = RunHistory(tmp_path / "runs")
graph = Graph.from_specs([TaskSpec("a", fn=lambda: 42)])
report = px.run(graph, strategy="sequential")
path = history.save(report)
assert path.exists()
loaded = history.load(report.run_id)
assert loaded["a"] == 42
assert loaded.run_id == report.run_id
def test_list_runs(self, tmp_path: Path) -> None:
"""list_runs 返回全部 run_id。"""
history = RunHistory(tmp_path / "runs")
for i in range(3):
graph = Graph.from_specs([TaskSpec(f"t{i}", fn=lambda i=i: i)])
report = px.run(graph, strategy="sequential")
history.save(report)
runs = history.list_runs()
assert len(runs) == 3
def test_latest(self, tmp_path: Path) -> None:
"""latest 返回最近保存的报告。"""
history = RunHistory(tmp_path / "runs")
assert history.latest() is None
graph1 = Graph.from_specs([TaskSpec("a", fn=lambda: 1)])
r1 = px.run(graph1, strategy="sequential")
history.save(r1)
graph2 = Graph.from_specs([TaskSpec("b", fn=lambda: 2)])
r2 = px.run(graph2, strategy="sequential")
history.save(r2)
latest = history.latest()
assert latest is not None
assert latest.run_id == r2.run_id
def test_delete(self, tmp_path: Path) -> None:
"""delete 删除指定报告。"""
history = RunHistory(tmp_path / "runs")
graph = Graph.from_specs([TaskSpec("a", fn=lambda: 1)])
report = px.run(graph, strategy="sequential")
history.save(report)
assert report.run_id in history
assert history.delete(report.run_id)
assert report.run_id not in history
assert not history.delete(report.run_id) # 再删返回 False
def test_load_not_found(self, tmp_path: Path) -> None:
"""加载不存在的 run_id 抛 FileNotFoundError。"""
history = RunHistory(tmp_path / "runs")
with pytest.raises(FileNotFoundError, match="不存在"):
history.load("nonexistent")
def test_len(self, tmp_path: Path) -> None:
"""__len__ 返回存储数量。"""
history = RunHistory(tmp_path / "runs")
assert len(history) == 0
graph = Graph.from_specs([TaskSpec("a", fn=lambda: 1)])
report = px.run(graph, strategy="sequential")
history.save(report)
assert len(history) == 1
def test_contains(self, tmp_path: Path) -> None:
"""__contains__ 检查 run_id 是否存在。"""
history = RunHistory(tmp_path / "runs")
assert "abc" not in history
graph = Graph.from_specs([TaskSpec("a", fn=lambda: 1)])
report = px.run(graph, strategy="sequential")
history.save(report)
assert report.run_id in history
def test_creates_dir(self, tmp_path: Path) -> None:
"""目录不存在时自动创建。"""
dir = tmp_path / "nested" / "runs"
assert not dir.exists()
RunHistory(dir)
assert dir.exists()
def test_resume_from_history(self, tmp_path: Path) -> None:
"""从 RunHistory 加载报告用于 resume_from。"""
history = RunHistory(tmp_path / "runs")
graph = Graph.from_specs([TaskSpec("a", fn=lambda: 42)])
report1 = px.run(graph, strategy="sequential")
history.save(report1)
call_count = 0
def _a() -> int:
nonlocal call_count
call_count += 1
return 999
graph2 = Graph.from_specs([TaskSpec("a", fn=_a)])
prev = history.latest()
assert prev is not None
report2 = px.run(graph2, strategy="sequential", resume_from=prev)
assert call_count == 0
assert report2["a"] == 42