Files
chongming-worker-rs/scripts/verify_e2e.py
T
2026-08-31 17:18:03 +08:00

295 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Rust 进程插件端到端协议验证(多语言演进规划阶段 1 验收)
前置条件:
1. 本地 Zenoh Router 已运行(scripts/zenoh_router.py)
2. Rust 参考插件已编译(cargo build --examples 或 workers/rsdemo)
验证内容(docs/plugin-wire-protocol.md 全协议面):
1. 路由描述符发布(keyexpr 编码、字段、schema_version/type)
2. liveliness 服务发现(worker/rsdemo/*)
3. 普通 RPC(GET 信封 / POST user_info 透传)
4. 非信封载荷兼容(method 默认 GET)
5. 流式多回复(逐块)+ 流中异常 {"error": ...} 收尾块
6. 非流式异常回复格式(result/operation/error/worker)
7. SIGTERM 优雅退出 → 描述符 DELETE 摘除
8. 重启 → 10s 内重发布自愈
用法(项目根目录):
.venv/bin/python utils/worker-rs/scripts/verify_e2e.py [--type rsdemo] [--count 1] [--bin <路径>]
"""
import argparse
import orjson
import os
import signal
import subprocess
import sys
import time
import zenoh
PASS = 0
FAIL = 0
DEFAULT_BIN = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "target", "debug", "examples", "demo")
)
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
ROUTES_PREFIX = "chongming/routes"
def report(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f"✅ {name}" + (f" — {detail}" if detail else ""))
else:
FAIL += 1
print(f"❌ {name}" + (f" — {detail}" if detail else ""))
def start_worker(worker_type, count=1, bin_path=None):
"""启动 Rust 插件进程(CWD=项目根,读取全局 config.toml)"""
binary = bin_path or DEFAULT_BIN
proc = subprocess.Popen(
[binary, "--type", worker_type, "--count", str(count)],
cwd=ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
# 等待 Queryable 就绪(日志出现"进程就绪")
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if proc.poll() is not None:
out = proc.stdout.read().decode() if proc.stdout else ""
raise RuntimeError(f"Worker 启动失败,退出码 {proc.returncode}:\n{out}")
time.sleep(0.2)
line = proc.stdout.readline() if proc.stdout else b""
if "等待退出信号" in line.decode("utf-8", "ignore"):
return proc
raise RuntimeError("Worker 20s 内未就绪")
def wait_worker_exit(proc, timeout=10):
try:
proc.wait(timeout=timeout)
return True
except subprocess.TimeoutExpired:
return False
def make_subscriber(session, worker_type):
"""建立常驻订阅,持续记录 PUT 描述符与 DELETE 事件(不重放历史值,
调用方须在事件发生前建立订阅)"""
samples = {}
def cb(sample):
key = str(sample.key_expr)
if sample.kind == zenoh.SampleKind.DELETE:
samples[key] = None # 摘除标记
else:
samples[key] = orjson.loads(bytes(sample.payload))
sub = session.declare_subscriber(f"{ROUTES_PREFIX}/{worker_type}/**", cb)
return sub, samples
def rpc(session, worker_expr, subject, payload, timeout=10.0):
"""按信封协议发起 Zenoh Query,返回所有回复块 JSON 列表"""
full_key = f"{worker_expr}/{subject}"
replies = session.get(
full_key,
payload=orjson.dumps(payload),
consolidation=zenoh.ConsolidationMode.NONE,
timeout=timeout,
)
result = []
for reply in replies:
if reply.ok:
result.append(orjson.loads(bytes(reply.ok.payload)))
else:
result.append({"__zenoh_err__": str(reply.err.payload)})
return result
def get_worker_exprs(session, worker_type):
"""liveliness 发现 worker/worker_type/* 副本列表"""
exprs = []
for reply in session.liveliness().get(f"worker/{worker_type}/*"):
if reply.ok:
exprs.append(str(reply.ok.key_expr))
return exprs
def main():
global PASS, FAIL
parser = argparse.ArgumentParser()
parser.add_argument("--type", default="rsdemo")
parser.add_argument("--count", type=int, default=1)
parser.add_argument("--bin", default=None, help="插件二进制路径(默认 SDK 示例 demo)")
args = parser.parse_args()
wt = args.type
session = zenoh.open(zenoh.Config.from_file("config/zenoh_config.json5"))
print(f"=== Rust 进程插件协议验证 (type={wt}, bin={args.bin or DEFAULT_BIN}) ===")
# ========== 1. 路由描述符发布(先订阅再启动,zenoh 不重放历史值) ==========
sub1, samples1 = make_subscriber(session, wt)
proc = start_worker(wt, args.count, args.bin)
time.sleep(1.5)
expect_keys = {
"rsdemo": {
"chongming/routes/rsdemo/rsdemo_hello__GET",
"chongming/routes/rsdemo/rsdemo_echo__POST",
"chongming/routes/rsdemo/rsdemo_stream__POST",
"chongming/routes/rsdemo/rsdemo_error__GET",
},
"demo": {
"chongming/routes/demo/demo_hello__GET",
"chongming/routes/demo/demo_storage_upload_bytes__POST",
"chongming/routes/demo/demo_storage_download_bytes__GET",
"chongming/routes/demo/demo_storage_presign_upload__POST",
"chongming/routes/demo/demo_storage_presign_download__GET",
},
}[wt]
missing = expect_keys - set(samples1.keys())
report(
"路由描述符 keyexpr 编码正确",
not missing,
f"缺失: {missing or '无'}",
)
if "chongming/routes/rsdemo/rsdemo_hello__GET" in samples1:
d = samples1["chongming/routes/rsdemo/rsdemo_hello__GET"]
report("描述符 schema_version=1", d.get("schema_version") == 1, f"实际: {d.get('schema_version')}")
report("描述符 type=插件名", d.get("type") == wt, f"实际: {d.get('type')}")
report(
"描述符字段完整(subject/method/path/params/response_model)",
d.get("subject") == "rsdemo.hello"
and d.get("method") == "GET"
and d.get("path") == "/rsdemo/hello"
and d.get("params") == ["name: str"]
and isinstance(d.get("response_model"), dict),
)
report(
"defaults 合并(timeout=30.0/auth_required=false)",
d.get("timeout") == 30.0 and d.get("auth_required") is False,
)
sub1.undeclare()
# ========== 2. liveliness 服务发现 ==========
exprs = get_worker_exprs(session, wt)
report(
"liveliness 服务发现副本",
len(exprs) == args.count,
f"实际: {exprs}",
)
worker_expr = exprs[0] if exprs else f"worker/{wt}/missing"
# ========== 3. 普通 RPC:GET 信封 ==========
replies = rpc(session, worker_expr, "rsdemo.hello", {"meta": {"method": "GET"}, "data": {"name": "rust"}})
ok = (
len(replies) == 1
and replies[0].get("greeting") == "Hello, rust!"
and replies[0].get("worker_id") == worker_expr.split("/")[-1]
and isinstance(replies[0].get("timestamp"), (int, float))
)
report("RPC GET(信封分派 + worker_id 注入)", ok, f"实际: {replies}")
# ========== 4. 非信封载荷兼容(method 默认 GET) ==========
replies = rpc(session, worker_expr, "rsdemo.hello", {"name": "legacy"})
report(
"非信封载荷兼容(按 GET 处理)",
len(replies) == 1 and replies[0].get("greeting") == "Hello, legacy!",
f"实际: {replies}",
)
# ========== 5. POST + user_info 透传 ==========
replies = rpc(
session, worker_expr, "rsdemo.echo",
{"meta": {"method": "POST", "user_info": {"username": "alice"}}, "data": {"msg": "hi"}},
)
ok = (
len(replies) == 1
and replies[0].get("echo") == "hi"
and replies[0].get("user_info") == {"username": "alice"}
)
report("RPC POST(user_info 信封透传)", ok, f"实际: {replies}")
# ========== 6. 流式多回复 ==========
replies = rpc(
session, worker_expr, "rsdemo.stream",
{"meta": {"method": "POST"}, "data": {"prompt": "x", "tokens": 5}},
timeout=15.0,
)
report(
"流式多回复(5 块逐块推送)",
len(replies) == 5
and all(r.get("index") == i for i, r in enumerate(replies))
and all("error" not in r for r in replies),
f"块数: {len(replies)}",
)
# 流中异常 → {"error": ...} 收尾块(第 26 块抛错,共 26 块)
replies = rpc(
session, worker_expr, "rsdemo.stream",
{"meta": {"method": "POST"}, "data": {"prompt": "x", "tokens": 60}},
timeout=20.0,
)
ok = (
len(replies) == 26
and all("error" not in r for r in replies[:25])
and isinstance(replies[25].get("error"), str)
and "模拟流中异常" in replies[25]["error"]
)
report("流中异常 → error 收尾块终止", ok, f"块数: {len(replies)}, 末块: {replies[-1] if replies else None}")
# ========== 7. 非流式异常回复格式 ==========
replies = rpc(session, worker_expr, "rsdemo.error", {"meta": {"method": "GET"}, "data": {}})
ok = (
len(replies) == 1
and replies[0].get("result") == 0.0
and replies[0].get("operation") == "error"
and "模拟业务异常" in replies[0].get("error", "")
and replies[0].get("worker") == worker_expr.split("/")[-1]
)
report("非流式异常回复格式(result/operation/error/worker)", ok, f"实际: {replies}")
# ========== 8. SIGTERM 优雅退出 → 描述符摘除(先订阅再 kill) ==========
sub2, samples2 = make_subscriber(session, wt)
os.kill(proc.pid, signal.SIGTERM)
exited = wait_worker_exit(proc, timeout=10)
report("SIGTERM 优雅退出", exited, f"退出码: {proc.returncode if exited else '超时'}")
time.sleep(1.5)
removed = [k for k, v in samples2.items() if v is None]
report(
"退出时 DELETE 摘除全部描述符",
len(removed) == len(expect_keys),
f"摘除: {sorted(removed)}",
)
sub2.undeclare()
# ========== 9. 重启 → 10s 内重发布自愈 ==========
sub3, samples3 = make_subscriber(session, wt)
proc = start_worker(wt, args.count, args.bin)
time.sleep(11.5) # 覆盖一个完整重发布周期
ok = all(k in samples3 and samples3[k] is not None for k in expect_keys)
report(
"重启后周期重发布自愈(≤10s)",
ok,
f"恢复: {sorted(k for k in samples3 if samples3[k] is not None)}",
)
sub3.undeclare()
# 清理
os.kill(proc.pid, signal.SIGTERM)
wait_worker_exit(proc, timeout=10)
session.close()
print(f"\n=== 结果: {PASS} 通过, {FAIL} 失败 ===")
sys.exit(1 if FAIL else 0)
if __name__ == "__main__":
main()