Files
pyflowx/tests/test_cancellation.py
T

367 lines
12 KiB
Python

"""测试任务取消与优雅停止."""
from __future__ import annotations
import threading
import time
from typing import Any
import pytest
import pyflowx as px
from pyflowx.cancellation import CancelToken, _is_cancelled
from pyflowx.executors import _mark_remaining_skipped
from pyflowx.report import RunReport
from pyflowx.task import TaskStatus
# ---------------------------------------------------------------------- #
# CancelToken 单元测试
# ---------------------------------------------------------------------- #
class TestCancelToken:
"""测试 CancelToken 基本行为."""
def test_initial_state_not_cancelled(self) -> None:
"""新建的 token 不应处于取消状态."""
token = CancelToken()
assert not token.is_cancelled
assert not token.is_set()
assert not bool(token)
assert token.reason is None
def test_cancel_sets_state(self) -> None:
"""cancel() 后应处于取消状态."""
token = CancelToken()
token.cancel()
assert token.is_cancelled
assert token.is_set()
assert bool(token)
def test_cancel_with_reason(self) -> None:
"""cancel() 可附带原因描述."""
token = CancelToken()
token.cancel("用户中断")
assert token.is_cancelled
assert token.reason == "用户中断"
def test_cancel_without_reason_keeps_none(self) -> None:
"""cancel() 不传原因时 reason 保持 None."""
token = CancelToken()
token.cancel()
assert token.reason is None
def test_wait_returns_true_when_cancelled(self) -> None:
"""wait() 在已取消时应立即返回 True."""
token = CancelToken()
token.cancel()
assert token.wait(timeout=0.1)
def test_wait_timeout_returns_false(self) -> None:
"""wait() 在超时未取消时应返回 False."""
token = CancelToken()
assert not token.wait(timeout=0.05)
def test_wait_blocks_until_cancelled(self) -> None:
"""wait() 应阻塞直到另一线程调用 cancel()."""
token = CancelToken()
result: list[bool] = []
def _waiter() -> None:
result.append(token.wait(timeout=2.0))
t = threading.Thread(target=_waiter)
t.start()
time.sleep(0.05) # 等待 waiter 开始等待
token.cancel()
t.join(timeout=1.0)
assert result == [True]
def test_repr_active(self) -> None:
"""repr 在活跃状态应显示 active."""
token = CancelToken()
assert "active" in repr(token)
def test_repr_cancelled_with_reason(self) -> None:
"""repr 在取消状态应显示 cancelled 和 reason."""
token = CancelToken()
token.cancel("测试原因")
r = repr(token)
assert "cancelled" in r
assert "测试原因" in r
def test_link_threading_event(self) -> None:
"""link() 应在 threading.Event 设置时联动取消 token."""
token = CancelToken()
event = threading.Event()
token.link(event)
event.set()
time.sleep(0.1) # 等待 watcher 线程响应
assert token.is_cancelled
# ---------------------------------------------------------------------- #
# _is_cancelled 辅助函数测试
# ---------------------------------------------------------------------- #
class TestIsCancelled:
"""测试 _is_cancelled 统一检查函数."""
def test_none_returns_false(self) -> None:
"""None 应返回 False."""
assert not _is_cancelled(None)
def test_cancel_token_not_cancelled(self) -> None:
"""未取消的 CancelToken 应返回 False."""
token = CancelToken()
assert not _is_cancelled(token)
def test_cancel_token_cancelled(self) -> None:
"""已取消的 CancelToken 应返回 True."""
token = CancelToken()
token.cancel()
assert _is_cancelled(token)
def test_threading_event_not_set(self) -> None:
"""未设置的 threading.Event 应返回 False."""
event = threading.Event()
assert not _is_cancelled(event)
def test_threading_event_set(self) -> None:
"""已设置的 threading.Event 应返回 True."""
event = threading.Event()
event.set()
assert _is_cancelled(event)
# ---------------------------------------------------------------------- #
# _mark_remaining_skipped 测试
# ---------------------------------------------------------------------- #
class TestMarkRemainingSkipped:
"""测试 _mark_remaining_skipped 函数."""
def test_marks_all_unexecuted_tasks(self) -> None:
"""未执行的任务应被标记为 SKIPPED."""
graph = px.Graph.from_specs(
[
px.TaskSpec("a", lambda: 1),
px.TaskSpec("b", lambda: 2, depends_on=("a",)),
px.TaskSpec("c", lambda: 3, depends_on=("b",)),
]
)
report = RunReport()
# 模拟 a 已完成
from datetime import datetime
from pyflowx.task import TaskResult
report.results["a"] = TaskResult(
spec=graph.spec("a"),
status=TaskStatus.SUCCESS,
value=1,
finished_at=datetime.now(),
)
_mark_remaining_skipped(graph, report, None)
assert "a" in report.results
assert report.results["a"].status == TaskStatus.SUCCESS
assert report.results["b"].status == TaskStatus.SKIPPED
assert report.results["b"].reason == "执行被取消"
assert report.results["c"].status == TaskStatus.SKIPPED
def test_marks_all_when_none_executed(self) -> None:
"""无已完成任务时,所有任务应被标记为 SKIPPED."""
graph = px.Graph.from_specs(
[
px.TaskSpec("a", lambda: 1),
px.TaskSpec("b", lambda: 2, depends_on=("a",)),
]
)
report = RunReport()
_mark_remaining_skipped(graph, report, None)
assert len(report.results) == 2
assert report.results["a"].status == TaskStatus.SKIPPED
assert report.results["b"].status == TaskStatus.SKIPPED
def test_no_op_when_all_executed(self) -> None:
"""所有任务已完成时不应添加任何结果."""
from datetime import datetime
from pyflowx.task import TaskResult
graph = px.Graph.from_specs([px.TaskSpec("a", lambda: 1)])
report = RunReport()
report.results["a"] = TaskResult(
spec=graph.spec("a"),
status=TaskStatus.SUCCESS,
value=1,
finished_at=datetime.now(),
)
_mark_remaining_skipped(graph, report, None)
assert len(report.results) == 1
# ---------------------------------------------------------------------- #
# 外部取消集成测试
# ---------------------------------------------------------------------- #
def _make_cancellable_graph() -> px.Graph:
"""构建一个可被取消的图:a → b → c → d,每步有延迟."""
def a() -> int:
time.sleep(0.05)
return 1
def b(a: int) -> int:
time.sleep(0.05)
return a + 1
def c(b: int) -> int:
time.sleep(0.05)
return b + 1
def d(c: int) -> int:
time.sleep(0.05)
return c + 1
return px.Graph.from_specs(
[
px.TaskSpec("a", a),
px.TaskSpec("b", b, depends_on=("a",)),
px.TaskSpec("c", c, depends_on=("b",)),
px.TaskSpec("d", d, depends_on=("c",)),
]
)
class TestExternalCancel:
"""测试通过 CancelToken 外部取消 run()."""
@pytest.mark.parametrize("strategy", ["sequential", "thread", "async", "dependency"])
def test_cancel_before_run(self, strategy: str) -> None:
"""运行前取消:所有任务应标记为 SKIPPED."""
graph = _make_cancellable_graph()
token = CancelToken()
token.cancel()
report = px.run(graph, strategy=strategy, cancel_event=token) # type: ignore[arg-type]
assert not report.success
assert len(report.results) == 4
for name in ("a", "b", "c", "d"):
assert report.result_of(name).status == TaskStatus.SKIPPED
@pytest.mark.parametrize("strategy", ["sequential", "thread", "async", "dependency"])
def test_cancel_during_run(self, strategy: str) -> None:
"""运行中取消:已完成任务保持 SUCCESS,剩余任务标记为 SKIPPED."""
graph = _make_cancellable_graph()
token = CancelToken()
events: list[str] = []
def _on_event(event: px.TaskEvent) -> None:
events.append(event.task)
# a 完成后触发取消
if event.task == "a" and event.status == TaskStatus.SUCCESS:
token.cancel()
report = px.run(graph, strategy=strategy, cancel_event=token, on_event=_on_event) # type: ignore[arg-type]
assert not report.success
# a 应该成功
assert report.result_of("a").status == TaskStatus.SUCCESS
# 取消后未运行的任务应为 SKIPPED(d 一定未运行)
assert report.result_of("d").status == TaskStatus.SKIPPED
def test_cancel_with_threading_event(self) -> None:
"""threading.Event 也可作为取消信号."""
graph = _make_cancellable_graph()
event = threading.Event()
def _cancel_after_a() -> None:
time.sleep(0.08)
event.set()
t = threading.Thread(target=_cancel_after_a)
t.start()
report = px.run(graph, strategy="sequential", cancel_event=event) # type: ignore[arg-type]
t.join()
assert not report.success
assert report.result_of("a").status == TaskStatus.SUCCESS
def test_cancel_preserves_completed_results(self) -> None:
"""取消后已完成任务的结果应保留在 report 中."""
graph = _make_cancellable_graph()
token = CancelToken()
def _on_event(event: px.TaskEvent) -> None:
if event.task == "b" and event.status == TaskStatus.SUCCESS:
token.cancel()
report = px.run(graph, strategy="sequential", cancel_event=token, on_event=_on_event) # type: ignore[arg-type]
assert report.result_of("a").status == TaskStatus.SUCCESS
assert report.result_of("a").value == 1
assert report.result_of("b").status == TaskStatus.SUCCESS
assert report.result_of("b").value == 2
def test_no_cancel_returns_normal_report(self) -> None:
"""不传 cancel_event 时应正常完成."""
graph = _make_cancellable_graph()
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("d").value == 4
# ---------------------------------------------------------------------- #
# KeyboardInterrupt 测试
# ---------------------------------------------------------------------- #
class TestKeyboardInterrupt:
"""测试 KeyboardInterrupt 优雅处理."""
def test_keyboard_interrupt_marks_remaining_skipped(self) -> None:
"""KeyboardInterrupt 时未完成任务应标记为 SKIPPED."""
from unittest.mock import patch
from pyflowx import executors
graph = _make_cancellable_graph()
def _interrupt_after_first_layer(
graph: Any, layers: Any, context: Any, report: Any, backend: Any, on_event: Any, cancel_event: Any = None
) -> None:
# 只运行第一层,然后抛 KeyboardInterrupt
executors.SequentialLayerRunner.execute(layers[0], graph, context, report, backend, 1, on_event)
raise KeyboardInterrupt
with patch.object(executors, "_drive_sequential", _interrupt_after_first_layer):
report = px.run(graph, strategy="sequential")
assert not report.success
assert report.result_of("a").status == TaskStatus.SUCCESS
# b/c/d 应被标记为 SKIPPED
assert report.result_of("d").status == TaskStatus.SKIPPED
def test_keyboard_interrupt_with_dependency_strategy(self) -> None:
"""dependency 策略下 KeyboardInterrupt 也应优雅处理."""
from unittest.mock import patch
from pyflowx import executors
def fn() -> int:
return 1
graph = px.Graph.from_specs(
[
px.TaskSpec("a", fn),
px.TaskSpec("b", fn, depends_on=("a",)),
]
)
async def _interrupt(*args: Any, **kwargs: Any) -> None:
raise KeyboardInterrupt
with patch.object(executors.DependencyRunner, "execute", _interrupt):
report = px.run(graph, strategy="dependency")
assert not report.success