247 lines
7.5 KiB
Python
247 lines
7.5 KiB
Python
"""
|
||
网关 call_stream 单测(依赖隔离:桩掉 chongming 包链与 chongming_cache)
|
||
|
||
直接加载 src/chongming/app/core/worker_manager.py 模块(避开 chongming.app
|
||
整链导入与 config.toml 依赖),验证:
|
||
1. 多块顺序产出(ConsolidationMode.NONE 逐块消费)
|
||
2. Worker 错误收尾块 {"error": ...}:转发后终止流
|
||
3. zenoh 错误回复(reply.err):转发错误块后终止流
|
||
4. 空闲超时:抛 StreamIdleTimeoutError
|
||
5. 无可用 worker:抛 RuntimeError
|
||
6. 兼容性回归:call()(非流式路径)仍只取第一个回复
|
||
|
||
运行: uv run python test_stream_gateway.py
|
||
"""
|
||
import asyncio
|
||
import importlib.util
|
||
import logging
|
||
import sys
|
||
import time
|
||
import types
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import orjson
|
||
import zenoh
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent
|
||
|
||
# ---- 依赖隔离:桩掉网关包链(chongming.app 顶层导入 fastapi/uvicorn/config)----
|
||
_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 # WorkerManager 仅作类型标注使用
|
||
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
|
||
|
||
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 FakeReply:
|
||
"""模拟 zenoh.Reply:ok / err 二选一"""
|
||
|
||
def __init__(self, payload=None, error=None):
|
||
if error is None:
|
||
self.ok = SimpleNamespace(payload=payload)
|
||
self.err = None
|
||
else:
|
||
self.ok = None
|
||
self.err = SimpleNamespace(payload=error)
|
||
|
||
|
||
class FakeSession:
|
||
"""模拟 zenoh.Session.get():返回 reply 迭代器(可迭代对象)"""
|
||
|
||
def __init__(self, reply_source):
|
||
self._reply_source = reply_source
|
||
self.get_calls = []
|
||
|
||
def get(self, key_expr, payload, consolidation=None, timeout=None):
|
||
self.get_calls.append((str(key_expr), payload, consolidation, timeout))
|
||
return iter(self._reply_source)
|
||
|
||
|
||
def make_manager(replies, worker_id="worker-0"):
|
||
wm = WorkerManager(FakeSession(replies))
|
||
wm.register_worker("example", worker_id)
|
||
return wm
|
||
|
||
|
||
def test_idle_timeout():
|
||
# 模拟阻塞式 reply 迭代器:每个 __next__ 阻塞 0.3s(首个块 0.3s 后才到达),
|
||
# 而空闲超时仅 0.05s -> 应抛 StreamIdleTimeoutError
|
||
class BlockingReplyIterator:
|
||
def __init__(self):
|
||
self._done = False
|
||
|
||
def __iter__(self):
|
||
return self
|
||
|
||
def __next__(self):
|
||
time.sleep(0.3)
|
||
if self._done:
|
||
raise StopIteration
|
||
self._done = True
|
||
return FakeReply(payload=orjson.dumps({"token": "slow"}))
|
||
|
||
wm = WorkerManager(FakeSession(BlockingReplyIterator()))
|
||
|
||
|
||
def collect_stream(wm, idle_timeout=1.0):
|
||
"""消费 call_stream 全部块并返回解码后的列表"""
|
||
|
||
async def _run():
|
||
gen = await wm.call_stream(
|
||
"example", "stream_chat", {"meta": {"method": "POST"}, "data": {}},
|
||
timeout=5.0, idle_timeout=idle_timeout,
|
||
)
|
||
return [orjson.loads(b) async for b in gen]
|
||
|
||
return asyncio.run(_run())
|
||
|
||
|
||
def test_multiple_chunks():
|
||
replies = [
|
||
FakeReply(payload=orjson.dumps({"token": "a"})),
|
||
FakeReply(payload=orjson.dumps({"token": "b"})),
|
||
]
|
||
wm = make_manager(replies)
|
||
chunks = collect_stream(wm)
|
||
check("多块顺序产出", chunks == [{"token": "a"}, {"token": "b"}])
|
||
check(
|
||
"使用 ConsolidationMode.NONE",
|
||
wm.session.get_calls[0][2] is zenoh.ConsolidationMode.NONE,
|
||
)
|
||
check("key_expr 正确", wm.session.get_calls[0][0] == "worker/example/worker-0/stream_chat")
|
||
|
||
|
||
def test_error_chunk_terminates():
|
||
replies = [
|
||
FakeReply(payload=orjson.dumps({"token": "a"})),
|
||
FakeReply(payload=orjson.dumps({"error": "boom"})),
|
||
FakeReply(payload=orjson.dumps({"token": "c"})), # 不应被消费
|
||
]
|
||
wm = make_manager(replies)
|
||
chunks = collect_stream(wm)
|
||
check("错误块转发后终止", chunks == [{"token": "a"}, {"error": "boom"}])
|
||
|
||
|
||
def test_err_reply_terminates():
|
||
replies = [
|
||
FakeReply(payload=orjson.dumps({"token": "a"})),
|
||
FakeReply(error="oops"),
|
||
]
|
||
wm = make_manager(replies)
|
||
chunks = collect_stream(wm)
|
||
check(
|
||
"reply.err 转发错误块后终止",
|
||
chunks == [{"token": "a"}, {"error": "Worker worker/example/worker-0 返回错误: oops"}],
|
||
)
|
||
|
||
|
||
def test_idle_timeout():
|
||
# 模拟阻塞式 reply 迭代器:每个 __next__ 阻塞 0.3s(首个块 0.3s 后才到达),
|
||
# 而空闲超时仅 0.05s -> 应抛 StreamIdleTimeoutError
|
||
class BlockingReplyIterator:
|
||
def __init__(self):
|
||
self._done = False
|
||
|
||
def __iter__(self):
|
||
return self
|
||
|
||
def __next__(self):
|
||
time.sleep(0.3)
|
||
if self._done:
|
||
raise StopIteration
|
||
self._done = True
|
||
return FakeReply(payload=orjson.dumps({"token": "slow"}))
|
||
|
||
wm = WorkerManager(FakeSession(BlockingReplyIterator()))
|
||
wm.register_worker("example", "worker-0")
|
||
|
||
async def _run():
|
||
gen = await wm.call_stream(
|
||
"example", "stream_chat", {}, timeout=5.0, idle_timeout=0.05
|
||
)
|
||
async for _ in gen:
|
||
pass
|
||
|
||
try:
|
||
asyncio.run(_run())
|
||
check("空闲超时抛 StreamIdleTimeoutError", False)
|
||
except StreamIdleTimeoutError as e:
|
||
check("空闲超时抛 StreamIdleTimeoutError", True, str(e))
|
||
|
||
|
||
def test_no_worker():
|
||
wm = WorkerManager(FakeSession([])) # 未注册任何 worker
|
||
|
||
async def _run():
|
||
gen = await wm.call_stream("missing", "x", {})
|
||
async for _ in gen:
|
||
pass
|
||
|
||
try:
|
||
asyncio.run(_run())
|
||
check("无 worker 抛 RuntimeError", False)
|
||
except RuntimeError as e:
|
||
check("无 worker 抛 RuntimeError", True, str(e)[:30])
|
||
|
||
|
||
def test_call_compat_first_reply_only():
|
||
"""兼容性回归:非流式 call() 仍只取第一个回复,行为不变"""
|
||
replies = [
|
||
FakeReply(payload=orjson.dumps({"result": 1})),
|
||
FakeReply(payload=orjson.dumps({"result": 2})),
|
||
]
|
||
wm = make_manager(replies)
|
||
|
||
async def _run():
|
||
return await wm.call(
|
||
"example", "stream_chat", {"meta": {"method": "POST"}, "data": {}},
|
||
timeout=5.0,
|
||
)
|
||
|
||
result = asyncio.run(_run())
|
||
check("call() 仍只取第一个回复(非流式兼容)", result == {"result": 1})
|
||
|
||
|
||
def run():
|
||
test_multiple_chunks()
|
||
test_error_chunk_terminates()
|
||
test_err_reply_terminates()
|
||
test_idle_timeout()
|
||
test_no_worker()
|
||
test_call_compat_first_reply_only()
|
||
|
||
print(f"\n结果: {len(failures)} 个失败")
|
||
return 1 if failures else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(run())
|