Files
wov-api/app/node_manager.py
T

413 lines
17 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.
"""节点生命周期管理器。
NodeManager 是节点实例生命周期的唯一所有者:负责按 Manifest 启动节点进程、
等待就绪、复用空闲实例、空闲回收和调用转发。API 与调度器不得绕过本模块
直接启动或杀死节点进程。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse, NodeManifest
from app.config import (
NODE_INVOKE_TIMEOUT_SECONDS,
NODE_READY_TIMEOUT_SECONDS,
NODE_REAP_INTERVAL_SECONDS,
SDK_SRC,
WORKSPACE_ROOT,
)
from app.db import Database
def _now_iso() -> str:
"""返回当前 UTC 时间的 ISO 格式字符串,用于统一时间戳存储。"""
return datetime.now(timezone.utc).isoformat()
def _idle_seconds(last_used_at: str) -> float:
"""计算实例距上次使用的空闲秒数;非法时间按 0 处理。"""
if not last_used_at:
return 0.0
try:
last = datetime.fromisoformat(last_used_at)
return max(0.0, (datetime.now(timezone.utc) - last).total_seconds())
except ValueError:
# 时间格式损坏时保守返回 0,避免误回收实例。
return 0.0
def _cuda_library_dirs(repo_dir: Path) -> list[Path]:
"""在节点虚拟环境中查找 nvidia cublas/cudnn 动态库目录。
faster-whisper 通过 pip 安装的 CUDA 库位于
.venv/.../site-packages/nvidia/<包>/libLinux)或 binWindows),
需要把这些目录加入动态库搜索路径才能被加载。
"""
venv = repo_dir / ".venv"
site_packages: list[Path] = []
# Windows 的 site-packages 位于 Lib 下。
windows_site = venv / "Lib" / "site-packages"
if windows_site.is_dir():
site_packages.append(windows_site)
# Linux 使用 lib/python3.x/site-packages 结构。
site_packages.extend((venv / "lib").glob("python3*/site-packages"))
dirs: list[Path] = []
for site in site_packages:
for vendor in ("cublas", "cudnn"):
for subdir in ("lib", "bin"):
lib_dir = site / "nvidia" / vendor / subdir
if lib_dir.is_dir():
dirs.append(lib_dir)
return dirs
def _with_cuda_library_path(env: dict[str, str], repo_dir: Path) -> dict[str, str]:
"""把 CUDA 动态库目录注入进程环境变量并去重。"""
dirs = [str(path) for path in _cuda_library_dirs(repo_dir)]
if not dirs:
return env
# Windows 动态库搜索走 PATHLinux 走 LD_LIBRARY_PATH。
var = "PATH" if os.name == "nt" else "LD_LIBRARY_PATH"
existing = [item for item in env.get(var, "").split(os.pathsep) if item]
# 只追加尚未存在的目录,避免重复路径拖慢加载。
additions = [path for path in dirs if path not in existing]
if additions:
# 新目录放在最前,优先使用虚拟环境内的 CUDA 库。
env[var] = os.pathsep.join(additions + existing)
return env
@dataclass
class NodeRuntime:
"""运行中的节点实例内存态:进程句柄、状态、地址与使用计数。"""
# 实例唯一 ID,格式 ni_xxxx。
instance_id: str
# 所属节点注册 ID。
node_id: str
# 启动该实例使用的 manifest 快照。
manifest: NodeManifest
# 生命周期状态:starting / ready / stopping / stopped / error。
status: str = "starting"
# 节点子进程句柄,用于终止与回收。
process: subprocess.Popen | None = None
pid: int | None = None
# 节点 HTTP 服务地址,例如 http://127.0.0.1:xxxx。
address: str | None = None
started_at: str = field(default_factory=_now_iso)
last_used_at: str = field(default_factory=_now_iso)
# 当前并发占用数,超过 manifest.max_concurrency 不再复用。
busy_count: int = 0
error: str | None = None
# 最近 200 行 stderr,便于排查启动失败。
stderr_tail: list[str] = field(default_factory=list)
class NodeManager:
"""节点进程管理器:启动、复用、调用与回收的唯一入口。"""
def __init__(self, db: Database) -> None:
"""保存数据库引用并初始化运行时表与回收线程控制字段。"""
self.db = db
self._runtimes: dict[str, NodeRuntime] = {}
# 可重入锁保护运行时表,避免回收线程与调用线程竞争。
self._lock = threading.RLock()
self._reaper_thread: threading.Thread | None = None
self._stopping = False
def start_reaper(self) -> None:
"""启动后台回收线程,周期性回收空闲节点。"""
self._stopping = False
self._reaper_thread = threading.Thread(
target=self._reaper_loop,
name="wov-node-reaper",
daemon=True,
)
self._reaper_thread.start()
def shutdown(self) -> None:
"""停止所有节点实例,供应用退出时清理。"""
self._stopping = True
with self._lock:
for runtime in list(self._runtimes.values()):
self._stop_locked(runtime)
def _reaper_loop(self) -> None:
"""后台循环:每隔固定间隔检查并回收满足 TTL 的空闲实例。"""
while not self._stopping:
time.sleep(NODE_REAP_INTERVAL_SECONDS)
with self._lock:
for runtime in list(self._runtimes.values()):
# 未就绪或正在被调用的实例不回收。
if runtime.status != "ready" or runtime.busy_count > 0:
continue
# 常驻节点永不自动回收。
if runtime.manifest.keep_warm:
continue
ttl = runtime.manifest.idle_ttl_seconds
if ttl >= 0 and _idle_seconds(runtime.last_used_at) >= ttl:
runtime.status = "stopping"
self._stop_locked(runtime)
def _resolve_command(self, manifest: NodeManifest) -> list[str]:
"""解析启动命令;python 命令替换为节点仓库虚拟环境解释器。"""
command = list(manifest.command)
if command and command[0].lower() in {"python", "python3"}:
repo_dir = WORKSPACE_ROOT / manifest.repo_dir
# 同时兼容 Windows 与 Linux 的虚拟环境路径。
windows_python = repo_dir / ".venv" / "Scripts" / "python.exe"
unix_python = repo_dir / ".venv" / "bin" / "python"
if windows_python.is_file():
command[0] = str(windows_python)
elif unix_python.is_file():
command[0] = str(unix_python)
else:
# 虚拟环境不存在时使用 API 自身的 Python,便于演示。
command[0] = sys.executable
return command
def _node_env(self, manifest: NodeManifest) -> dict[str, str]:
"""构造节点进程环境:注入 SDK 路径、manifest 环境变量与 CUDA 路径。"""
env = os.environ.copy()
current_pythonpath = env.get("PYTHONPATH", "")
# 把 wov-sdk 源码目录放到 PYTHONPATH 最前。
env["PYTHONPATH"] = os.pathsep.join(
item for item in [str(SDK_SRC), current_pythonpath] if item
)
env.update(manifest.env)
# 为 faster-whisper 等节点补充 CUDA 动态库搜索路径。
repo_dir = WORKSPACE_ROOT / manifest.repo_dir
return _with_cuda_library_path(env, repo_dir)
def _start_locked(self, manifest: NodeManifest) -> NodeRuntime:
"""启动单个节点进程并等待就绪;调用方必须持有锁。"""
runtime = NodeRuntime(
instance_id=f"ni_{uuid.uuid4().hex[:12]}",
node_id=manifest.id,
manifest=manifest,
)
repo_dir = WORKSPACE_ROOT / manifest.repo_dir
if not repo_dir.is_dir():
# 仓库目录缺失时直接标记错误,避免后续命令误导。
runtime.status = "error"
runtime.error = f"repo_dir not found: {repo_dir}"
self.db.upsert_instance(self._instance_row(runtime))
return runtime
# 子进程 stdout 读取线程解析到端口后设置事件。
ready_event = threading.Event()
ready_port: dict[str, int] = {}
try:
process = subprocess.Popen(
self._resolve_command(manifest),
cwd=str(repo_dir),
env=self._node_env(manifest),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
bufsize=1,
)
except Exception as exc: # noqa: BLE001
# 命令不存在等启动错误统一记录为 error。
runtime.status = "error"
runtime.error = str(exc)
self.db.upsert_instance(self._instance_row(runtime))
return runtime
runtime.process = process
runtime.pid = process.pid
def read_stdout() -> None:
"""逐行读取子进程 stdout,解析 WOV_NODE_READY 端口。"""
assert process.stdout is not None
for line in iter(process.stdout.readline, ""):
line = line.strip()
if line.startswith("WOV_NODE_READY"):
try:
# 从 "port=<数字>" 中提取端口。
ready_port["port"] = int(line.split("port=", 1)[1])
except (IndexError, ValueError):
# 格式异常时按 0 处理,随后会判定为未就绪。
ready_port["port"] = 0
ready_event.set()
def read_stderr() -> None:
"""缓存子进程 stderr 尾部,供失败诊断使用。"""
assert process.stderr is not None
for line in iter(process.stderr.readline, ""):
line = line.strip()
if line:
runtime.stderr_tail.append(line)
runtime.stderr_tail = runtime.stderr_tail[-200:]
threading.Thread(target=read_stdout, daemon=True).start()
threading.Thread(target=read_stderr, daemon=True).start()
# 等待就绪行,超时或端口无效则终止进程并标记失败。
ready = ready_event.wait(timeout=NODE_READY_TIMEOUT_SECONDS)
if not ready or "port" not in ready_port or not ready_port["port"]:
self._stop_process(process)
runtime.status = "error"
runtime.error = "node did not report readiness"
self.db.upsert_instance(self._instance_row(runtime))
return runtime
# 端口有效后做一次真实 HTTP 健康检查。
address = f"http://127.0.0.1:{ready_port['port']}"
try:
with urllib.request.urlopen(f"{address}/health", timeout=2) as response:
if response.status != 200:
raise RuntimeError(f"health check returned {response.status}")
except Exception as exc: # noqa: BLE001
# 健康检查失败说明进程虽然打印了就绪行但服务不可用。
self._stop_process(process)
runtime.status = "error"
runtime.error = f"health check failed: {exc}"
self.db.upsert_instance(self._instance_row(runtime))
return runtime
runtime.address = address
runtime.status = "ready"
runtime.started_at = _now_iso()
runtime.last_used_at = _now_iso()
self._runtimes[runtime.instance_id] = runtime
self.db.upsert_instance(self._instance_row(runtime))
return runtime
def _stop_process(self, process: subprocess.Popen) -> None:
"""先优雅 terminate,超时后强制 kill。"""
# 进程已退出时无需处理。
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
# 优雅退出失败时强制结束,避免僵尸进程占用端口。
process.kill()
process.wait(timeout=5)
def _stop_locked(self, runtime: NodeRuntime) -> None:
"""停止实例进程并更新数据库状态;调用方必须持有锁。"""
if runtime.process is not None:
self._stop_process(runtime.process)
runtime.status = "stopped"
runtime.pid = None
runtime.address = None
# 从内存运行时表中移除,后续调用会重新启动。
self._runtimes.pop(runtime.instance_id, None)
self.db.upsert_instance(self._instance_row(runtime))
def _instance_row(self, runtime: NodeRuntime) -> dict:
"""把运行时对象转换为数据库实例记录。"""
return {
"id": runtime.instance_id,
"node_id": runtime.node_id,
"status": runtime.status,
"pid": runtime.pid,
"address": runtime.address,
"started_at": runtime.started_at,
"last_used_at": runtime.last_used_at,
"busy_since": None,
"error": runtime.error,
}
def acquire(self, node_id: str) -> tuple[NodeRuntime, str]:
"""获取一个可用节点实例:优先复用空闲实例,否则启动新实例。"""
manifest = self.db.get_node(node_id)
if manifest is None:
raise ValueError(f"node not registered: {node_id}")
with self._lock:
# 优先复用未达并发上限的就绪实例。
for runtime in self._runtimes.values():
if (
runtime.node_id == node_id
and runtime.status == "ready"
and runtime.busy_count < manifest.max_concurrency
):
runtime.busy_count += 1
runtime.last_used_at = _now_iso()
return runtime, runtime.address or ""
# 没有可复用实例时启动新进程。
runtime = self._start_locked(manifest)
if runtime.status != "ready":
raise RuntimeError(runtime.error or "node failed to start")
# 新实例占用一个并发槽位。
runtime.busy_count += 1
runtime.last_used_at = _now_iso()
return runtime, runtime.address or ""
def release(self, instance_id: str) -> None:
"""释放一次实例占用;由 invoke 的 finally 保证执行。"""
with self._lock:
runtime = self._runtimes.get(instance_id)
if runtime is None:
return
# 计数下限为 0,防止重复释放导致负值。
runtime.busy_count = max(0, runtime.busy_count - 1)
runtime.last_used_at = _now_iso()
self.db.upsert_instance(self._instance_row(runtime))
def invoke(self, node_id: str, request: InvokeRequest) -> InvokeResponse:
"""向节点实例发起一次调用,并保证无论成败都释放实例。"""
runtime, address = self.acquire(node_id)
# 回填实际实例 ID,供节点与日志追踪。
request.node_instance_id = runtime.instance_id
body = json.dumps(request.to_dict(), ensure_ascii=False).encode("utf-8")
http_request = urllib.request.Request(
f"{address}/invoke",
data=body,
headers={"Content-Type": "application/json; charset=utf-8"},
method="POST",
)
try:
# 超时来自配置,避免 LLM 等慢节点被过早中断。
with urllib.request.urlopen(http_request, timeout=NODE_INVOKE_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode("utf-8"))
return InvokeResponse.from_dict(payload)
except urllib.error.HTTPError as exc:
try:
# 节点返回 500 时 body 仍是协议 JSON,尝试解析失败原因。
payload = json.loads(exc.read().decode("utf-8"))
return InvokeResponse.from_dict(payload)
except Exception: # noqa: BLE001
return InvokeResponse(status="failed", error=f"node returned {exc.code}")
except Exception as exc: # noqa: BLE001
# 网络错误、超时等统一转换为 failed。
return InvokeResponse(status="failed", error=str(exc))
finally:
self.release(request.node_instance_id)
def stop_instance(self, instance_id: str) -> None:
"""按实例 ID 停止指定节点,供管理后台使用。"""
with self._lock:
runtime = self._runtimes.get(instance_id)
if runtime is None:
return
self._stop_locked(runtime)
def stop_all_for_node(self, node_id: str) -> None:
"""停止某个节点的全部实例,用于删除节点前清理。"""
with self._lock:
for runtime in list(self._runtimes.values()):
if runtime.node_id == node_id:
self._stop_locked(runtime)