616 lines
20 KiB
Python
616 lines
20 KiB
Python
"""NodeManager 单元测试。
|
|
|
|
覆盖命令解析、环境变量注入、CUDA 路径处理、进程启动失败分支、实例复用、
|
|
调用错误处理、回收线程以及停止清理等真实生命周期路径。
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.db import Database
|
|
from app.node_manager import (
|
|
NodeManager,
|
|
NodeRuntime,
|
|
_cuda_library_dirs,
|
|
_idle_seconds,
|
|
_now_iso,
|
|
_with_cuda_library_path,
|
|
)
|
|
from wov_sdk.models import InvokeRequest, NodeManifest
|
|
|
|
WORKSPACE = Path(__file__).resolve().parent.parent.parent
|
|
# 从真实节点仓库加载 Echo manifest,测试使用真实协议数据。
|
|
_BASE_ECHO_MANIFEST = NodeManifest.load(WORKSPACE / "wov-node-echo" / "node.manifest.json")
|
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path) -> Database:
|
|
return Database(tmp_path / "wov.db")
|
|
|
|
|
|
def _register(db: Database, manifest: NodeManifest) -> None:
|
|
"""把 manifest 写入测试数据库,模拟注册节点。"""
|
|
db.upsert_node(manifest)
|
|
|
|
|
|
def echo_manifest() -> NodeManifest:
|
|
"""返回 Echo 节点 manifest 的副本,避免测试间共享可变对象。"""
|
|
return NodeManifest.from_dict(_BASE_ECHO_MANIFEST.to_dict())
|
|
|
|
|
|
def test_time_helpers() -> None:
|
|
"""验证时间工具对正常、空与非法输入的返回。"""
|
|
now = _now_iso()
|
|
assert datetime.fromisoformat(now)
|
|
assert _idle_seconds(now) < 0.1
|
|
old = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
|
|
assert _idle_seconds(old) >= 9
|
|
assert _idle_seconds("") == 0
|
|
assert _idle_seconds("invalid") == 0
|
|
|
|
|
|
def test_resolve_command_and_env(db: Database) -> None:
|
|
"""验证 python 命令解析回退与固定命令保持原样。"""
|
|
manager = NodeManager(db)
|
|
python_command = NodeManifest(
|
|
id="x",
|
|
name="x",
|
|
version="1",
|
|
capability="x",
|
|
command=["python", "-m", "x"],
|
|
)
|
|
assert manager._resolve_command(python_command)[0] == sys.executable
|
|
|
|
fixed_command = NodeManifest(
|
|
id="x",
|
|
name="x",
|
|
version="1",
|
|
capability="x",
|
|
command=["node", "index.js"],
|
|
)
|
|
assert manager._resolve_command(fixed_command) == ["node", "index.js"]
|
|
|
|
env = manager._node_env(python_command)
|
|
assert "wov-sdk" in env["PYTHONPATH"]
|
|
|
|
|
|
def test_resolve_command_prefers_node_venv(db: Database, tmp_path, monkeypatch) -> None:
|
|
"""验证 Windows 与 Linux 虚拟环境解释器优先级。"""
|
|
monkeypatch.setattr("app.node_manager.WORKSPACE_ROOT", tmp_path)
|
|
repo = tmp_path / "wov-node-demo"
|
|
python_exe = repo / ".venv" / "Scripts" / "python.exe"
|
|
python_exe.parent.mkdir(parents=True)
|
|
python_exe.write_bytes(b"")
|
|
manifest = NodeManifest(
|
|
id="x",
|
|
name="x",
|
|
version="1",
|
|
capability="x",
|
|
command=["python", "-m", "x"],
|
|
repo_dir="wov-node-demo",
|
|
)
|
|
manager = NodeManager(db)
|
|
assert manager._resolve_command(manifest)[0] == str(python_exe)
|
|
|
|
python_exe.unlink()
|
|
unix_python = repo / ".venv" / "bin" / "python"
|
|
unix_python.parent.mkdir(parents=True)
|
|
unix_python.write_bytes(b"")
|
|
assert manager._resolve_command(manifest)[0] == str(unix_python)
|
|
|
|
|
|
def test_cuda_library_dirs_finds_unix_nvidia_libs(tmp_path) -> None:
|
|
"""验证 Linux site-packages 下的 cublas/cudnn lib 目录被发现。"""
|
|
cublas = (
|
|
tmp_path / ".venv" / "lib" / "python3.12" / "site-packages" / "nvidia" / "cublas" / "lib"
|
|
)
|
|
cudnn = (
|
|
tmp_path / ".venv" / "lib" / "python3.12" / "site-packages" / "nvidia" / "cudnn" / "lib"
|
|
)
|
|
cublas.mkdir(parents=True)
|
|
cudnn.mkdir(parents=True)
|
|
|
|
dirs = _cuda_library_dirs(tmp_path)
|
|
|
|
assert cublas in dirs
|
|
assert cudnn in dirs
|
|
|
|
|
|
def test_cuda_library_dirs_finds_windows_nvidia_bins(tmp_path) -> None:
|
|
"""验证 Windows Lib/site-packages 下的 bin 目录被发现。"""
|
|
cublas = tmp_path / ".venv" / "Lib" / "site-packages" / "nvidia" / "cublas" / "bin"
|
|
cublas.mkdir(parents=True)
|
|
|
|
dirs = _cuda_library_dirs(tmp_path)
|
|
|
|
assert cublas in dirs
|
|
|
|
|
|
def test_with_cuda_library_path_prepends_on_posix(monkeypatch) -> None:
|
|
"""验证 Linux 下 CUDA 目录插入 LD_LIBRARY_PATH 开头。"""
|
|
monkeypatch.setattr(os, "name", "posix")
|
|
monkeypatch.setattr(
|
|
"app.node_manager._cuda_library_dirs",
|
|
lambda repo_dir: ["/opt/nvidia/cublas/lib", "/opt/nvidia/cudnn/lib"],
|
|
)
|
|
|
|
env = _with_cuda_library_path({"LD_LIBRARY_PATH": "/usr/lib/foo"}, Path("/repo"))
|
|
|
|
entries = env["LD_LIBRARY_PATH"].split(os.pathsep)
|
|
assert entries[:2] == ["/opt/nvidia/cublas/lib", "/opt/nvidia/cudnn/lib"]
|
|
assert entries[-1] == "/usr/lib/foo"
|
|
|
|
|
|
def test_with_cuda_library_path_uses_path_on_windows(monkeypatch) -> None:
|
|
"""验证 Windows 下 CUDA 目录插入 PATH 开头。"""
|
|
monkeypatch.setattr(os, "name", "nt")
|
|
monkeypatch.setattr(
|
|
"app.node_manager._cuda_library_dirs",
|
|
lambda repo_dir: ["/opt/nvidia/cublas/bin"],
|
|
)
|
|
|
|
env = _with_cuda_library_path({"PATH": "/usr/local/bin"}, Path("/repo"))
|
|
|
|
entries = env["PATH"].split(os.pathsep)
|
|
assert entries[0] == "/opt/nvidia/cublas/bin"
|
|
assert entries[-1] == "/usr/local/bin"
|
|
|
|
|
|
def test_with_cuda_library_path_without_existing(monkeypatch) -> None:
|
|
"""验证环境变量原本不存在时直接创建。"""
|
|
monkeypatch.setattr(os, "name", "posix")
|
|
monkeypatch.setattr(
|
|
"app.node_manager._cuda_library_dirs",
|
|
lambda repo_dir: ["/opt/nvidia/cublas/lib"],
|
|
)
|
|
|
|
env = _with_cuda_library_path({}, Path("/repo"))
|
|
|
|
assert env["LD_LIBRARY_PATH"] == "/opt/nvidia/cublas/lib"
|
|
|
|
|
|
def test_with_cuda_library_path_deduplicates(monkeypatch) -> None:
|
|
"""验证已存在的目录不会被重复追加。"""
|
|
monkeypatch.setattr(os, "name", "posix")
|
|
monkeypatch.setattr(
|
|
"app.node_manager._cuda_library_dirs",
|
|
lambda repo_dir: ["/opt/nvidia/cublas/lib", "/opt/nvidia/cudnn/lib"],
|
|
)
|
|
|
|
env = _with_cuda_library_path(
|
|
{"LD_LIBRARY_PATH": "/opt/nvidia/cublas/lib:/usr/lib/foo"},
|
|
Path("/repo"),
|
|
)
|
|
|
|
entries = env["LD_LIBRARY_PATH"].split(os.pathsep)
|
|
assert entries == ["/opt/nvidia/cudnn/lib", "/opt/nvidia/cublas/lib", "/usr/lib/foo"]
|
|
|
|
|
|
def test_with_cuda_library_path_all_present(monkeypatch) -> None:
|
|
"""验证所有目录都已存在时环境变量保持不变。"""
|
|
monkeypatch.setattr(os, "name", "posix")
|
|
monkeypatch.setattr(
|
|
"app.node_manager._cuda_library_dirs",
|
|
lambda repo_dir: ["/opt/nvidia/cublas/lib"],
|
|
)
|
|
original = {"LD_LIBRARY_PATH": "/opt/nvidia/cublas/lib:/usr/lib/foo"}
|
|
|
|
env = _with_cuda_library_path(dict(original), Path("/repo"))
|
|
|
|
assert env["LD_LIBRARY_PATH"] == original["LD_LIBRARY_PATH"]
|
|
|
|
|
|
def test_with_cuda_library_path_without_libs(monkeypatch) -> None:
|
|
"""验证没有任何 CUDA 库时环境变量原样返回。"""
|
|
monkeypatch.setattr("app.node_manager._cuda_library_dirs", lambda repo_dir: [])
|
|
|
|
env = _with_cuda_library_path({"LD_LIBRARY_PATH": "/usr/lib/foo"}, Path("/repo"))
|
|
|
|
assert env == {"LD_LIBRARY_PATH": "/usr/lib/foo"}
|
|
|
|
|
|
def test_node_env_injects_cuda_library_path(db: Database, tmp_path, monkeypatch) -> None:
|
|
"""验证 _node_env 会注入 SDK、manifest 变量与 CUDA 路径。"""
|
|
monkeypatch.setattr("app.node_manager.WORKSPACE_ROOT", tmp_path)
|
|
repo = tmp_path / "wov-node-whisper"
|
|
cublas = repo / ".venv" / "lib" / "python3.12" / "site-packages" / "nvidia" / "cublas" / "lib"
|
|
cublas.mkdir(parents=True)
|
|
manifest = NodeManifest(
|
|
id="x",
|
|
name="x",
|
|
version="1",
|
|
capability="x",
|
|
command=["python", "-m", "x"],
|
|
repo_dir="wov-node-whisper",
|
|
env={"WOV_NODE_PORT": "0"},
|
|
)
|
|
|
|
env = NodeManager(db)._node_env(manifest)
|
|
|
|
assert str(cublas) in env["LD_LIBRARY_PATH"].split(os.pathsep)
|
|
assert env["WOV_NODE_PORT"] == "0"
|
|
assert "wov-sdk" in env["PYTHONPATH"]
|
|
|
|
|
|
def test_acquire_unregistered(db: Database) -> None:
|
|
"""验证未注册节点无法获取实例。"""
|
|
manager = NodeManager(db)
|
|
with pytest.raises(ValueError):
|
|
manager.acquire("missing")
|
|
|
|
|
|
def test_start_missing_repo(db: Database) -> None:
|
|
"""验证 repo_dir 不存在时启动失败并记录 error。"""
|
|
manifest = echo_manifest()
|
|
manifest.repo_dir = "missing-repo"
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
with pytest.raises(RuntimeError, match="repo_dir not found"):
|
|
manager.acquire("echo")
|
|
assert db.list_instances()[0]["status"] == "error"
|
|
|
|
|
|
def test_start_command_not_found(db: Database) -> None:
|
|
"""验证命令不存在时启动失败并记录 error。"""
|
|
manifest = echo_manifest()
|
|
manifest.command = ["definitely-not-a-real-wov-command"]
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
with pytest.raises(RuntimeError):
|
|
manager.acquire("echo")
|
|
assert db.list_instances()[0]["status"] == "error"
|
|
|
|
|
|
def test_start_no_ready_timeout(db: Database, monkeypatch) -> None:
|
|
"""验证超时未打印就绪行时启动失败。"""
|
|
monkeypatch.setattr("app.node_manager.NODE_READY_TIMEOUT_SECONDS", 0.2)
|
|
manifest = echo_manifest()
|
|
manifest.command = ["python", "-c", "import time; time.sleep(0.5)"]
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
with pytest.raises(RuntimeError, match="did not report readiness"):
|
|
manager.acquire("echo")
|
|
assert db.list_instances()[0]["status"] == "error"
|
|
|
|
|
|
def test_start_bad_ready_line(db: Database) -> None:
|
|
"""验证就绪行缺少端口时启动失败。"""
|
|
manifest = echo_manifest()
|
|
manifest.command = ["python", "-u", str(FIXTURES / "bad_ready_node.py")]
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
with pytest.raises(RuntimeError, match="did not report readiness"):
|
|
manager.acquire("echo")
|
|
assert db.list_instances()[0]["status"] == "error"
|
|
|
|
|
|
def test_start_health_check_failure(db: Database) -> None:
|
|
"""验证 /health 返回错误状态码时启动失败。"""
|
|
manifest = echo_manifest()
|
|
manifest.command = ["python", "-u", str(FIXTURES / "bad_node.py")]
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
with pytest.raises(RuntimeError, match="health check failed"):
|
|
manager.acquire("echo")
|
|
assert db.list_instances()[0]["status"] == "error"
|
|
|
|
|
|
def test_start_health_check_bad_status(db: Database) -> None:
|
|
"""验证 /health 返回非 200 状态码时启动失败。"""
|
|
manifest = echo_manifest()
|
|
manifest.command = ["python", "-u", str(FIXTURES / "bad_status_node.py")]
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
with pytest.raises(RuntimeError, match="health check returned 201"):
|
|
manager.acquire("echo")
|
|
assert db.list_instances()[0]["status"] == "error"
|
|
|
|
|
|
def test_stderr_is_captured(db: Database) -> None:
|
|
"""验证节点 stderr 会被缓存到运行时供诊断。"""
|
|
manifest = echo_manifest()
|
|
manifest.command = ["python", "-u", str(FIXTURES / "stderr_node.py")]
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
try:
|
|
runtime, _ = manager.acquire("echo")
|
|
time.sleep(0.2)
|
|
runtime = next(iter(manager._runtimes.values()))
|
|
assert "stderr warning" in runtime.stderr_tail
|
|
manager.release(runtime.instance_id)
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_acquire_reuses_ready_instance(db: Database) -> None:
|
|
"""验证释放后的就绪实例会被再次复用。"""
|
|
_register(db, echo_manifest())
|
|
manager = NodeManager(db)
|
|
try:
|
|
first, first_address = manager.acquire("echo")
|
|
manager.release(first.instance_id)
|
|
second, second_address = manager.acquire("echo")
|
|
assert first.instance_id == second.instance_id
|
|
assert first_address == second_address
|
|
manager.release(second.instance_id)
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_invoke_success(db: Database, tmp_path) -> None:
|
|
"""验证真实节点调用返回完成状态与输出。"""
|
|
_register(db, echo_manifest())
|
|
manager = NodeManager(db)
|
|
try:
|
|
response = manager.invoke(
|
|
"echo",
|
|
InvokeRequest(
|
|
run_id="run_ok",
|
|
node_instance_id="",
|
|
inputs={"text": "real path"},
|
|
output_dir=str(tmp_path),
|
|
),
|
|
)
|
|
assert response.status == "completed"
|
|
assert response.outputs["text"] == "real path"
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_invoke_http_error(db: Database, tmp_path) -> None:
|
|
"""验证节点返回协议 JSON 错误时透传错误信息。"""
|
|
manifest = echo_manifest()
|
|
manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")]
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
try:
|
|
response = manager.invoke(
|
|
"echo",
|
|
InvokeRequest(
|
|
run_id="run_fail",
|
|
node_instance_id="",
|
|
inputs={},
|
|
output_dir=str(tmp_path),
|
|
),
|
|
)
|
|
assert response.status == "failed"
|
|
assert response.error == "boom"
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_invoke_http_error_invalid_json(db: Database, tmp_path) -> None:
|
|
"""验证节点返回非法 JSON 时降级为通用错误信息。"""
|
|
manifest = echo_manifest()
|
|
manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")]
|
|
manifest.env["WOV_FAIL_INVALID_JSON"] = "1"
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
try:
|
|
response = manager.invoke(
|
|
"echo",
|
|
InvokeRequest(
|
|
run_id="run_bad_json",
|
|
node_instance_id="",
|
|
inputs={},
|
|
output_dir=str(tmp_path),
|
|
),
|
|
)
|
|
assert response.status == "failed"
|
|
assert "node returned 500" in response.error
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_invoke_network_error(db: Database, tmp_path) -> None:
|
|
"""验证进程退出导致连接失败时返回 failed。"""
|
|
_register(db, echo_manifest())
|
|
manager = NodeManager(db)
|
|
try:
|
|
runtime, _ = manager.acquire("echo")
|
|
manager.release(runtime.instance_id)
|
|
runtime.process.terminate()
|
|
runtime.process.wait(timeout=5)
|
|
response = manager.invoke(
|
|
"echo",
|
|
InvokeRequest(
|
|
run_id="run_net",
|
|
node_instance_id="",
|
|
inputs={},
|
|
output_dir=str(tmp_path),
|
|
),
|
|
)
|
|
assert response.status == "failed"
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_invoke_uses_configured_timeout(db: Database, monkeypatch) -> None:
|
|
"""验证调用超时取自配置常量。"""
|
|
_register(db, echo_manifest())
|
|
manager = NodeManager(db)
|
|
captured = {}
|
|
|
|
class FakeRuntime:
|
|
instance_id = "ni_x"
|
|
status = "ready"
|
|
busy_count = 0
|
|
|
|
class FakeResponse:
|
|
def read(self) -> bytes:
|
|
return b'{"status":"completed","outputs":{"text":"ok"}}'
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args) -> bool:
|
|
return False
|
|
|
|
def fake_acquire(node_id):
|
|
return FakeRuntime(), "http://127.0.0.1:1"
|
|
|
|
def fake_urlopen(request, timeout):
|
|
captured["timeout"] = timeout
|
|
return FakeResponse()
|
|
|
|
monkeypatch.setattr("app.node_manager.NODE_INVOKE_TIMEOUT_SECONDS", 123)
|
|
monkeypatch.setattr(manager, "acquire", fake_acquire)
|
|
monkeypatch.setattr("app.node_manager.urllib.request.urlopen", fake_urlopen)
|
|
|
|
response = manager.invoke(
|
|
"echo",
|
|
InvokeRequest(
|
|
run_id="run_t",
|
|
node_instance_id="",
|
|
inputs={},
|
|
output_dir=".",
|
|
),
|
|
)
|
|
|
|
assert response.status == "completed"
|
|
assert captured["timeout"] == 123
|
|
|
|
|
|
def test_release_unknown(db: Database) -> None:
|
|
"""验证释放未知实例是无害操作。"""
|
|
manager = NodeManager(db)
|
|
manager.release("missing")
|
|
|
|
|
|
def test_stop_unknown_instance(db: Database) -> None:
|
|
"""验证停止未知实例是无害操作。"""
|
|
manager = NodeManager(db)
|
|
manager.stop_instance("missing")
|
|
|
|
|
|
def test_stop_all_for_node(db: Database) -> None:
|
|
"""验证 stop_all_for_node 会停止该节点全部实例。"""
|
|
_register(db, echo_manifest())
|
|
manager = NodeManager(db)
|
|
runtime, _ = manager.acquire("echo")
|
|
manager.stop_all_for_node("echo")
|
|
assert runtime.status == "stopped"
|
|
assert runtime.instance_id not in manager._runtimes
|
|
|
|
|
|
def test_stop_locked_without_process(db: Database) -> None:
|
|
"""验证无进程句柄的实例也能被标记停止。"""
|
|
_register(db, echo_manifest())
|
|
manager = NodeManager(db)
|
|
runtime = NodeRuntime(
|
|
instance_id="ni_static",
|
|
node_id="echo",
|
|
manifest=echo_manifest(),
|
|
status="ready",
|
|
)
|
|
manager._runtimes[runtime.instance_id] = runtime
|
|
manager.stop_instance(runtime.instance_id)
|
|
assert runtime.status == "stopped"
|
|
assert runtime.instance_id not in manager._runtimes
|
|
|
|
|
|
def test_stop_process_already_exited() -> None:
|
|
"""验证进程已退出时停止流程直接返回。"""
|
|
class FakeProcess:
|
|
def poll(self):
|
|
return 0
|
|
|
|
manager = object.__new__(NodeManager)
|
|
manager._stop_process(FakeProcess())
|
|
|
|
|
|
def test_stop_process_timeout() -> None:
|
|
"""验证优雅终止超时后强制 kill。"""
|
|
class FakeProcess:
|
|
def poll(self):
|
|
return None
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
|
|
def wait(self, timeout):
|
|
if not self.killed:
|
|
raise subprocess.TimeoutExpired("fake", timeout)
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
fake = FakeProcess()
|
|
fake.killed = False
|
|
manager = object.__new__(NodeManager)
|
|
manager._stop_process(fake)
|
|
assert fake.terminated
|
|
assert fake.killed
|
|
|
|
|
|
def test_reaper_recycles_idle(db: Database, tmp_path) -> None:
|
|
"""验证空闲超过 TTL 的实例会被回收线程停止。"""
|
|
manifest = echo_manifest()
|
|
manifest.idle_ttl_seconds = 0
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
manager.start_reaper()
|
|
try:
|
|
response = manager.invoke(
|
|
"echo",
|
|
InvokeRequest(
|
|
run_id="run_reap",
|
|
node_instance_id="",
|
|
inputs={"text": "reap"},
|
|
output_dir=str(tmp_path),
|
|
),
|
|
)
|
|
assert response.status == "completed"
|
|
time.sleep(0.4)
|
|
assert db.list_instances()[0]["status"] == "stopped"
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_reaper_keeps_warm(db: Database, tmp_path) -> None:
|
|
"""验证 keep_warm 实例即使空闲也不会被回收。"""
|
|
manifest = echo_manifest()
|
|
manifest.idle_ttl_seconds = 0
|
|
manifest.keep_warm = True
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
manager.start_reaper()
|
|
try:
|
|
response = manager.invoke(
|
|
"echo",
|
|
InvokeRequest(
|
|
run_id="run_warm",
|
|
node_instance_id="",
|
|
inputs={"text": "warm"},
|
|
output_dir=str(tmp_path),
|
|
),
|
|
)
|
|
assert response.status == "completed"
|
|
time.sleep(0.4)
|
|
assert db.list_instances()[0]["status"] == "ready"
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
def test_reaper_keeps_busy(db: Database) -> None:
|
|
"""验证正在被占用的实例不会被回收。"""
|
|
manifest = echo_manifest()
|
|
manifest.idle_ttl_seconds = 0
|
|
_register(db, manifest)
|
|
manager = NodeManager(db)
|
|
manager.start_reaper()
|
|
try:
|
|
runtime, _ = manager.acquire("echo")
|
|
time.sleep(0.4)
|
|
assert runtime.status == "ready"
|
|
manager.release(runtime.instance_id)
|
|
finally:
|
|
manager.shutdown()
|