210 lines
8.9 KiB
Python
210 lines
8.9 KiB
Python
"""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()))
|