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()
|