Files
chongming-core/test/test_instance_targeting.py
T

221 lines
9.8 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.
"""
worker 实例定向(把请求固定到某个进程实例)测试
验证 WorkerManager 的实例定向语义(HTTP 的 X-Chongming-Instance 头 / MQTT 的
target_instance 字段 / 程序调用 instance_id 参数最终都走这里):
1. 不指定实例:与以往一致,在全部候选副本里随机挑
2. 指定实例:只在其实例的在线副本里挑,不跨实例
3. 身份来源:副本经 admin status 上报 instance_id,网关按需查询并缓存
(本地注册的 inline 副本直接用网关自身实例,无需查询)
4. 目标实例不在线:报错而不回退到别的实例(定向即严格)
5. 调用失败重试:限定在同一实例内换副本,绝不跨实例
6. 实例内已无其它副本可换:立即失败,不重复打同一个副本
7. 实例维度缓存键:定向调用的缓存与其它实例隔离
运行: .venv/bin/python test/test_instance_targeting.py
"""
import asyncio
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent.parent # chongming-zenoh/
_PROJ = _HERE
for _p in (_PROJ / "gateway" / "src", _PROJ / "utils" / "worker" / "src"):
sys.path.insert(0, str(_p))
from chongming.app.core.worker_manager import WorkerManager # 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)
class _OkPayload:
def __init__(self, data: bytes):
self.payload = data
class _Ok:
def __init__(self, data: bytes):
self.ok = _OkPayload(data)
class _Reply:
"""一次 get 的回复:ok 与 err 互斥(与 Zenoh reply 的形态一致)。"""
def __init__(self, data: bytes = b"{}", err: bytes | None = None):
if err is None:
self.ok = _OkPayload(data)
self.err = None
else:
self.ok = None
self.err = _OkPayload(err)
class FakeSession:
"""记录每次 get 的 key,并按 key 决定回复。
admin 查询(worker/<type>/<id>/__admin__)由 admin_instances 决定 instance_id;
业务调用(worker/<type>/<id>/<subject>)可按 call_errors 注入错误回复。
"""
def __init__(self, admin_instances: dict | None = None,
call_errors: set | None = None):
self.admin_instances = admin_instances or {}
self.call_errors = set(call_errors or ())
self.calls: list = []
def get(self, key, payload=None, **kwargs):
self.calls.append(key)
if key.endswith("/__admin__"):
wid = key.split("/")[2]
iid = self.admin_instances.get(wid)
body = (
f'{{"worker_id":"{wid}","instance_id":"{iid}","status":"running"}}'.encode()
if iid else f'{{"worker_id":"{wid}","status":"running"}}'.encode()
)
return iter([_Reply(body)])
if key in self.call_errors:
return iter([_Reply(err=b'{"error":"boom"}')])
return iter([_Reply(b'{"ok":true}')])
def liveliness(self):
raise RuntimeError("本测试不触及 liveliness")
def close(self):
pass
def new_manager(session, gateway_id="gw-local"):
m = WorkerManager(session=session)
m._gateway_id = gateway_id
return m
async def main():
# ── 1. 不定向:与以往一致,全候选集随机 ─────────────────
sess = FakeSession(admin_instances={"w-a": "edge-01", "w-b": "edge-02"})
m = new_manager(sess)
m._workers_cache["demo"] = ["worker/demo/w-a", "worker/demo/w-b"]
picked = {await m.get_worker("demo") for _ in range(60)}
check("1.1 不定向时可命中全部副本", picked == set(m._workers_cache["demo"]),
f"picked={sorted(picked)}")
check("1.2 不定向不触发实例身份查询(零额外 RPC)",
not [c for c in sess.calls if c.endswith("__admin__")], f"calls={sess.calls}")
check("1.3 不定向调用走随机副本",
await m.call_raw("demo", "ping", b"{}") == b'{"ok":true}')
# ── 2. 定向:只在目标实例内挑副本 ────────────────────────
sess = FakeSession(admin_instances={"w-a": "edge-01", "w-b": "edge-02",
"w-c": "edge-01"})
m = new_manager(sess)
m._workers_cache["demo"] = [
"worker/demo/w-a", "worker/demo/w-b", "worker/demo/w-c",
]
picked = {await m.get_worker("demo", instance_id="edge-01") for _ in range(60)}
check("2.1 定向只命中目标实例的副本",
picked == {"worker/demo/w-a", "worker/demo/w-c"}, f"picked={sorted(picked)}")
# ── 3. 身份查询:按需查询并缓存,实例归属正确 ─────────────
sess = FakeSession(admin_instances={"w-a": "edge-01", "w-b": "edge-02",
"w-c": "edge-01"})
m = new_manager(sess)
m._workers_cache["demo"] = [
"worker/demo/w-a", "worker/demo/w-b", "worker/demo/w-c",
]
index = await m._instance_workers("demo", "edge-01")
check("3.1 实例→副本反向索引正确",
sorted(index) == ["worker/demo/w-a", "worker/demo/w-c"], f"index={sorted(index)}")
admin_calls = [c for c in sess.calls if c.endswith("__admin__")]
check("3.2 每个副本只查询一次身份", len(admin_calls) == 3, f"n={len(admin_calls)}")
await m._refresh_instance_map("demo")
check("3.3 重复刷新复用缓存不重复查询",
len([c for c in sess.calls if c.endswith("__admin__")]) == len(admin_calls))
# ── 3b. inline 本地注册副本:直接用网关实例,零查询 ──────
sess = FakeSession()
m = new_manager(sess, gateway_id="gw-1")
m.register_worker("demo", "worker-0-1-999")
index = await m._instance_workers("demo", "gw-1")
check("3.4 inline 副本归入网关实例", index == ["worker/demo/worker-0-1-999"],
f"index={index}")
check("3.5 inline 副本身份查询零 RPC",
not [c for c in sess.calls if c.endswith("__admin__")], f"calls={sess.calls}")
# ── 4. 定向实例不在线:严格报错,不回退 ──────────────────
sess = FakeSession(admin_instances={"w-a": "edge-01"})
m = new_manager(sess)
m._workers_cache["demo"] = ["worker/demo/w-a"]
check("4.1 目标实例无副本时 get_worker 返回 None",
await m.get_worker("demo", instance_id="edge-99") is None)
try:
await m.call_raw("demo", "ping", b"{}", instance_id="edge-99")
check("4.2 定向到离线实例应报错", False, "未抛错")
except RuntimeError as e:
msg = str(e)
check("4.2 定向到离线实例报错且说明不跨实例回退",
"edge-99" in msg and "不" in msg, f"msg={msg[:90]}")
# ── 5. 重试锚定同实例:绝不跨实例 ────────────────────────
sess = FakeSession(
admin_instances={"w-a": "edge-01", "w-b": "edge-02", "w-c": "edge-01"},
call_errors={"worker/demo/w-b/ping"}, # 只让别实例的副本出错,验证不会被选中
)
m = new_manager(sess)
m._workers_cache["demo"] = [
"worker/demo/w-a", "worker/demo/w-b", "worker/demo/w-c",
]
# 目标实例 edge-01 内的 w-a 失败 -> 应换到同实例的 w-c,不碰 edge-02 的 w-b
sess.call_errors = {"worker/demo/w-a/ping"}
out = await m.call_raw("demo", "ping", b"{}", instance_id="edge-01", retries=2)
business = [c for c in sess.calls if not c.endswith("__admin__")]
check("5.1 重试不跨实例(未打其它实例的副本)",
all("/w-b/" not in c for c in business), f"calls={business}")
check("5.2 同实例内换副本后成功", out == b'{"ok":true}', f"out={out!r}")
# ── 6. 实例内无其它副本可换:立即失败 ────────────────────
sess = FakeSession(admin_instances={"w-a": "edge-01"},
call_errors={"worker/demo/w-a/ping"})
m = new_manager(sess)
m._workers_cache["demo"] = ["worker/demo/w-a", "worker/demo/w-b"]
sess.admin_instances["w-b"] = "edge-02"
raised = None
try:
await m.call_raw("demo", "ping", b"{}", instance_id="edge-01", retries=2)
except RuntimeError as e:
raised = e
check("6.1 实例内无副本可换应报错", raised is not None, f"raised={raised!r}")
n = len([c for c in sess.calls if not c.endswith("__admin__")])
check("6.2 不重复打同一个副本(只调用一次)", n == 1, f"业务调用次数={n}")
check("6.3 错误信息点名实例与原因",
raised is not None and "edge-01" in str(raised), f"msg={str(raised)[:90]}")
# ── 7. 定向调用的缓存按实例隔离 ──────────────────────────
sess = FakeSession(admin_instances={"w-a": "edge-01", "w-b": "edge-02"})
m = new_manager(sess)
m._workers_cache["demo"] = ["worker/demo/w-a", "worker/demo/w-b"]
r1 = await m.call_raw("demo", "state", b"{}", cacheable=True, instance_id="edge-01")
r2 = await m.call_raw("demo", "state", b"{}", cacheable=True, instance_id="edge-02")
keys = [f"{iid}@demo:state:" for iid in ("edge-01", "edge-02")]
check("7.1 不同实例的定向缓存键互不影响",
all(any(k.startswith(p) for k in m._rpc_mem_cache._d) for p in keys),
f"cache_keys={list(m._rpc_mem_cache._d)}")
check("7.2 定向调用结果正常返回", r1 == b'{"ok":true}' and r2 == b'{"ok":true}',
f"r1={r1!r}, r2={r2!r}")
print()
if failures:
print(f"FAILED: {len(failures)} 项 -> {failures}")
return 1
print("全部通过")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))