2026-08-21 14:40:36 +08:00
|
|
|
|
"""
|
|
|
|
|
|
真实 Zenoh 会话下的流式端到端测试(peer 模式,无需 router)
|
|
|
|
|
|
|
|
|
|
|
|
单进程内起两个 Zenoh 会话(listen + connect),跑通完整生产链路:
|
|
|
|
|
|
@rpc_method(stream=True) 注册 -> create_worker 声明 Queryable
|
|
|
|
|
|
-> WorkerManager.call_stream 消费多回复 -> 逐块到达
|
|
|
|
|
|
|
|
|
|
|
|
验证:
|
|
|
|
|
|
1. 流式方法:3+1 块按序到达(真实 Zenoh 多回复 + ConsolidationMode.NONE)
|
|
|
|
|
|
2. 流式中途异常:{"error": ...} 收尾块转发后终止
|
|
|
|
|
|
3. 非流式方法:仍是单回复(兼容性回归)
|
|
|
|
|
|
4. generate_route_configs 输出 stream 标记、非流式不含
|
|
|
|
|
|
5. decorators 对生成器函数的调用不消费生成器
|
|
|
|
|
|
|
|
|
|
|
|
运行: uv run python test_stream_e2e_zenoh.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import importlib.util
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import types
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import orjson
|
|
|
|
|
|
import zenoh
|
|
|
|
|
|
|
2026-10-02 11:54:46 +08:00
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
2026-08-21 14:40:36 +08:00
|
|
|
|
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
|
|
|
|
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 网关依赖隔离(同 test_stream_gateway):桩掉 chongming 包链与 chongming_cache ----
|
|
|
|
|
|
_pkg = types.ModuleType("chongming")
|
|
|
|
|
|
_pkg.__path__ = []
|
|
|
|
|
|
_app = types.ModuleType("chongming.app")
|
|
|
|
|
|
_app.__path__ = []
|
|
|
|
|
|
_core = types.ModuleType("chongming.app.core")
|
|
|
|
|
|
_core.__path__ = []
|
|
|
|
|
|
_logger_stub = types.ModuleType("chongming.app.core.logger")
|
|
|
|
|
|
_logger_stub.get_logger = lambda name: logging.getLogger(name)
|
|
|
|
|
|
_cache_stub = types.ModuleType("chongming_cache")
|
|
|
|
|
|
_cache_stub.Cache = object
|
2026-10-02 11:54:46 +08:00
|
|
|
|
_cache_stub.DistributedLock = object # worker_manager 顶层导入(本测试不触达)
|
2026-08-21 14:40:36 +08:00
|
|
|
|
for mod in (_pkg, _app, _core, _logger_stub, _cache_stub):
|
|
|
|
|
|
sys.modules[mod.__name__] = mod
|
|
|
|
|
|
|
|
|
|
|
|
_wm_path = REPO_ROOT / "src" / "chongming" / "app" / "core" / "worker_manager.py"
|
|
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
|
|
|
|
"chongming.app.core.worker_manager", _wm_path
|
|
|
|
|
|
)
|
|
|
|
|
|
_wm_mod = importlib.util.module_from_spec(_spec)
|
|
|
|
|
|
sys.modules["chongming.app.core.worker_manager"] = _wm_mod
|
|
|
|
|
|
_spec.loader.exec_module(_wm_mod) # type: ignore[union-attr]
|
|
|
|
|
|
|
|
|
|
|
|
WorkerManager = _wm_mod.WorkerManager
|
|
|
|
|
|
StreamIdleTimeoutError = _wm_mod.StreamIdleTimeoutError
|
|
|
|
|
|
|
|
|
|
|
|
from chongming_worker import clear_registry, rpc_method # noqa: E402
|
|
|
|
|
|
from chongming_worker.decorators import generate_route_configs # noqa: E402
|
|
|
|
|
|
from chongming_worker.runtime import create_worker # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 11:54:46 +08:00
|
|
|
|
import os
|
|
|
|
|
|
LISTEN_PORT = int(os.environ.get("CM_STREAM_TEST_PORT", "7447"))
|
2026-08-21 14:40:36 +08:00
|
|
|
|
LISTEN = f"tcp/127.0.0.1:{LISTEN_PORT}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def open_session(listen=None):
|
|
|
|
|
|
"""peer 模式会话:worker 侧 listen,网关侧 connect(显式端点,禁用组播)"""
|
|
|
|
|
|
c = zenoh.Config()
|
|
|
|
|
|
c.insert_json5("scouting/multicast/enabled", "false")
|
|
|
|
|
|
if listen:
|
|
|
|
|
|
c.insert_json5("listen/endpoints", f'["{listen}"]')
|
|
|
|
|
|
else:
|
|
|
|
|
|
c.insert_json5("connect/endpoints", f'["{LISTEN}"]')
|
|
|
|
|
|
return zenoh.open(c)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def run_checks():
|
|
|
|
|
|
clear_registry()
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 流式端点(返回异步生成器)----
|
|
|
|
|
|
@rpc_method(subject="streamtest.echo", method="POST", stream=True)
|
|
|
|
|
|
async def echo_stream(prompt: str = "hi", tokens: int = 3):
|
|
|
|
|
|
for i in range(tokens):
|
|
|
|
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
yield {"token": f"{prompt}-{i}"}
|
|
|
|
|
|
yield {"done": True}
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 流式端点:中途异常(框架应发 {"error": ...} 收尾块)----
|
|
|
|
|
|
@rpc_method(subject="streamtest.fail", method="POST", stream=True)
|
|
|
|
|
|
async def fail_stream():
|
|
|
|
|
|
yield {"token": "a"}
|
|
|
|
|
|
raise ValueError("mid-boom")
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 非流式端点(兼容性对照)----
|
|
|
|
|
|
@rpc_method(subject="streamtest.add", method="GET")
|
|
|
|
|
|
async def add(a: float, b: float):
|
|
|
|
|
|
return a + b
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 配置生成:流式标记
|
|
|
|
|
|
configs = generate_route_configs("streamtest")
|
|
|
|
|
|
stream_cfg = next(c for c in configs if c["subject"] == "streamtest.echo")
|
|
|
|
|
|
check("generate_route_configs 输出 stream 标记", stream_cfg.get("stream") is True)
|
|
|
|
|
|
add_cfg = next(c for c in configs if c["subject"] == "streamtest.add")
|
|
|
|
|
|
check("非流式配置不含 stream 标记", "stream" not in add_cfg)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 真实 Zenoh 会话(peer 模式,无 router)
|
|
|
|
|
|
session_worker = open_session(listen=LISTEN)
|
|
|
|
|
|
session_gw = open_session()
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 启动真实 Worker(create_worker 声明 Queryable)
|
|
|
|
|
|
worker_task = asyncio.create_task(
|
|
|
|
|
|
create_worker(session_worker, "streamtest", "w1")
|
|
|
|
|
|
)
|
|
|
|
|
|
await asyncio.sleep(0.5) # 等待 queryable 在 peer 间可见
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 4. 网关 WorkerManager.call_stream 真实消费流式端点
|
|
|
|
|
|
wm = WorkerManager(session_gw)
|
|
|
|
|
|
wm.register_worker("streamtest", "w1")
|
|
|
|
|
|
|
|
|
|
|
|
async def _consume(subject, data, idle_timeout=2.0):
|
|
|
|
|
|
gen = await wm.call_stream(
|
|
|
|
|
|
"streamtest", subject,
|
|
|
|
|
|
{"meta": {"method": "POST"}, "data": data},
|
|
|
|
|
|
timeout=10.0, idle_timeout=idle_timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
return [orjson.loads(b) async for b in gen]
|
|
|
|
|
|
|
|
|
|
|
|
chunks = await _consume("streamtest.echo", {"prompt": "hi", "tokens": 3})
|
|
|
|
|
|
check(
|
|
|
|
|
|
"流式 3+1 块按序到达(真实多回复)",
|
|
|
|
|
|
chunks == [
|
|
|
|
|
|
{"token": "hi-0"}, {"token": "hi-1"}, {"token": "hi-2"}, {"done": True},
|
|
|
|
|
|
],
|
|
|
|
|
|
f"chunks={chunks}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 流式中途异常:错误块收尾
|
|
|
|
|
|
chunks = await _consume("streamtest.fail", {})
|
|
|
|
|
|
check(
|
|
|
|
|
|
"流式中途异常 error 块收尾",
|
|
|
|
|
|
chunks == [{"token": "a"}, {"error": "mid-boom"}],
|
|
|
|
|
|
f"chunks={chunks}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 6. 非流式兼容:call() 仍单回复
|
|
|
|
|
|
result = await wm.call(
|
|
|
|
|
|
"streamtest", "streamtest.add",
|
|
|
|
|
|
{"meta": {"method": "GET"}, "data": {"a": 2, "b": 3}},
|
|
|
|
|
|
timeout=10.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
check("非流式 call() 正常单回复", result == 5.0, f"result={result}")
|
|
|
|
|
|
|
|
|
|
|
|
# 7. 负载均衡路径兼容:liveliness 发现的 worker 也能流式(同 call)
|
|
|
|
|
|
worker_expr = await wm.get_worker("streamtest")
|
|
|
|
|
|
check("get_worker 仍可解析本地 worker", worker_expr is not None)
|
|
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
worker_task.cancel()
|
|
|
|
|
|
await asyncio.gather(worker_task, return_exceptions=True)
|
|
|
|
|
|
session_gw.close()
|
|
|
|
|
|
session_worker.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run():
|
|
|
|
|
|
asyncio.run(run_checks())
|
|
|
|
|
|
print(f"\n结果: {len(failures)} 个失败")
|
|
|
|
|
|
return 1 if failures else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(run())
|