feat: 模型推理异步加载与对话链路修复,同步基线
模型推理全异步化改造: - 计算节点 InferenceSession 改为后台线程异步加载模型,load 立即返回, 加载期间事件循环保持响应(/inference/status 与 /health 不阻塞) - 后端模型加载改为异步派发 + 轮询对账器(reconcile_inference_loads), 任务状态由 starting 自动推进到 ready/error,解决多节点启动超时 (timeout of 120000ms exceeded) - 推理删除/卸载改为任务感知 + 短超时,删除先删记录再 best-effort 卸载, 不再被不可达节点阻塞;同节点新模型替换旧任务标记失效 - 流式对话透传 task_id/node_id 路由到真正加载模型的算力节点, useStreamChat 解析 SSE 错误帧以干净文案展示 - 对话历史按任务 id 本地持久化,退出重进可恢复;移除页脚提示文本 - 新增后端推理异步加载与计算节点异步状态机单元测试 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,15 @@ def _select_first_online_node(store: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_online_nodes(store: Any, preferred_node_id: str | None = None) -> list[dict[str, Any]]:
|
||||
nodes = [node for node in store.compute_nodes() if node.get("enabled") and node.get("scheduler_status") == "online"]
|
||||
if not preferred_node_id:
|
||||
return nodes
|
||||
preferred = [node for node in nodes if node.get("id") == preferred_node_id]
|
||||
others = [node for node in nodes if node.get("id") != preferred_node_id]
|
||||
return preferred + others
|
||||
|
||||
|
||||
def _build_messages_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert frontend inference payload to compute API messages format.
|
||||
|
||||
@@ -61,10 +70,47 @@ def _build_messages_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _node_for_inference_payload(store: Any, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
node_id = payload.get("node_id") or payload.get("compute_node_id")
|
||||
task_id = payload.get("task_id") or payload.get("compare_task_id")
|
||||
if task_id and not node_id:
|
||||
try:
|
||||
task = store.compare_task(str(task_id))
|
||||
load_status = task.get("load_status") or {}
|
||||
if isinstance(load_status, str):
|
||||
load_status = json.loads(load_status)
|
||||
loaded_models = load_status.get("loaded_models") or []
|
||||
ready_model = next((item for item in loaded_models if item.get("status") in {"ready", "running"} and item.get("node_id")), None)
|
||||
if ready_model:
|
||||
node_id = ready_model.get("node_id")
|
||||
except Exception:
|
||||
node_id = None
|
||||
if node_id:
|
||||
return next((node for node in store.compute_nodes() if node.get("id") == node_id), None)
|
||||
return _select_first_online_node(store)
|
||||
|
||||
|
||||
async def _stream_chat_proxy(payload: dict[str, Any]) -> StreamingResponse:
|
||||
"""Common SSE streaming proxy: convert payload → forward to compute node → stream back."""
|
||||
store = get_platform_store()
|
||||
node = _select_first_online_node(store)
|
||||
# 任务仍在加载中时,直接返回明确的加载中提示,避免转发到尚未就绪的节点
|
||||
task_id = payload.get("task_id") or payload.get("compare_task_id")
|
||||
if task_id:
|
||||
try:
|
||||
task = store.compare_task(str(task_id))
|
||||
load_status = task.get("load_status") or {}
|
||||
if isinstance(load_status, str):
|
||||
load_status = json.loads(load_status)
|
||||
items = load_status.get("loaded_models") or []
|
||||
if items and not any(item.get("status") in {"ready", "running"} for item in items):
|
||||
if any(item.get("status") == "starting" for item in items):
|
||||
return StreamingResponse(
|
||||
iter(['data: {"error": "模型加载中,请稍候再试"}\n\n']),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - fall through to normal routing on lookup errors
|
||||
pass
|
||||
node = _node_for_inference_payload(store, payload)
|
||||
if not node:
|
||||
return StreamingResponse(
|
||||
iter(['data: {"error": "no online compute node available for inference"}\n\n']),
|
||||
@@ -729,12 +775,17 @@ async def merge_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
None,
|
||||
)
|
||||
base_model_path = payload.get("base_model_path") or (trained_model and trained_model.get("base_model_path"))
|
||||
adapter_path = payload.get("adapter_path") or payload.get("adapter_name_or_path") or (trained_model and trained_model.get("merged_path"))
|
||||
adapter_path = (
|
||||
payload.get("adapter_path")
|
||||
or payload.get("adapter_name_or_path")
|
||||
or (trained_model and (trained_model.get("artifact_dir") or trained_model.get("adapter_path") or trained_model.get("merged_path")))
|
||||
)
|
||||
if not base_model_path:
|
||||
raise fail(400, "base_model_path is required")
|
||||
if not adapter_path:
|
||||
raise fail(400, "adapter_path is required")
|
||||
node = store.schedule_node({**payload, "gpus": payload.get("gpus") or []})
|
||||
requested_node_id = payload.get("requested_node_id") or payload.get("compute_node_id") or (trained_model and trained_model.get("compute_node_id"))
|
||||
node = store.schedule_node({**payload, "requested_node_id": requested_node_id, "gpus": payload.get("gpus") or []})
|
||||
health = node.get("health_detail") or {}
|
||||
output_root = str(health.get("output_root") or f"{node['data_root'].rstrip('/')}/outputs")
|
||||
output_name = str(payload.get("output_model_name") or payload.get("merged_model_name") or f"{trained_model_id or 'model'}-merged")
|
||||
@@ -753,11 +804,13 @@ async def merge_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"gpus": payload.get("gpus") or [],
|
||||
"trained_model_id": trained_model["id"] if trained_model else trained_model_id,
|
||||
"model_name": trained_model["name"] if trained_model else payload.get("model_name"),
|
||||
"compute_node_id": node["id"],
|
||||
"compute_node_code": node.get("code"),
|
||||
}
|
||||
if get_settings().compute_mode == "simulator":
|
||||
job = {"id": job_payload["id"], "status": "queued", "progress": 10, "command": [], "output_dir": output_dir}
|
||||
else:
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
client = ComputeNodeClient(node["api_base_url"], timeout=900)
|
||||
preview = await client.validate_job(job_payload)
|
||||
if not preview.get("valid", False):
|
||||
raise fail(409, "; ".join(preview.get("errors") or ["merge preflight failed"]))
|
||||
@@ -1537,28 +1590,49 @@ async def model_compare_detail(task_id: str) -> dict[str, Any]:
|
||||
raise fail(404, "compare task not found")
|
||||
|
||||
|
||||
async def _unload_from_compute_node() -> dict[str, Any]:
|
||||
"""Best-effort unload the inference model from the first online compute node."""
|
||||
store = get_platform_store()
|
||||
# Clear all inference tracking — only one model can be loaded at a time
|
||||
for node in store.compute_nodes():
|
||||
store.mark_inference_unloaded(node["id"])
|
||||
node = _select_first_online_node(store)
|
||||
if not node:
|
||||
return {"unloaded": False, "error": "no online compute node"}
|
||||
try:
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
result = await client._request("POST", "/inference/unload", json_data={})
|
||||
return result
|
||||
except Exception as exc:
|
||||
return {"unloaded": False, "error": str(exc)}
|
||||
async def _unload_from_compute_node(store: Any, task: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Best-effort unload the inference model from the node(s) that hold it.
|
||||
|
||||
任务感知:优先卸载 ``task.load_status.loaded_models`` 中记录的节点;
|
||||
无任务时回退到平台记录的已加载推理的节点。每个节点使用短超时,
|
||||
保证卸载永远不会长时间阻塞调用方(例如删除操作)。
|
||||
"""
|
||||
node_ids: set[str] = set()
|
||||
if task:
|
||||
load_status = task.get("load_status") or {}
|
||||
if isinstance(load_status, str):
|
||||
try:
|
||||
load_status = json.loads(load_status)
|
||||
except json.JSONDecodeError:
|
||||
load_status = {}
|
||||
node_ids = {item.get("node_id") for item in load_status.get("loaded_models") or [] if item.get("node_id")}
|
||||
if not node_ids:
|
||||
node_ids = {node["id"] for node in store.compute_nodes() if store.is_inference_loaded(node["id"])}
|
||||
nodes = [node for node in store.compute_nodes() if node["id"] in node_ids]
|
||||
results: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
try:
|
||||
result = await ComputeNodeClient(node["api_base_url"]).inference_unload()
|
||||
results.append({"node_id": node["id"], "node_code": node.get("code"), "success": True, "result": result})
|
||||
except Exception as exc: # noqa: BLE001 - best-effort unload must not raise
|
||||
results.append({"node_id": node["id"], "node_code": node.get("code"), "success": False, "error": str(exc)})
|
||||
finally:
|
||||
store.mark_inference_unloaded(node["id"])
|
||||
return {"unloaded": bool(results), "nodes": results}
|
||||
|
||||
|
||||
@router.delete("/model-compare/{task_id}")
|
||||
async def model_compare_delete(task_id: str) -> dict[str, Any]:
|
||||
# 删除前先释放算力节点上的模型
|
||||
await _unload_from_compute_node()
|
||||
# 先删记录(快),再 best-effort 释放算力节点上的模型——删除绝不被卸载阻塞
|
||||
try:
|
||||
task = get_platform_store().compare_task(task_id)
|
||||
except KeyError:
|
||||
raise fail(404, "compare task not found")
|
||||
get_platform_store().delete_compare_task(task_id)
|
||||
try:
|
||||
await _unload_from_compute_node(get_platform_store(), task=task)
|
||||
except Exception: # noqa: BLE001 - deletion must succeed even if unload fails
|
||||
pass
|
||||
return ok({"deleted": task_id})
|
||||
|
||||
|
||||
@@ -1585,9 +1659,44 @@ async def model_compare_update_load_status(task_id: str, payload: dict[str, Any]
|
||||
raise fail(404, "compare task not found")
|
||||
|
||||
|
||||
def _invalidate_superseded_models(store: Any, task_id: str, loaded_models: list[dict[str, Any]]) -> None:
|
||||
"""同一计算节点同一时刻只能加载一个推理模型。
|
||||
|
||||
当新任务把模型派发到了某节点后,把其它任务中在该节点上 ready/running
|
||||
的模型标记为已被替换,保持平台 DB 与计算节点实际状态一致。
|
||||
"""
|
||||
taken_node_ids = {m.get("node_id") for m in loaded_models if m.get("node_id") and m.get("status") == "starting"}
|
||||
if not taken_node_ids:
|
||||
return
|
||||
for other in store.compare_tasks():
|
||||
if str(other.get("id")) == str(task_id):
|
||||
continue
|
||||
load_status = other.get("load_status") or {}
|
||||
if isinstance(load_status, str):
|
||||
try:
|
||||
load_status = json.loads(load_status)
|
||||
except json.JSONDecodeError:
|
||||
load_status = {}
|
||||
items = load_status.get("loaded_models") or []
|
||||
changed = False
|
||||
for item in items:
|
||||
if item.get("node_id") in taken_node_ids and item.get("status") in {"ready", "running"}:
|
||||
item["status"] = "error"
|
||||
item["error"] = "模型已被其他推理任务替换"
|
||||
changed = True
|
||||
if changed:
|
||||
new_status = "loaded" if any(i.get("status") in {"ready", "running"} for i in items) else "failed"
|
||||
store.update_compare_task(other["id"], {"status": new_status, "load_status": {"loaded_models": items}})
|
||||
|
||||
|
||||
@router.post("/model-compare/{task_id}/load")
|
||||
async def model_compare_load(task_id: str) -> dict[str, Any]:
|
||||
"""真正加载模型到算力节点(不再使用假 PID/端口)。"""
|
||||
"""异步派发模型加载到算力节点,立即返回。
|
||||
|
||||
加载进度由轮询对账器(compute_poller → reconcile_inference_loads)推进:
|
||||
任务项先以 status=starting 记录,对账器查询节点 /inference/status 后
|
||||
推进到 ready/error。这里只负责把加载请求派发出去,绝不同步等待加载完成。
|
||||
"""
|
||||
try:
|
||||
store = get_platform_store()
|
||||
task = store.compare_task(task_id)
|
||||
@@ -1597,15 +1706,14 @@ async def model_compare_load(task_id: str) -> dict[str, Any]:
|
||||
models = json.loads(models)
|
||||
except json.JSONDecodeError:
|
||||
models = []
|
||||
# 选取在线算力节点
|
||||
node = _select_first_online_node(store)
|
||||
if not node:
|
||||
online_nodes = _candidate_online_nodes(store)
|
||||
if not online_nodes:
|
||||
return ok({"status": "failed", "error": "no online compute node"})
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
loaded_models = []
|
||||
for item in models:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
preferred_node_id = item.get("node_id") or item.get("compute_node_id")
|
||||
model_path = item.get("model_path", "")
|
||||
if not model_path:
|
||||
# 尝试从模型库获取路径
|
||||
@@ -1614,28 +1722,46 @@ async def model_compare_load(task_id: str) -> dict[str, Any]:
|
||||
db_model = store.model(model_id)
|
||||
model_path = db_model.get("path", "")
|
||||
except KeyError:
|
||||
pass
|
||||
trained_model = next((m for m in store.trained_models() if str(m.get("id")) == str(model_id)), None)
|
||||
if trained_model:
|
||||
model_path = trained_model.get("merged_path") or trained_model.get("artifact_dir") or ""
|
||||
preferred_node_id = preferred_node_id or trained_model.get("compute_node_id")
|
||||
if not model_path:
|
||||
loaded_models.append({**item, "status": "error", "error": "model_path not found"})
|
||||
continue
|
||||
# 真正调用算力节点加载模型
|
||||
load_payload = {
|
||||
"model_name_or_path": model_path,
|
||||
"template": item.get("template", "qwen"),
|
||||
}
|
||||
if item.get("adapter_path"):
|
||||
load_payload["adapter_name_or_path"] = item["adapter_path"]
|
||||
try:
|
||||
result = await client._request("POST", "/inference/load", json_data=load_payload)
|
||||
if result.get("loaded"):
|
||||
if get_settings().compute_mode == "simulator":
|
||||
loaded_models.append({**item, "status": "ready", "node_id": "", "node_name": ""})
|
||||
continue
|
||||
# 只派发:HTTP 响应成功即视为已接受(节点会异步加载),loaded 字段忽略
|
||||
item_dispatched = False
|
||||
errors = []
|
||||
for node in _candidate_online_nodes(store, preferred_node_id):
|
||||
try:
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
await client.inference_load(load_payload)
|
||||
store.mark_inference_loaded(node["id"])
|
||||
loaded_models.append({**item, "status": "ready"})
|
||||
else:
|
||||
loaded_models.append({**item, "status": "error", "error": result.get("error", "load failed")})
|
||||
except Exception as exc:
|
||||
loaded_models.append({**item, "status": "error", "error": str(exc)})
|
||||
status = "loaded" if any(m.get("status") == "ready" for m in loaded_models) else "failed"
|
||||
loaded_models.append({**item, "status": "starting", "node_id": node["id"], "node_name": node.get("name")})
|
||||
item_dispatched = True
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001 - try next candidate node
|
||||
errors.append(f"{node.get('name') or node.get('code')}: {exc}")
|
||||
if not item_dispatched:
|
||||
loaded_models.append({**item, "status": "error", "error": "; ".join(errors) or "load dispatch failed"})
|
||||
if any(m.get("status") == "starting" for m in loaded_models):
|
||||
status = "starting"
|
||||
elif any(m.get("status") == "error" for m in loaded_models):
|
||||
status = "failed"
|
||||
else:
|
||||
status = "loaded"
|
||||
updated = store.update_compare_task(task_id, {"status": status, "load_status": {"loaded_models": loaded_models}})
|
||||
# 同一节点同一时刻只能有一个推理模型;新任务占用了节点后,把其它任务上该节点的模型标记为已被替换
|
||||
_invalidate_superseded_models(store, task_id, loaded_models)
|
||||
return ok(updated)
|
||||
except KeyError:
|
||||
raise fail(404, "compare task not found")
|
||||
@@ -1645,8 +1771,9 @@ async def model_compare_load(task_id: str) -> dict[str, Any]:
|
||||
async def model_compare_unload(task_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_platform_store()
|
||||
# 真正释放算力节点上的模型资源
|
||||
unload_result = await _unload_from_compute_node()
|
||||
task = store.compare_task(task_id)
|
||||
# 任务感知卸载:只释放该任务实际加载到的节点,短超时快速返回
|
||||
unload_result = await _unload_from_compute_node(store, task=task)
|
||||
updated = store.update_compare_task(task_id, {"status": "pending", "load_status": {"loaded_models": []}})
|
||||
return ok({"task": updated, "unload": unload_result})
|
||||
except KeyError:
|
||||
@@ -1662,7 +1789,7 @@ async def model_compare_start_model(task_id: str, payload: dict[str, Any] = Body
|
||||
async def model_compare_chat_with_port(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Proxy non-streaming chat to the compute node running the inference model."""
|
||||
store = get_platform_store()
|
||||
node = _select_first_online_node(store)
|
||||
node = _node_for_inference_payload(store, payload)
|
||||
if not node:
|
||||
return ok({"response": "no online compute node available for inference", "request": payload})
|
||||
try:
|
||||
@@ -1717,8 +1844,9 @@ async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[
|
||||
return ok({"loaded": False, "error": "no online compute node"})
|
||||
try:
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
result = await client._request("POST", "/inference/load", json_data=payload)
|
||||
if result.get("loaded"):
|
||||
# 计算节点现在异步加载:HTTP 接受(loading/ready)即视为派发成功
|
||||
result = await client.inference_load(payload)
|
||||
if result.get("loaded") or result.get("status") in {"loading", "ready"}:
|
||||
store.mark_inference_loaded(node["id"])
|
||||
return ok(result)
|
||||
except Exception as exc:
|
||||
@@ -1729,18 +1857,19 @@ async def model_chat_local_preload(payload: dict[str, Any] = Body(...)) -> dict[
|
||||
async def model_chat_local_unload() -> dict[str, Any]:
|
||||
"""Unload the inference model from the compute node."""
|
||||
store = get_platform_store()
|
||||
# Clear all inference tracking
|
||||
# 释放所有已加载推理的节点(短超时,best-effort)
|
||||
results: list[dict[str, Any]] = []
|
||||
for n in store.compute_nodes():
|
||||
store.mark_inference_unloaded(n["id"])
|
||||
node = _select_first_online_node(store)
|
||||
if not node:
|
||||
return ok({"unloaded": False, "error": "no online compute node"})
|
||||
try:
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
result = await client._request("POST", "/inference/unload", json_data={})
|
||||
return ok(result)
|
||||
except Exception as exc:
|
||||
return ok({"unloaded": False, "error": str(exc)})
|
||||
if not store.is_inference_loaded(n["id"]):
|
||||
continue
|
||||
try:
|
||||
result = await ComputeNodeClient(n["api_base_url"]).inference_unload()
|
||||
results.append({"node_id": n["id"], "success": True, "result": result})
|
||||
except Exception as exc: # noqa: BLE001 - best-effort unload
|
||||
results.append({"node_id": n["id"], "success": False, "error": str(exc)})
|
||||
finally:
|
||||
store.mark_inference_unloaded(n["id"])
|
||||
return ok({"unloaded": True, "nodes": results})
|
||||
|
||||
|
||||
@router.get("/model-chat/local/status")
|
||||
@@ -1752,7 +1881,7 @@ async def model_chat_local_status() -> dict[str, Any]:
|
||||
return ok({"loaded": False, "error": "no online compute node"})
|
||||
try:
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
result = await client._request("GET", "/inference/status")
|
||||
result = await client.inference_status()
|
||||
return ok(result)
|
||||
except Exception as exc:
|
||||
return ok({"loaded": False, "error": str(exc)})
|
||||
@@ -1770,8 +1899,9 @@ async def model_chat_trained_preload(payload: dict[str, Any] = Body(...)) -> dic
|
||||
return ok({"loaded": False, "error": "no online compute node"})
|
||||
try:
|
||||
client = ComputeNodeClient(node["api_base_url"])
|
||||
result = await client._request("POST", "/inference/load", json_data=payload)
|
||||
if result.get("loaded"):
|
||||
# 计算节点现在异步加载:HTTP 接受(loading/ready)即视为派发成功
|
||||
result = await client.inference_load(payload)
|
||||
if result.get("loaded") or result.get("status") in {"loading", "ready"}:
|
||||
store.mark_inference_loaded(node["id"])
|
||||
return ok(result)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -208,8 +208,12 @@ def parse_training_metric_line(line: str) -> dict[str, float] | None:
|
||||
if "loss" not in line and "learning_rate" not in line:
|
||||
return None
|
||||
result: dict[str, float] = {}
|
||||
number_pattern = r"([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)"
|
||||
step_match = re.search(rf"(?:^|[\s,{{])['\"]?step['\"]?\s*(?:=|:)\s*{number_pattern}", line, re.I)
|
||||
if step_match:
|
||||
result["step"] = float(step_match.group(1))
|
||||
for key in ["loss", "grad_norm", "learning_rate", "epoch"]:
|
||||
match = re.search(rf"['\"]?{key}['\"]?\s*:\s*([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)", line)
|
||||
match = re.search(rf"['\"]?{key}['\"]?\s*(?:=|:)\s*{number_pattern}", line, re.I)
|
||||
if match:
|
||||
result[key] = float(match.group(1))
|
||||
return result or None
|
||||
@@ -452,6 +456,15 @@ class PlatformStore:
|
||||
)
|
||||
self._ensure_columns(conn, "gpus", {"last_seen_at": "TEXT"})
|
||||
self._ensure_columns(conn, "fine_tune_tasks", {"compute_job_id": "TEXT"})
|
||||
self._ensure_columns(
|
||||
conn,
|
||||
"trained_models",
|
||||
{
|
||||
"artifact_dir": "TEXT",
|
||||
"compute_node_id": "TEXT",
|
||||
"compute_node_name": "TEXT",
|
||||
},
|
||||
)
|
||||
self._ensure_columns(
|
||||
conn,
|
||||
"resource_replicas",
|
||||
@@ -589,6 +602,15 @@ class PlatformStore:
|
||||
name = task.get("output_model_name") or f"{task['name']}-lora"
|
||||
exists = conn.execute("SELECT id FROM trained_models WHERE name=?", (name,)).fetchone()
|
||||
if exists:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE trained_models
|
||||
SET compute_node_id=COALESCE(compute_node_id, ?),
|
||||
compute_node_name=COALESCE(compute_node_name, ?)
|
||||
WHERE id=?
|
||||
""",
|
||||
(task.get("compute_node_id"), task.get("compute_node_code") or task.get("compute_node_name"), exists["id"]),
|
||||
)
|
||||
return
|
||||
model = conn.execute("SELECT path FROM models WHERE id=?", (task.get("base_model"),)).fetchone()
|
||||
output_dir = task.get("output_dir") or f"/data/yg-ft/outputs/{task['name']}"
|
||||
@@ -596,8 +618,8 @@ class PlatformStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO trained_models
|
||||
(id, name, train_methods, base_model_path, create_time, merged, merging, merged_path, artifact_dir)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, name, train_methods, base_model_path, create_time, merged, merging, merged_path, artifact_dir, compute_node_id, compute_node_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
trained_model_id,
|
||||
@@ -609,6 +631,8 @@ class PlatformStore:
|
||||
0,
|
||||
output_dir,
|
||||
output_dir,
|
||||
task.get("compute_node_id"),
|
||||
task.get("compute_node_code") or task.get("compute_node_name"),
|
||||
),
|
||||
)
|
||||
# Use real artifact data from compute node when available
|
||||
@@ -875,7 +899,7 @@ class PlatformStore:
|
||||
(
|
||||
new_id("metric"),
|
||||
task_id,
|
||||
line_number,
|
||||
int(metric.get("step") or line_number),
|
||||
metric.get("epoch"),
|
||||
metric.get("loss"),
|
||||
metric.get("grad_norm"),
|
||||
@@ -1342,15 +1366,25 @@ class PlatformStore:
|
||||
self.refresh_runtime_state()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM trained_models ORDER BY create_time DESC").fetchall()
|
||||
return [
|
||||
{
|
||||
items = []
|
||||
for row in rows:
|
||||
item = {
|
||||
**dict(row),
|
||||
"train_methods": json_loads(row["train_methods"], []),
|
||||
"merged": bool(row["merged"]),
|
||||
"merging": bool(row["merging"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
if not item.get("compute_node_id"):
|
||||
task_rows = conn.execute("SELECT payload FROM fine_tune_tasks ORDER BY create_time DESC").fetchall()
|
||||
for task in task_rows:
|
||||
task_payload = json_loads(task["payload"], {})
|
||||
output_name = task_payload.get("output_model_name") or f"{task_payload.get('name')}-lora"
|
||||
if output_name == item["name"]:
|
||||
item["compute_node_id"] = task_payload.get("compute_node_id")
|
||||
item["compute_node_name"] = task_payload.get("compute_node_code") or task_payload.get("compute_node_name")
|
||||
break
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def delete_trained_model(self, model_id: str) -> None:
|
||||
with self.connect() as conn:
|
||||
|
||||
@@ -33,7 +33,10 @@ CREATE TABLE IF NOT EXISTS trained_models (
|
||||
create_time TEXT NOT NULL,
|
||||
merged INTEGER NOT NULL DEFAULT 0,
|
||||
merging INTEGER NOT NULL DEFAULT 0,
|
||||
merged_path TEXT
|
||||
merged_path TEXT,
|
||||
artifact_dir TEXT,
|
||||
compute_node_id TEXT,
|
||||
compute_node_name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_lineage (
|
||||
|
||||
@@ -33,6 +33,16 @@ def _unwrap_dict(payload: Any) -> dict[str, Any]:
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
# Inference calls are intentionally short-timeout:
|
||||
# - load dispatch only confirms the compute node accepted the request
|
||||
# (the actual model load now runs asynchronously on the node).
|
||||
# - status/unload must never block the platform for long when a node is
|
||||
# unreachable but still marked online.
|
||||
INFERENCE_LOAD_TIMEOUT = httpx.Timeout(30, connect=10)
|
||||
INFERENCE_STATUS_TIMEOUT = httpx.Timeout(30, connect=5)
|
||||
INFERENCE_UNLOAD_TIMEOUT = httpx.Timeout(30, connect=5)
|
||||
|
||||
|
||||
class ComputeNodeClient:
|
||||
"""Application-side client for one compute node.
|
||||
|
||||
@@ -182,10 +192,16 @@ class ComputeNodeClient:
|
||||
response.raise_for_status()
|
||||
return _unwrap_dict(response.json())
|
||||
|
||||
async def _request(self, method: str, path: str, json_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generic request method for compute API endpoints."""
|
||||
url = _join_url(self.api_base_url, f"{self.route_prefix}{path}")
|
||||
async with httpx.AsyncClient(timeout=300, headers=self.headers()) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout or 300, headers=self.headers()) as client:
|
||||
if method.upper() == "GET":
|
||||
response = await client.get(url)
|
||||
else:
|
||||
@@ -193,6 +209,19 @@ class ComputeNodeClient:
|
||||
response.raise_for_status()
|
||||
return _unwrap_dict(response.json())
|
||||
|
||||
# ── Inference helpers (short timeouts — see module constants) ──────────
|
||||
|
||||
async def inference_load(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Dispatch a model load. Returns as soon as the node accepts the
|
||||
request; the node now loads asynchronously (status goes 'loading')."""
|
||||
return await self._request("POST", "/inference/load", json_data=payload, timeout=INFERENCE_LOAD_TIMEOUT)
|
||||
|
||||
async def inference_status(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/inference/status", timeout=INFERENCE_STATUS_TIMEOUT)
|
||||
|
||||
async def inference_unload(self) -> dict[str, Any]:
|
||||
return await self._request("POST", "/inference/unload", json_data={}, timeout=INFERENCE_UNLOAD_TIMEOUT)
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
filename: str,
|
||||
|
||||
@@ -1,15 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.db.platform_store import get_platform_store
|
||||
from app.modules.compute_gateway.client import ComputeNodeClient
|
||||
|
||||
# starting 状态允许的最大轮询次数(约 40 * 3s ≈ 2 分钟),超过即判定节点不可达
|
||||
MAX_STARTING_ATTEMPTS = 40
|
||||
|
||||
|
||||
def _node_for_task(task: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None)
|
||||
|
||||
|
||||
def _parse_inference_load_status(task: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
load_status = task.get("load_status") or {}
|
||||
if isinstance(load_status, str):
|
||||
try:
|
||||
load_status = json.loads(load_status)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
load_status = {}
|
||||
return load_status.get("loaded_models") or [], load_status
|
||||
|
||||
|
||||
async def reconcile_inference_loads(store: Any) -> list[dict[str, Any]]:
|
||||
"""推进处于 starting 状态的推理加载。
|
||||
|
||||
模型加载已改为异步派发:/model-compare/{id}/load 立即返回,这里在每次
|
||||
轮询时查询对应计算节点的 /inference/status,把任务从 starting 推进到
|
||||
ready/error。使用短超时,单节点不可达不会阻塞整轮轮询。
|
||||
"""
|
||||
reconciled: list[dict[str, Any]] = []
|
||||
now = time.time()
|
||||
for task in store.compare_tasks():
|
||||
items, _ = _parse_inference_load_status(task)
|
||||
if not any(item.get("status") == "starting" for item in items):
|
||||
continue
|
||||
# dirty 只要处理过任一 starting 项就置位:load_attempts / last_polled_at
|
||||
# 必须落库,否则节点不可达时计数不会累积,封顶逻辑永远触发不了
|
||||
dirty = False
|
||||
for item in items:
|
||||
if item.get("status") != "starting":
|
||||
continue
|
||||
# 节流:同一 item 每 3s 只查询一次
|
||||
if now - float(item.get("last_polled_at") or 0) < 3:
|
||||
continue
|
||||
item["last_polled_at"] = now
|
||||
item["load_attempts"] = int(item.get("load_attempts") or 0) + 1
|
||||
dirty = True
|
||||
node = next((n for n in store.compute_nodes() if n["id"] == item.get("node_id")), None)
|
||||
if not node:
|
||||
item["status"] = "error"
|
||||
item["error"] = "compute node deleted"
|
||||
store.mark_inference_unloaded(item.get("node_id") or "")
|
||||
continue
|
||||
if not node.get("enabled") or node.get("scheduler_status") != "online":
|
||||
item["status"] = "error"
|
||||
item["error"] = "compute node offline"
|
||||
store.mark_inference_unloaded(node["id"])
|
||||
continue
|
||||
try:
|
||||
status = await ComputeNodeClient(node["api_base_url"]).inference_status()
|
||||
except Exception as exc: # noqa: BLE001 - node unreachable; keep retrying until cap
|
||||
if int(item.get("load_attempts") or 0) >= MAX_STARTING_ATTEMPTS:
|
||||
item["status"] = "error"
|
||||
item["error"] = f"compute node unreachable: {exc}"
|
||||
store.mark_inference_unloaded(node["id"])
|
||||
continue
|
||||
node_status = status.get("status")
|
||||
if node_status == "ready":
|
||||
item["status"] = "ready"
|
||||
item.pop("error", None)
|
||||
store.mark_inference_loaded(node["id"])
|
||||
elif node_status == "error":
|
||||
item["status"] = "error"
|
||||
item["error"] = status.get("error") or "model load failed on compute node"
|
||||
store.mark_inference_unloaded(node["id"])
|
||||
elif node_status == "idle":
|
||||
# 节点重启导致已加载模型丢失
|
||||
item["status"] = "error"
|
||||
item["error"] = "model disappeared from compute node (node may have restarted)"
|
||||
store.mark_inference_unloaded(node["id"])
|
||||
# node_status == "loading" -> 保持 starting,下轮再查
|
||||
if dirty:
|
||||
if any(i.get("status") in {"ready", "running"} for i in items):
|
||||
new_status = "loaded"
|
||||
elif any(i.get("status") == "starting" for i in items):
|
||||
new_status = "starting" # 仍在加载中,保持 starting
|
||||
else:
|
||||
new_status = "failed"
|
||||
store.update_compare_task(task["id"], {"status": new_status, "load_status": {"loaded_models": items}})
|
||||
reconciled.append({"task_id": task["id"], "status": new_status})
|
||||
return reconciled
|
||||
|
||||
|
||||
async def fetch_eval_result_content(client: ComputeNodeClient, node: dict[str, Any], job: dict[str, Any]) -> dict[str, Any] | None:
|
||||
output_dir = job.get("output_dir")
|
||||
if not output_dir:
|
||||
@@ -95,5 +181,13 @@ async def poll_compute_jobs_once() -> dict[str, Any]:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed.append({"eval_task_id": eval_task["id"], "error": str(exc)})
|
||||
|
||||
# ── Inference load reconciliation ─────────────────────────────────────
|
||||
try:
|
||||
inference_reconciled = await reconcile_inference_loads(store)
|
||||
except Exception as exc: # noqa: BLE001 - keep polling alive
|
||||
failed.append({"inference_reconcile": str(exc)})
|
||||
inference_reconciled = []
|
||||
|
||||
return {"synced": len(synced) + len(standalone_synced) + eval_synced, "failed": failed,
|
||||
"items": synced, "standalone": standalone_synced, "eval_synced": eval_synced}
|
||||
"items": synced, "standalone": standalone_synced, "eval_synced": eval_synced,
|
||||
"inference_reconciled": inference_reconciled}
|
||||
|
||||
254
backend/tests/test_compare_inference_async.py
Normal file
254
backend/tests/test_compare_inference_async.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
模型推理异步加载改造的单元测试。
|
||||
|
||||
覆盖:
|
||||
- model_compare_load:异步派发,立即返回 starting + 节点信息(不等待加载完成)
|
||||
- model_compare_delete:先删记录,卸载失败也不阻塞删除
|
||||
- reconcile_inference_loads:starting -> ready/error/idle/不可达的状态迁移与封顶
|
||||
- _unload_from_compute_node:任务感知,只命中记录中的节点
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.endpoints.platform import model_compare_delete, model_compare_load
|
||||
import app.api.v1.endpoints.platform as platform
|
||||
from app.modules.compute_gateway.client import ComputeNodeClient
|
||||
from app.modules.compute_gateway.sync import MAX_STARTING_ATTEMPTS, reconcile_inference_loads
|
||||
|
||||
|
||||
class FakeInferenceStore:
|
||||
"""内存 store,仅实现推理加载/对账用到的接口。"""
|
||||
|
||||
def __init__(self, tasks: list[dict[str, Any]] | None = None, nodes: list[dict[str, Any]] | None = None) -> None:
|
||||
self._tasks: dict[str, dict[str, Any]] = {t["id"]: dict(t) for t in (tasks or [])}
|
||||
self._nodes = nodes or []
|
||||
self._inference_nodes: set[str] = set()
|
||||
|
||||
def compare_task(self, task_id: str) -> dict[str, Any]:
|
||||
if task_id not in self._tasks:
|
||||
raise KeyError(task_id)
|
||||
return dict(self._tasks[task_id])
|
||||
|
||||
def compare_tasks(self) -> list[dict[str, Any]]:
|
||||
return [dict(t) for t in self._tasks.values()]
|
||||
|
||||
def update_compare_task(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
current = self._tasks[task_id]
|
||||
merged = {**current, **payload, "id": task_id}
|
||||
self._tasks[task_id] = merged
|
||||
return dict(merged)
|
||||
|
||||
def delete_compare_task(self, task_id: str) -> None:
|
||||
self._tasks.pop(task_id, None)
|
||||
|
||||
def compute_nodes(self) -> list[dict[str, Any]]:
|
||||
return [dict(n) for n in self._nodes]
|
||||
|
||||
def model(self, model_id: str) -> dict[str, Any]:
|
||||
raise KeyError(model_id)
|
||||
|
||||
def trained_models(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def mark_inference_loaded(self, node_id: str) -> None:
|
||||
self._inference_nodes.add(node_id)
|
||||
|
||||
def mark_inference_unloaded(self, node_id: str) -> None:
|
||||
self._inference_nodes.discard(node_id)
|
||||
|
||||
def is_inference_loaded(self, node_id: str) -> bool:
|
||||
return node_id in self._inference_nodes
|
||||
|
||||
|
||||
def _node(node_id: str, code: str = "") -> dict[str, Any]:
|
||||
return {
|
||||
"id": node_id,
|
||||
"code": code or node_id,
|
||||
"name": code or node_id,
|
||||
"api_base_url": f"http://{code or node_id}:19100",
|
||||
"enabled": True,
|
||||
"scheduler_status": "online",
|
||||
}
|
||||
|
||||
|
||||
def _task(task_id: str, *, node_id: str | None = None, load_status: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": task_id,
|
||||
"name": f"task-{task_id}",
|
||||
"status": "pending",
|
||||
"models": [
|
||||
{"model_id": "m_1", "model_name": "qwen", "model_path": "/models/qwen", "node_id": node_id}
|
||||
],
|
||||
"load_status": load_status or {"loaded_models": []},
|
||||
}
|
||||
|
||||
|
||||
async def _fake_inference_load(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"loaded": False, "status": "loading", "request_id": "req-1"}
|
||||
|
||||
|
||||
async def _fake_inference_unload(self) -> dict[str, Any]:
|
||||
return {"unloaded": True, "status": "idle"}
|
||||
|
||||
|
||||
def _patch_store(monkeypatch, store: FakeInferenceStore) -> None:
|
||||
monkeypatch.setattr(platform, "get_platform_store", lambda: store)
|
||||
monkeypatch.setattr(platform, "get_settings", lambda: SimpleNamespace(compute_mode="real"))
|
||||
|
||||
|
||||
def test_model_compare_load_dispatches_and_returns_starting(monkeypatch) -> None:
|
||||
store = FakeInferenceStore(tasks=[_task("t1", node_id="n1")], nodes=[_node("n1")])
|
||||
_patch_store(monkeypatch, store)
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_load", _fake_inference_load)
|
||||
|
||||
result = asyncio.run(model_compare_load("t1"))
|
||||
assert result["code"] == 0
|
||||
updated = result["data"]
|
||||
assert updated["status"] == "starting"
|
||||
items = updated["load_status"]["loaded_models"]
|
||||
assert items[0]["status"] == "starting"
|
||||
assert items[0]["node_id"] == "n1"
|
||||
assert "n1" in store._inference_nodes
|
||||
|
||||
|
||||
def test_model_compare_load_marks_error_when_all_nodes_fail(monkeypatch) -> None:
|
||||
store = FakeInferenceStore(tasks=[_task("t1", node_id="n1")], nodes=[_node("n1")])
|
||||
_patch_store(monkeypatch, store)
|
||||
|
||||
async def _raise(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise RuntimeError("conn refused")
|
||||
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_load", _raise)
|
||||
|
||||
result = asyncio.run(model_compare_load("t1"))
|
||||
updated = result["data"]
|
||||
assert updated["status"] == "failed"
|
||||
assert updated["load_status"]["loaded_models"][0]["status"] == "error"
|
||||
assert "conn refused" in updated["load_status"]["loaded_models"][0]["error"]
|
||||
|
||||
|
||||
def test_model_compare_delete_removes_record_even_if_unload_raises(monkeypatch) -> None:
|
||||
task = _task(
|
||||
"t1",
|
||||
node_id="n1",
|
||||
load_status={"loaded_models": [{"model_id": "m_1", "status": "ready", "node_id": "n1"}]},
|
||||
)
|
||||
store = FakeInferenceStore(tasks=[task], nodes=[_node("n1")])
|
||||
_patch_store(monkeypatch, store)
|
||||
|
||||
async def _raise(self) -> dict[str, Any]:
|
||||
raise RuntimeError("unload boom")
|
||||
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_unload", _raise)
|
||||
|
||||
result = asyncio.run(model_compare_delete("t1"))
|
||||
assert result["data"] == {"deleted": "t1"}
|
||||
assert "t1" not in store._tasks
|
||||
# finally 中仍清掉了节点标记
|
||||
assert "n1" not in store._inference_nodes
|
||||
|
||||
|
||||
def test_unload_from_compute_node_only_hits_recorded_node(monkeypatch) -> None:
|
||||
task = _task(
|
||||
"t1",
|
||||
load_status={"loaded_models": [{"model_id": "m_1", "status": "ready", "node_id": "n1"}]},
|
||||
)
|
||||
store = FakeInferenceStore(tasks=[task], nodes=[_node("n1"), _node("n2")])
|
||||
_patch_store(monkeypatch, store)
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_unload", _fake_inference_unload)
|
||||
|
||||
from app.api.v1.endpoints.platform import _unload_from_compute_node
|
||||
|
||||
result = asyncio.run(_unload_from_compute_node(store, task=task))
|
||||
assert result["unloaded"] is True
|
||||
# 只命中任务记录中的节点 n1,n2 未被卸载
|
||||
assert [r["node_id"] for r in result["nodes"]] == ["n1"]
|
||||
assert "n1" not in store._inference_nodes
|
||||
|
||||
|
||||
async def _status_ready(self) -> dict[str, Any]:
|
||||
return {"loaded": True, "status": "ready", "model_name": "qwen"}
|
||||
|
||||
|
||||
def test_reconcile_transitions_starting_to_ready(monkeypatch) -> None:
|
||||
task = _task(
|
||||
"t1",
|
||||
node_id="n1",
|
||||
load_status={"loaded_models": [{"model_id": "m_1", "status": "starting", "node_id": "n1"}]},
|
||||
)
|
||||
store = FakeInferenceStore(tasks=[task], nodes=[_node("n1")])
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_status", _status_ready)
|
||||
|
||||
reconciled = asyncio.run(reconcile_inference_loads(store))
|
||||
assert reconciled == [{"task_id": "t1", "status": "loaded"}]
|
||||
updated = store._tasks["t1"]
|
||||
assert updated["status"] == "loaded"
|
||||
assert updated["load_status"]["loaded_models"][0]["status"] == "ready"
|
||||
assert "n1" in store._inference_nodes
|
||||
|
||||
|
||||
def test_reconcile_transitions_to_error_and_failed(monkeypatch) -> None:
|
||||
async def _status_error(self) -> dict[str, Any]:
|
||||
return {"loaded": False, "status": "error", "error": "CUDA out of memory"}
|
||||
|
||||
task = _task(
|
||||
"t1",
|
||||
node_id="n1",
|
||||
load_status={"loaded_models": [{"model_id": "m_1", "status": "starting", "node_id": "n1"}]},
|
||||
)
|
||||
store = FakeInferenceStore(tasks=[task], nodes=[_node("n1")])
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_status", _status_error)
|
||||
|
||||
reconciled = asyncio.run(reconcile_inference_loads(store))
|
||||
assert reconciled == [{"task_id": "t1", "status": "failed"}]
|
||||
item = store._tasks["t1"]["load_status"]["loaded_models"][0]
|
||||
assert item["status"] == "error"
|
||||
assert "CUDA out of memory" in item["error"]
|
||||
assert "n1" not in store._inference_nodes
|
||||
|
||||
|
||||
def test_reconcile_idle_marks_model_disappeared(monkeypatch) -> None:
|
||||
async def _status_idle(self) -> dict[str, Any]:
|
||||
return {"loaded": False, "status": "idle"}
|
||||
|
||||
task = _task(
|
||||
"t1",
|
||||
node_id="n1",
|
||||
load_status={"loaded_models": [{"model_id": "m_1", "status": "starting", "node_id": "n1"}]},
|
||||
)
|
||||
store = FakeInferenceStore(tasks=[task], nodes=[_node("n1")])
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_status", _status_idle)
|
||||
|
||||
asyncio.run(reconcile_inference_loads(store))
|
||||
item = store._tasks["t1"]["load_status"]["loaded_models"][0]
|
||||
assert item["status"] == "error"
|
||||
assert "disappeared" in item["error"]
|
||||
assert store._tasks["t1"]["status"] == "failed"
|
||||
|
||||
|
||||
def test_reconcile_unreachable_node_flips_to_error_after_cap(monkeypatch) -> None:
|
||||
async def _raise(self) -> dict[str, Any]:
|
||||
raise RuntimeError("conn refused")
|
||||
|
||||
task = _task(
|
||||
"t1",
|
||||
node_id="n1",
|
||||
load_status={"loaded_models": [{"model_id": "m_1", "status": "starting", "node_id": "n1"}]},
|
||||
)
|
||||
store = FakeInferenceStore(tasks=[task], nodes=[_node("n1")])
|
||||
monkeypatch.setattr(ComputeNodeClient, "inference_status", _raise)
|
||||
|
||||
# 每次轮询前重置节流时间戳,逐次推进 load_attempts 到封顶
|
||||
for _ in range(MAX_STARTING_ATTEMPTS):
|
||||
item = store._tasks["t1"]["load_status"]["loaded_models"][0]
|
||||
item["last_polled_at"] = 0
|
||||
asyncio.run(reconcile_inference_loads(store))
|
||||
|
||||
item = store._tasks["t1"]["load_status"]["loaded_models"][0]
|
||||
assert item["status"] == "error"
|
||||
assert "unreachable" in item["error"]
|
||||
assert store._tasks["t1"]["status"] == "failed"
|
||||
Reference in New Issue
Block a user