189 lines
6.2 KiB
Python
189 lines
6.2 KiB
Python
"""Python 与 Rust 进程插件同 type 混布对拍(多语言演进规划 §3.4 场景 5)
|
||
|
||
前置条件:
|
||
1. 本地 Zenoh Router 已运行(scripts/zenoh_router.py)
|
||
2. Rust 参考插件已编译;venv 已安装(worker_serve console script)
|
||
|
||
验证内容:
|
||
1. Python 与 Rust 对同一 TOML 配置发布的描述符内容 hash 一致
|
||
(网关据此幂等去重,内容不一致会告警冲突)
|
||
2. 同一 type 下两个实现副本均被 liveliness 发现(负载均衡透明)
|
||
3. 两个实现的 RPC 均返回等价结果(worker_id 区分副本)
|
||
4. SIGTERM 后各自摘除(不互相干扰)
|
||
|
||
用法(项目根目录):
|
||
.venv/bin/python utils/worker-rs/scripts/verify_mixed.py
|
||
"""
|
||
import hashlib
|
||
import orjson
|
||
import os
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
import zenoh
|
||
|
||
PASS = 0
|
||
FAIL = 0
|
||
|
||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||
RUST_BIN = os.path.abspath(
|
||
os.path.join(os.path.dirname(__file__), "..", "target", "debug", "examples", "demo")
|
||
)
|
||
PY_WORKER = os.path.join(ROOT, ".venv", "bin", "worker_serve")
|
||
|
||
|
||
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(cmd, worker_type):
|
||
proc = subprocess.Popen(
|
||
cmd + ["--type", worker_type, "--count", "1"],
|
||
cwd=ROOT,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
)
|
||
deadline = time.monotonic() + 25
|
||
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}")
|
||
line = proc.stdout.readline() if proc.stdout else b""
|
||
if "等待退出信号" in line.decode("utf-8", "ignore"):
|
||
return proc
|
||
raise RuntimeError("Worker 25s 未就绪")
|
||
|
||
|
||
def rpc(session, worker_expr, subject, payload, timeout=10.0):
|
||
full_key = f"{worker_expr}/{subject}"
|
||
replies = session.get(
|
||
full_key,
|
||
payload=orjson.dumps(payload),
|
||
consolidation=zenoh.ConsolidationMode.NONE,
|
||
timeout=timeout,
|
||
)
|
||
return [orjson.loads(bytes(r.ok.payload)) for r in replies if r.ok]
|
||
|
||
|
||
def main():
|
||
session = zenoh.open(zenoh.Config.from_file("config/zenoh_config.json5"))
|
||
print("=== Python + Rust 同 type(demo) 混布对拍 ===")
|
||
|
||
# 先建订阅(zenoh 不重放历史值)
|
||
samples = {}
|
||
|
||
def cb(sample):
|
||
if not sample.payload:
|
||
return # 空载荷样本(如 DELETE 预发布期),忽略
|
||
key = str(sample.key_expr)
|
||
samples[key] = orjson.loads(bytes(sample.payload))
|
||
|
||
sub = session.declare_subscriber("chongming/routes/demo/**", cb)
|
||
|
||
# 同时启动 Python 与 Rust 两个实现
|
||
py_proc = start([PY_WORKER], "demo")
|
||
rs_proc = start([RUST_BIN], "demo")
|
||
time.sleep(2.0)
|
||
|
||
# 1. 描述符内容一致性:同一 route_id 多次发布(Python/Rust 各一次)内容 hash 必须一致
|
||
expect = {
|
||
"demo_hello__GET",
|
||
"demo_storage_upload_bytes__POST",
|
||
"demo_storage_download_bytes__GET",
|
||
"demo_storage_presign_upload__POST",
|
||
"demo_storage_presign_download__GET",
|
||
}
|
||
seen = {k: [] for k in expect}
|
||
for key, desc in samples.items():
|
||
rid = key.split("/")[-1]
|
||
if rid in seen:
|
||
seen[rid].append(orjson.dumps(desc, option=orjson.OPT_SORT_KEYS).decode())
|
||
|
||
all_same = True
|
||
for rid, versions in seen.items():
|
||
if len(versions) < 1:
|
||
continue
|
||
hashes = {hashlib.sha1(v.encode("utf-8")).hexdigest()[:8] for v in versions}
|
||
if len(hashes) > 1:
|
||
all_same = False
|
||
print(f" ⚠ {rid} 描述符内容不一致: {hashes}")
|
||
report(
|
||
"Python/Rust 描述符逐字段一致(内容 hash 唯一,无冲突告警)",
|
||
all_same and all(seen.values()),
|
||
f"各 route_id 版本数: { {k: len(v) for k, v in seen.items()} }",
|
||
)
|
||
|
||
# 2. liveliness:同 type 两个实现副本均被发现
|
||
exprs = [
|
||
str(r.ok.key_expr)
|
||
for r in session.liveliness().get("worker/demo/*")
|
||
if r.ok
|
||
]
|
||
report(
|
||
"liveliness 发现 Python + Rust 副本(共 2 个)",
|
||
len(exprs) == 2,
|
||
f"实际: {exprs}",
|
||
)
|
||
if len(exprs) < 2:
|
||
print("混布副本不足,跳过 RPC 对拍")
|
||
else:
|
||
# 区分 Python 副本与 Rust 副本:Rust worker_id 格式同为 worker-0-<pid>,
|
||
# 无法从 key 区分实现,但可分别对两个副本调用并验证都响应
|
||
for expr in exprs:
|
||
replies = rpc(
|
||
session, expr, "demo.hello",
|
||
{"meta": {"method": "GET"}, "data": {"name": "mixed"}},
|
||
)
|
||
ok = (
|
||
len(replies) == 1
|
||
and replies[0].get("greeting") == "Hello, mixed!"
|
||
and replies[0].get("worker_id") == expr.split("/")[-1]
|
||
)
|
||
report(f"RPC 对拍 {expr}", ok, f"实际: {replies}")
|
||
|
||
# storage 端点:两实现均应返回错误(Python 未配置 storage 降级;Rust 未实现降级)
|
||
replies = rpc(
|
||
session, exprs[0], "demo.storage.upload_bytes",
|
||
{"meta": {"method": "POST"}, "data": {"object_key": "a.txt", "data_b64": "eA=="}},
|
||
)
|
||
report(
|
||
"storage 端点两实现均降级为错误",
|
||
len(replies) == 1
|
||
and replies[0].get("operation") == "error",
|
||
f"实际: {replies}",
|
||
)
|
||
|
||
# 3. SIGTERM 顺序退出:互不干扰
|
||
os.kill(rs_proc.pid, signal.SIGTERM)
|
||
rs_proc.wait(timeout=10)
|
||
time.sleep(1.5)
|
||
remain = [
|
||
str(r.ok.key_expr)
|
||
for r in session.liveliness().get("worker/demo/*")
|
||
if r.ok
|
||
]
|
||
report(
|
||
"Rust 退出后 Python 副本不受影响",
|
||
len(remain) == 1,
|
||
f"剩余: {remain}",
|
||
)
|
||
os.kill(py_proc.pid, signal.SIGTERM)
|
||
py_proc.wait(timeout=10)
|
||
sub.undeclare()
|
||
session.close()
|
||
|
||
print(f"\n=== 结果: {PASS} 通过, {FAIL} 失败 ===")
|
||
sys.exit(1 if FAIL else 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|