docs: 为全部代码补充中文注释并加入 AGENTS 注释规范
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
"""节点生命周期管理器。
|
||||
|
||||
NodeManager 是节点实例生命周期的唯一所有者:负责按 Manifest 启动节点进程、
|
||||
等待就绪、复用空闲实例、空闲回收和调用转发。API 与调度器不得绕过本模块
|
||||
直接启动或杀死节点进程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -26,25 +33,36 @@ 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/<包>/lib(Linux)或 bin(Windows),
|
||||
需要把这些目录加入动态库搜索路径才能被加载。
|
||||
"""
|
||||
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] = []
|
||||
@@ -58,42 +76,61 @@ def _cuda_library_dirs(repo_dir: Path) -> list[Path]:
|
||||
|
||||
|
||||
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 动态库搜索走 PATH,Linux 走 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,
|
||||
@@ -103,18 +140,22 @@ class NodeManager:
|
||||
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
|
||||
@@ -123,9 +164,11 @@ class NodeManager:
|
||||
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():
|
||||
@@ -133,20 +176,25 @@ class NodeManager:
|
||||
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,
|
||||
@@ -154,11 +202,13 @@ class NodeManager:
|
||||
)
|
||||
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] = {}
|
||||
|
||||
@@ -174,6 +224,7 @@ class NodeManager:
|
||||
bufsize=1,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 命令不存在等启动错误统一记录为 error。
|
||||
runtime.status = "error"
|
||||
runtime.error = str(exc)
|
||||
self.db.upsert_instance(self._instance_row(runtime))
|
||||
@@ -183,17 +234,21 @@ class NodeManager:
|
||||
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()
|
||||
@@ -204,6 +259,7 @@ class NodeManager:
|
||||
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)
|
||||
@@ -212,12 +268,14 @@ class NodeManager:
|
||||
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}"
|
||||
@@ -233,25 +291,31 @@ class NodeManager:
|
||||
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,
|
||||
@@ -265,11 +329,13 @@ class NodeManager:
|
||||
}
|
||||
|
||||
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
|
||||
@@ -280,24 +346,30 @@ class NodeManager:
|
||||
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(
|
||||
@@ -307,21 +379,25 @@ class NodeManager:
|
||||
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:
|
||||
@@ -329,6 +405,7 @@ class NodeManager:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user