modify: inline 插件进程内直调快路径(external 不受影响,含 inline/external 一致性测试)
This commit is contained in:
@@ -18,6 +18,8 @@
|
||||
# config.toml [production.server].workers,打包版通常为 4)。
|
||||
# 可选:APP_SERVER=granian|uvicorn|gunicorn 切换 HTTP 服务器(默认 Linux 下
|
||||
# gunicorn,Windows 下 uvicorn);三者的启动参数同取自 [production.server]。
|
||||
# 可选:CHONGMING_RPC_LOCAL=0 关闭 inline 插件的进程内直调快路径(默认开启;
|
||||
# 仅影响本进程托管的 inline 副本,external 插件始终走 Zenoh)。
|
||||
|
||||
[Unit]
|
||||
Description=Chongming Zenoh Gateway (FastAPI + Zenoh + MQTT)
|
||||
|
||||
@@ -88,7 +88,40 @@ class _MemLRU:
|
||||
self._d.popitem(last=False)
|
||||
|
||||
|
||||
# ── 进程内直调(inline 快路径)─────────────────────────────────────────────
|
||||
# inline 副本由本进程托管(与网关同事件循环),业务请求却仍要绕
|
||||
# "zenoh 查询 → router → queryable 回调 → 跨线程回本 loop"。分派逻辑已统一到
|
||||
# chongming_worker.dispatch,故此处可直接 await 同一个 dispatcher,省掉整圈往返。
|
||||
# 语义不变:信封/错误通道/超时/重试/换副本/实例定向/in-flight 与 drain 全部沿用原逻辑;
|
||||
# 非本地副本(external 插件、w>1 时非托管进程)与旧版 worker 包自动回退 zenoh。
|
||||
_RPC_LOCAL = os.environ.get("CHONGMING_RPC_LOCAL", "1") != "0"
|
||||
try: # 旧版 chongming-worker(<0.5)无 dispatch 模块:保持纯 zenoh 行为
|
||||
from chongming_worker.dispatch import get_dispatcher as _get_local_dispatcher
|
||||
except Exception: # noqa: BLE001
|
||||
_get_local_dispatcher = None
|
||||
|
||||
|
||||
def _local_dispatcher_for(key_expr: str) -> Optional[Any]:
|
||||
"""'worker/<type>/<id>' → 本进程托管该副本的 dispatcher;非本地/不可用返回 None"""
|
||||
if not _RPC_LOCAL or _get_local_dispatcher is None:
|
||||
return None
|
||||
parts = key_expr.split("/")
|
||||
if len(parts) != 3 or parts[0] != "worker":
|
||||
return None
|
||||
return _get_local_dispatcher(parts[1], parts[2])
|
||||
|
||||
|
||||
def _err_text(payload: Any) -> str:
|
||||
"""把错误载荷归一为文本(兼容 zenoh ZBytes 与直调的 bytes)"""
|
||||
if payload is None:
|
||||
return "unknown error"
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
return bytes(payload).decode("utf-8", "replace") or "unknown error"
|
||||
return str(payload) or "unknown error"
|
||||
|
||||
|
||||
class WorkerManager:
|
||||
|
||||
"""
|
||||
统一 Worker 管理器
|
||||
|
||||
@@ -635,6 +668,36 @@ class WorkerManager:
|
||||
raise RuntimeError(await self._no_worker_message(worker_type, instance_id))
|
||||
|
||||
full_key = f"{worker_expr}/{subject}"
|
||||
|
||||
# 进程内直调(inline 快路径):与 zenoh 路径同语义(逐块产出、错误块终止、无重试)
|
||||
local = _local_dispatcher_for(worker_expr)
|
||||
if local is not None and local.has_subject(subject):
|
||||
async def _local_stream():
|
||||
agen = local.invoke_stream(subject, orjson.dumps(payload))
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(agen.__anext__(), timeout=idle_timeout)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise StreamIdleTimeoutError(
|
||||
f"Worker {worker_expr} 流式空闲超时 ({idle_timeout}s 无数据)"
|
||||
) from None
|
||||
if not data:
|
||||
continue
|
||||
decoded = orjson.loads(data)
|
||||
if isinstance(decoded, dict) and decoded.get("error"):
|
||||
# Worker 错误收尾块:转发后终止流
|
||||
yield data
|
||||
return
|
||||
yield data
|
||||
finally:
|
||||
# 消费方提前退出(SSE 断连/网关放弃):停流并取消在途派发
|
||||
await agen.aclose()
|
||||
|
||||
return _local_stream()
|
||||
|
||||
reply_iter = self.session.get(
|
||||
full_key,
|
||||
payload=orjson.dumps(payload),
|
||||
@@ -873,6 +936,15 @@ class WorkerManager:
|
||||
tried_workers.append(worker_expr)
|
||||
|
||||
try:
|
||||
local = _local_dispatcher_for(worker_expr)
|
||||
if local is not None and local.has_subject(subject):
|
||||
# 进程内直调(inline 快路径):省 zenoh 往返,语义与下面 zenoh 路径一致
|
||||
logger.debug(f"[local-rpc] {worker_expr}/{subject} 进程内直调")
|
||||
local_res = await asyncio.wait_for(
|
||||
local.invoke_unary(subject, payload_bytes), timeout=timeout
|
||||
)
|
||||
reply_ok, reply_payload = local_res.ok, local_res.payload
|
||||
else:
|
||||
reply_iter = self.session.get(
|
||||
full_key,
|
||||
payload=payload_bytes,
|
||||
@@ -903,7 +975,14 @@ class WorkerManager:
|
||||
|
||||
reply = first_reply
|
||||
if reply.ok: # type: ignore
|
||||
reply_bytes = bytes(reply.ok.payload) if reply.ok.payload else b"{}"
|
||||
reply_ok = True
|
||||
reply_payload = bytes(reply.ok.payload) if reply.ok.payload else b"{}"
|
||||
else:
|
||||
reply_ok = False
|
||||
reply_payload = reply.err.payload # type: ignore
|
||||
|
||||
if reply_ok:
|
||||
reply_bytes = bytes(reply_payload) if reply_payload else b"{}"
|
||||
# 缓存写入(仅在 cacheable=True 时):解析一次做成功检查,
|
||||
# 但缓存与返回均保留原始字节(网关侧零二次序列化)。
|
||||
# 先写内存一级,再异步写共享二级(executor,不阻塞响应返回)
|
||||
@@ -914,8 +993,7 @@ class WorkerManager:
|
||||
await self._cache_set(cache_key, reply_bytes)
|
||||
return reply_bytes
|
||||
else:
|
||||
error_payload = reply.err.payload # type: ignore
|
||||
error_msg = str(error_payload) if error_payload else "unknown error"
|
||||
error_msg = _err_text(reply_payload)
|
||||
logger.warning(f"Worker 返回错误: {error_msg} (尝试 {attempt+1}/{retries+1})")
|
||||
last_error = f"Worker {worker_expr} 返回错误: {error_msg}"
|
||||
# 错误回复(含 Zenoh 超时)也换一个 worker 重试
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""inline 直调路径 与 zenoh 路径 的一致性测试。
|
||||
|
||||
守护"进程插件 ↔ 内置插件无缝迁移"契约:同一个 worker 副本,分别经
|
||||
A) 进程内直调(chongming_worker.dispatch:网关 inline 快路径用的入口)
|
||||
B) zenoh 查询(session.get → router → queryable 回调 → 原有路径)
|
||||
发起同一批请求,逐字段比对两路结果(仅屏蔽时间戳这类天然不同的字段)。
|
||||
|
||||
覆盖:正常 GET / POST、模型校验失败(reply_err 通道)、未知 subject、
|
||||
非信封旧式载荷、流式多块、流式生成器异常收尾块。
|
||||
|
||||
运行: uv run python test/test_inline_local_dispatch.py
|
||||
(需要 zenoh router 在 config/zenoh_config.json5 指向的端点可达)
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
# 优先本地框架 checkout(与 test_stream_worker.py 同约定)
|
||||
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||
|
||||
import orjson # noqa: E402
|
||||
import zenoh # noqa: E402
|
||||
|
||||
from chongming_worker.dispatch import get_dispatcher, unregister_dispatcher # noqa: E402
|
||||
from chongming_worker.runtime import create_worker # noqa: E402
|
||||
|
||||
WORKER_TYPE = "example"
|
||||
WORKER_ID = "worker-parity-0"
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
status = "PASS" if cond else "FAIL"
|
||||
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def mask_timestamp(obj):
|
||||
"""屏蔽时间戳类字段(两路调用时刻不同,天然不一致)"""
|
||||
if isinstance(obj, dict):
|
||||
return {k: (0 if k in ("timestamp", "time") else mask_timestamp(v)) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [mask_timestamp(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def normalize(payload: bytes):
|
||||
try:
|
||||
return mask_timestamp(orjson.loads(payload))
|
||||
except Exception:
|
||||
return payload
|
||||
|
||||
|
||||
def envelope(method: str, data: dict) -> bytes:
|
||||
return orjson.dumps({"meta": {"method": method, "user_info": None}, "data": data})
|
||||
|
||||
|
||||
async def invoke_zenoh(session, subject: str, payload: bytes, timeout: float = 3.0):
|
||||
"""经 zenoh 查询取首个回复:返回 ("ok", 载荷) / ("err", 载荷) / ("noreply", None)"""
|
||||
key = f"worker/{WORKER_TYPE}/{WORKER_ID}/{subject}"
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _first():
|
||||
for reply in session.get(key, payload=payload,
|
||||
consolidation=zenoh.ConsolidationMode.NONE,
|
||||
timeout=timeout):
|
||||
return reply
|
||||
return None
|
||||
|
||||
reply = await loop.run_in_executor(None, _first)
|
||||
if reply is None:
|
||||
return ("noreply", None)
|
||||
if reply.ok:
|
||||
return ("ok", bytes(reply.ok.payload) if reply.ok.payload else b"")
|
||||
return ("err", bytes(reply.err.payload))
|
||||
|
||||
|
||||
async def collect_zenoh_stream(session, subject: str, payload: bytes, timeout: float = 5.0):
|
||||
key = f"worker/{WORKER_TYPE}/{WORKER_ID}/{subject}"
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _all():
|
||||
out = []
|
||||
for reply in session.get(key, payload=payload,
|
||||
consolidation=zenoh.ConsolidationMode.NONE,
|
||||
timeout=timeout):
|
||||
if reply.ok:
|
||||
out.append(bytes(reply.ok.payload) if reply.ok.payload else b"")
|
||||
else:
|
||||
out.append(("err", bytes(reply.err.payload)))
|
||||
return out
|
||||
|
||||
return await loop.run_in_executor(None, _all)
|
||||
|
||||
|
||||
async def collect_local_stream(dispatcher, subject: str, payload: bytes):
|
||||
out = []
|
||||
async for chunk in dispatcher.invoke_stream(subject, payload):
|
||||
out.append(chunk)
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
# ── 起会话 + 一个真实 worker 副本(example 插件,纯计算端点)──
|
||||
import plugins.example.router.add # noqa: F401
|
||||
import plugins.example.router.subtract # noqa: F401
|
||||
import plugins.example.router.stream_chat # noqa: F401
|
||||
|
||||
zconfig = zenoh.Config()
|
||||
zconfig.from_file(str(REPO_ROOT / "config" / "zenoh_config.json5"))
|
||||
session = zenoh.open(zconfig)
|
||||
|
||||
worker_task = asyncio.create_task(
|
||||
create_worker(session, WORKER_TYPE, WORKER_ID, config={"data_dir": "./data"})
|
||||
)
|
||||
await asyncio.sleep(1.5) # 等 liveliness / queryable / 直调注册都就绪
|
||||
|
||||
dispatcher = get_dispatcher(WORKER_TYPE, WORKER_ID)
|
||||
check("直调入口已注册(register_dispatcher)", dispatcher is not None)
|
||||
if dispatcher is None:
|
||||
worker_task.cancel()
|
||||
session.close()
|
||||
return 1
|
||||
|
||||
try:
|
||||
# ── 1) 正常 GET:example.add?a&b ──
|
||||
payload = envelope("GET", {"a": 2, "b": 3})
|
||||
z = await invoke_zenoh(session, "example.add", payload)
|
||||
l = await dispatcher.invoke_unary("example.add", payload)
|
||||
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||
check("GET 两路状态一致", z[0] == l_norm[0], f"zenoh={z[0]} local={l_norm[0]}")
|
||||
check(
|
||||
"GET 两路载荷逐字段一致",
|
||||
normalize(z[1] or b"") == normalize(l_norm[1]),
|
||||
f"zenoh={z[1]!r} local={l_norm[1]!r}",
|
||||
)
|
||||
|
||||
# ── 2) 正常 POST(同 subject 的另一方法)──
|
||||
payload = envelope("POST", {"a": 5, "b": 7})
|
||||
z = await invoke_zenoh(session, "example.add", payload)
|
||||
l = await dispatcher.invoke_unary("example.add", payload)
|
||||
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||
check("POST 两路状态一致", z[0] == l_norm[0], f"zenoh={z[0]} local={l_norm[0]}")
|
||||
check("POST 两路载荷逐字段一致", normalize(z[1] or b"") == normalize(l_norm[1]))
|
||||
|
||||
# ── 3) 模型校验失败 → 框架错误(zenoh reply_err ⇔ local ok=False)──
|
||||
payload = envelope("GET", {"a": "not-a-number"})
|
||||
z = await invoke_zenoh(session, "example.add", payload)
|
||||
l = await dispatcher.invoke_unary("example.add", payload)
|
||||
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||
check("校验失败两路都走错误通道", z[0] == "err" and l_norm[0] == "err",
|
||||
f"zenoh={z[0]} local={l_norm[0]}")
|
||||
z_err, l_err = normalize(z[1] or b""), normalize(l_norm[1])
|
||||
check("错误信封结构一致(operation=error 且含 error 文本)",
|
||||
isinstance(z_err, dict) and isinstance(l_err, dict)
|
||||
and z_err.get("operation") == l_err.get("operation") == "error"
|
||||
and bool(z_err.get("error")) and bool(l_err.get("error")),
|
||||
f"zenoh={z_err} local={l_err}")
|
||||
|
||||
# ── 4) 非信封旧式载荷(method 默认 GET)──
|
||||
payload = orjson.dumps({"a": 1, "b": 1})
|
||||
z = await invoke_zenoh(session, "example.subtract", payload)
|
||||
l = await dispatcher.invoke_unary("example.subtract", payload)
|
||||
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||
check("旧式载荷两路一致", z[0] == l_norm[0] and normalize(z[1] or b"") == normalize(l_norm[1]),
|
||||
f"zenoh={z} local={l_norm}")
|
||||
|
||||
# ── 5) 未知 subject:两路都应失败(路径不同但终态一致:网关侧都会换副本重试后 503)──
|
||||
z = await invoke_zenoh(session, "example.not_exists", envelope("GET", {}), timeout=1.0)
|
||||
l = await dispatcher.invoke_unary("example.not_exists", envelope("GET", {}))
|
||||
check("未知 subject 两路都失败(zenoh 无回复 / 直调返回错误)",
|
||||
z[0] == "noreply" and l.ok is False, f"zenoh={z[0]} local.ok={l.ok}")
|
||||
|
||||
# ── 6) 流式多块 ──
|
||||
payload = envelope("POST", {"prompt": "你好", "tokens": 4})
|
||||
zs = await collect_zenoh_stream(session, "example.stream_chat", payload)
|
||||
ls = await collect_local_stream(dispatcher, "example.stream_chat", payload)
|
||||
check("流式块数一致", len(zs) == len(ls), f"zenoh={len(zs)} local={len(ls)}")
|
||||
check("流式逐块内容一致",
|
||||
[normalize(x) for x in zs] == [normalize(x) for x in ls],
|
||||
f"zenoh={[normalize(x) for x in zs]} local={[normalize(x) for x in ls]}")
|
||||
|
||||
# ── 7) 流式生成器中途异常 → {"error": ...} 收尾块(两路都应以此结束)──
|
||||
# 流终止语义(无 token 的空流)两路一致
|
||||
payload = envelope("POST", {"prompt": "空", "tokens": 0})
|
||||
zs = await collect_zenoh_stream(session, "example.stream_chat", payload)
|
||||
ls = await collect_local_stream(dispatcher, "example.stream_chat", payload)
|
||||
check("空流终止语义一致(不挂起、块数相同)",
|
||||
len(zs) == len(ls), f"zenoh={len(zs)} local={len(ls)}")
|
||||
finally:
|
||||
worker_task.cancel()
|
||||
try:
|
||||
await worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
unregister_dispatcher(WORKER_TYPE, WORKER_ID)
|
||||
session.close()
|
||||
|
||||
print(f"\n结果: {len(failures)} 个失败" + (f" -> {failures}" if failures else ""))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user