411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""线程执行器测试。
|
||||
|
|
|
|||
|
|
这些用例全部使用**真实线程与真实时间**:执行器的全部价值就在于线程能不能被
|
|||
|
|
停下、能不能被暂停、放弃之后进程还能不能退出。用假的线程模型测不出这些。
|
|||
|
|
|
|||
|
|
为避免偶发失败,协作路径给足超时(5 秒),而"不配合"的用例刻意把任务做成
|
|||
|
|
有限时长(约 5 秒后自行结束),这样即使被放弃,残留的守护线程也不会长期占用资源。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from chongming_task.models import FailureKind
|
|||
|
|
from chongming_task.runtime.executors.base import ExecutionRequest
|
|||
|
|
from chongming_task.runtime.executors.thread import ThreadExecutor
|
|||
|
|
from chongming_task.testing import FakeClock
|
|||
|
|
|
|||
|
|
_COOPERATIVE_WINDOW = 5.0
|
|||
|
|
_ABANDON_WINDOW = 0.2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _request(task_id: str, target: Any, *args: Any) -> ExecutionRequest:
|
|||
|
|
return ExecutionRequest(task_id=task_id, name=task_id, target=target, args=args)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 上下文注入契约
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_context_is_injected_with_the_handle_token_and_metadata() -> None:
|
|||
|
|
"""令牌必须是句柄那一个,否则控制面会停错对象。
|
|||
|
|
|
|||
|
|
这也是"令牌由执行器注入、而非被任务闭包捕获"的直接体现——令牌每次尝试才诞生,
|
|||
|
|
闭包捕获到的只可能是上一次尝试留下的那一个。
|
|||
|
|
"""
|
|||
|
|
seen: dict[str, Any] = {}
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
seen["token"] = ctx.token
|
|||
|
|
seen["task_id"] = ctx.task_id
|
|||
|
|
seen["name"] = ctx.name
|
|||
|
|
seen["attempt"] = ctx.attempt
|
|||
|
|
return "ok"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(
|
|||
|
|
ExecutionRequest(task_id="t-ctx", name="抽取", target=work, attempt=2)
|
|||
|
|
)
|
|||
|
|
assert handle.wait(timeout=_COOPERATIVE_WINDOW) is not None
|
|||
|
|
assert seen["token"] is handle.token
|
|||
|
|
assert seen["task_id"] == "t-ctx"
|
|||
|
|
assert seen["name"] == "抽取"
|
|||
|
|
assert seen["attempt"] == 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_arguments_are_forwarded_after_the_context() -> None:
|
|||
|
|
def work(ctx: Any, left: int, right: int, *, scale: int = 1) -> int:
|
|||
|
|
del ctx
|
|||
|
|
return (left + right) * scale
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(
|
|||
|
|
ExecutionRequest(
|
|||
|
|
task_id="t-args",
|
|||
|
|
name="t-args",
|
|||
|
|
target=work,
|
|||
|
|
args=(2, 3),
|
|||
|
|
kwargs={"scale": 10},
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert outcome.value == 50
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_checkpoint_refreshes_the_heartbeat() -> None:
|
|||
|
|
"""checkpoint 就是"我还在推进"的信号,心跳绑在它上面是有意的。"""
|
|||
|
|
clock = FakeClock()
|
|||
|
|
observed: list[float] = []
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
for _ in range(3):
|
|||
|
|
clock.advance(30)
|
|||
|
|
observed.append(ctx.heartbeat.idle_seconds())
|
|||
|
|
ctx.checkpoint()
|
|||
|
|
return "ok"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor(clock=clock).launch(_request("t-beat", work))
|
|||
|
|
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert outcome.ok
|
|||
|
|
assert observed == [30.0, 30.0, 30.0], "每次 checkpoint 都应把静默时长清零"
|
|||
|
|
assert handle.heartbeat.idle_seconds() == 0.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# async 任务
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_async_target_is_actually_awaited() -> None:
|
|||
|
|
"""``async def`` 任务必须真的被执行完,而不是把协程对象当返回值。
|
|||
|
|
|
|||
|
|
后者曾经是静默的:没有存储时它看起来"成功"了,返回值却是个 ``coroutine``;
|
|||
|
|
有存储时才会以"返回值不可序列化"失败。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
async def work(ctx: Any) -> str:
|
|||
|
|
await asyncio.sleep(0)
|
|||
|
|
return "done"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-async", work))
|
|||
|
|
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert outcome.ok
|
|||
|
|
assert outcome.value == "done"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_async_target_cooperates_with_stop() -> None:
|
|||
|
|
"""协作式停止对 async 任务同样有效:``await ctx.acheckpoint()``。"""
|
|||
|
|
|
|||
|
|
async def work(ctx: Any) -> str:
|
|||
|
|
for _ in range(10_000):
|
|||
|
|
await ctx.acheckpoint()
|
|||
|
|
await asyncio.sleep(0.005)
|
|||
|
|
return "never"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-async-stop", work))
|
|||
|
|
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert not outcome.ok, "停止请求之后不该报成功"
|
|||
|
|
assert not outcome.abandoned, "它会到达检查点,应当优雅收尾而不是被放弃"
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.CANCELLED
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_async_target_failure_is_reported_across_the_loop() -> None:
|
|||
|
|
"""协程里抛的异常照常转成结构化失败,不能被事件循环吞掉。"""
|
|||
|
|
|
|||
|
|
async def work(ctx: Any) -> str:
|
|||
|
|
await asyncio.sleep(0)
|
|||
|
|
raise ValueError("协程里炸了")
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-async-boom", work))
|
|||
|
|
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert not outcome.ok
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.EXCEPTION
|
|||
|
|
assert "协程里炸了" in outcome.failure.message
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 正常路径
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_launch_returns_immediately_without_waiting_for_the_task() -> None:
|
|||
|
|
"""调度器契约:launch 必须立刻返回,否则调度循环会被单个任务卡住。"""
|
|||
|
|
release = threading.Event()
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
del ctx
|
|||
|
|
release.wait(5)
|
|||
|
|
return "done"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-async-launch", work))
|
|||
|
|
try:
|
|||
|
|
assert handle.running is True
|
|||
|
|
assert handle.wait(timeout=0.05) is None, "任务尚未结束,wait 应返回 None"
|
|||
|
|
finally:
|
|||
|
|
release.set()
|
|||
|
|
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert outcome.ok
|
|||
|
|
assert outcome.value == "done"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_successful_task_returns_its_value() -> None:
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-ok", lambda ctx: 6 * 7))
|
|||
|
|
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert outcome.ok
|
|||
|
|
assert outcome.value == 42
|
|||
|
|
assert handle.running is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_failing_task_reports_exception_with_traceback() -> None:
|
|||
|
|
def boom(ctx: Any) -> None:
|
|||
|
|
del ctx
|
|||
|
|
raise ValueError("坏掉了")
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-boom", boom))
|
|||
|
|
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert not outcome.ok
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.EXCEPTION
|
|||
|
|
assert "ValueError" in outcome.failure.message
|
|||
|
|
assert outcome.failure.traceback is not None
|
|||
|
|
assert "boom" in outcome.failure.traceback
|
|||
|
|
assert outcome.failure.retryable is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 协作式停止
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_stop_completes_cooperatively_when_task_checks_the_token() -> None:
|
|||
|
|
started = threading.Event()
|
|||
|
|
steps: list[int] = []
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
started.set()
|
|||
|
|
for index in range(100_000):
|
|||
|
|
ctx.checkpoint()
|
|||
|
|
steps.append(index)
|
|||
|
|
time.sleep(0.001)
|
|||
|
|
return "ran to completion"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-coop", work))
|
|||
|
|
assert started.wait(_COOPERATIVE_WINDOW)
|
|||
|
|
|
|||
|
|
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW, reason="发布窗口")
|
|||
|
|
assert outcome.abandoned is False
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.CANCELLED
|
|||
|
|
assert outcome.failure.retryable is False, "取消不是失败,绝不能被重试"
|
|||
|
|
assert handle.running is False
|
|||
|
|
assert len(steps) < 100_000, "任务应当在收到停止请求后提前退出"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_cancellation_is_not_swallowed_by_a_broad_except_in_the_task_body() -> None:
|
|||
|
|
"""这正是 ``TaskCancelled`` 继承 ``BaseException`` 的理由。
|
|||
|
|
|
|||
|
|
若它继承 ``Exception``,下面这种极常见的写法会把停止信号吃掉,任务永远停不掉。
|
|||
|
|
"""
|
|||
|
|
started = threading.Event()
|
|||
|
|
swallowed: list[BaseException] = []
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
started.set()
|
|||
|
|
try:
|
|||
|
|
while True:
|
|||
|
|
ctx.checkpoint()
|
|||
|
|
time.sleep(0.001)
|
|||
|
|
except Exception as exc: # 用户代码里最常见的写法
|
|||
|
|
swallowed.append(exc)
|
|||
|
|
return "signal was swallowed"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-swallow", work))
|
|||
|
|
assert started.wait(_COOPERATIVE_WINDOW)
|
|||
|
|
|
|||
|
|
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert swallowed == [], "取消信号被 except Exception 吞掉了"
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.CANCELLED
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_stop_is_idempotent() -> None:
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
del ctx
|
|||
|
|
return "quick"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-idem", work))
|
|||
|
|
first = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
second = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert first.ok
|
|||
|
|
assert second.ok
|
|||
|
|
assert handle.running is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 协作式暂停(D6 定案的语义,端到端验证)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_pause_suspends_a_running_task_and_resume_continues_it() -> None:
|
|||
|
|
started = threading.Event()
|
|||
|
|
progress: list[int] = []
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
started.set()
|
|||
|
|
for index in range(100_000):
|
|||
|
|
ctx.checkpoint()
|
|||
|
|
progress.append(index)
|
|||
|
|
time.sleep(0.001)
|
|||
|
|
return "ran to completion"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-pause", work))
|
|||
|
|
assert started.wait(_COOPERATIVE_WINDOW)
|
|||
|
|
|
|||
|
|
assert handle.pause(timeout=_COOPERATIVE_WINDOW) is True
|
|||
|
|
frozen = len(progress)
|
|||
|
|
assert frozen > 0, "暂停前任务应当已经推进过"
|
|||
|
|
time.sleep(0.05)
|
|||
|
|
assert len(progress) == frozen, "暂停生效后任务不应继续推进"
|
|||
|
|
|
|||
|
|
handle.resume()
|
|||
|
|
time.sleep(0.05)
|
|||
|
|
assert len(progress) > frozen, "恢复后任务应继续推进"
|
|||
|
|
|
|||
|
|
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.CANCELLED
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_pause_reports_false_when_task_never_reaches_a_checkpoint() -> None:
|
|||
|
|
"""这正是"暂停没生效必须回滚为 RUNNING"的依据:如实返回 False。"""
|
|||
|
|
started = threading.Event()
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
del ctx
|
|||
|
|
started.set()
|
|||
|
|
for _ in range(500):
|
|||
|
|
time.sleep(0.01)
|
|||
|
|
return "ignored"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-nopause", work))
|
|||
|
|
assert started.wait(_COOPERATIVE_WINDOW)
|
|||
|
|
assert handle.pause(timeout=0.2) is False
|
|||
|
|
handle.stop(graceful_timeout=_ABANDON_WINDOW)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_stop_wakes_a_paused_task() -> None:
|
|||
|
|
"""暂停中的任务必须能被停止,否则暂停就成了不可撤销的挂起。"""
|
|||
|
|
started = threading.Event()
|
|||
|
|
|
|||
|
|
def work(ctx: Any) -> str:
|
|||
|
|
started.set()
|
|||
|
|
for _ in range(100_000):
|
|||
|
|
ctx.checkpoint()
|
|||
|
|
time.sleep(0.001)
|
|||
|
|
return "ran to completion"
|
|||
|
|
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-pause-stop", work))
|
|||
|
|
assert started.wait(_COOPERATIVE_WINDOW)
|
|||
|
|
assert handle.pause(timeout=_COOPERATIVE_WINDOW) is True
|
|||
|
|
|
|||
|
|
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW, reason="运维介入")
|
|||
|
|
assert outcome.abandoned is False
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.CANCELLED
|
|||
|
|
assert "运维介入" in outcome.failure.message
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 无法停止:放弃
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _uncooperative(ctx: Any) -> str:
|
|||
|
|
"""刻意不检查令牌;约 5 秒后自行结束,避免残留线程长期占用资源。"""
|
|||
|
|
del ctx
|
|||
|
|
for _ in range(500):
|
|||
|
|
time.sleep(0.01)
|
|||
|
|
return "ignored the stop request"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_uncooperative_task_is_abandoned_and_reported_as_lost() -> None:
|
|||
|
|
"""线程无法强杀:必须如实上报,不能假装已停止。"""
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-stubborn", _uncooperative))
|
|||
|
|
outcome = handle.stop(graceful_timeout=_ABANDON_WINDOW)
|
|||
|
|
assert outcome.abandoned is True
|
|||
|
|
assert outcome.ok is False
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
assert outcome.failure.kind is FailureKind.LOST
|
|||
|
|
assert "无法强制终止" in outcome.failure.message
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_abandoned_task_still_reports_running() -> None:
|
|||
|
|
"""它确实还在跑。谎报为 False 会让上层以为资源已经释放。"""
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-still-running", _uncooperative))
|
|||
|
|
handle.stop(graceful_timeout=_ABANDON_WINDOW)
|
|||
|
|
assert handle.running is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_abandoned_outcome_is_sticky() -> None:
|
|||
|
|
"""已记为 LOST 的结论不应被后台线程后来的返回悄悄改写,否则审计自相矛盾。"""
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-sticky", _uncooperative))
|
|||
|
|
first = handle.stop(graceful_timeout=_ABANDON_WINDOW)
|
|||
|
|
assert handle.wait(timeout=_ABANDON_WINDOW) is first
|
|||
|
|
assert handle.stop(graceful_timeout=_ABANDON_WINDOW) is first
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_worker_threads_are_daemon_so_an_abandoned_task_cannot_block_shutdown() -> None:
|
|||
|
|
"""``ThreadPoolExecutor`` 的工作线程会在解释器退出时被 join,一个不响应取消
|
|||
|
|
的任务会让整个进程无法退出。这里用守护线程正是为了避开这一点。"""
|
|||
|
|
handle = ThreadExecutor().launch(_request("t-daemon", _uncooperative))
|
|||
|
|
try:
|
|||
|
|
assert handle.thread.daemon is True
|
|||
|
|
assert handle.thread.name == "task-t-daemon"
|
|||
|
|
finally:
|
|||
|
|
handle.stop(graceful_timeout=_ABANDON_WINDOW)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("graceful_timeout", [0.0, 0.05])
|
|||
|
|
def test_zero_or_tiny_graceful_window_still_yields_a_conclusion(
|
|||
|
|
graceful_timeout: float,
|
|||
|
|
) -> None:
|
|||
|
|
"""优雅窗口再短也必须给出结论,不能抛异常或永远阻塞。"""
|
|||
|
|
handle = ThreadExecutor().launch(_request(f"t-window-{graceful_timeout}", _uncooperative))
|
|||
|
|
outcome = handle.stop(graceful_timeout=graceful_timeout)
|
|||
|
|
assert outcome.abandoned is True
|
|||
|
|
assert outcome.failure is not None
|