334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""
|
||
调用统计 + 审计日志(后台任务)测试(依赖隔离:桩掉 chongming 包链、auth、
|
||
chongming_cache 与 worker_manager,加载真实 call_stats 与 route_generator)
|
||
|
||
用 httpx.ASGITransport 验证:
|
||
1. CallStats.record/summary 聚合正确性(计数 / 平均 / 最大延迟 / recent 上限)
|
||
2. spawn/shutdown:后台任务被跟踪、shutdown 后全部取消
|
||
3. 动态端点请求后:响应与改动前一致(字节透传)+ 统计计数正确 + 审计日志出现 AUDIT 行
|
||
4. 错误路径(无可用 worker)-> HTTP 503 同样被记录
|
||
5. 后台任务失败兜底:record_call 内部异常不冒泡,端点响应不受影响
|
||
|
||
运行: uv run python test_call_stats.py
|
||
"""
|
||
import asyncio
|
||
import importlib.util
|
||
import logging
|
||
import sys
|
||
import types
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
import orjson
|
||
from fastapi import FastAPI
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||
|
||
# ---- 依赖隔离:桩掉 chongming 包链 / auth / chongming_cache / worker_manager ----
|
||
_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)
|
||
_auth_stub = types.ModuleType("chongming.app.core.auth")
|
||
_auth_stub.store_user_to_request = lambda x: x # 仅用于 Depends,桩掉鉴权
|
||
_cache_stub = types.ModuleType("chongming_cache")
|
||
_cache_stub.Cache = object
|
||
# worker_manager 桩:route_generator 仅用其类型注解与异常类
|
||
_wm_stub = types.ModuleType("chongming.app.core.worker_manager")
|
||
_wm_stub.WorkerManager = object
|
||
_wm_stub.StreamIdleTimeoutError = type("StreamIdleTimeoutError", (RuntimeError,), {})
|
||
for mod in (_pkg, _app, _core, _logger_stub, _auth_stub, _cache_stub, _wm_stub):
|
||
sys.modules[mod.__name__] = mod
|
||
|
||
# 加载真实 call_stats(route_generator 依赖其 spawn/record_call)
|
||
_cs_path = REPO_ROOT / "src" / "chongming" / "app" / "core" / "call_stats.py"
|
||
_cs_spec = importlib.util.spec_from_file_location("chongming.app.core.call_stats", _cs_path)
|
||
_cs_mod = importlib.util.module_from_spec(_cs_spec)
|
||
sys.modules["chongming.app.core.call_stats"] = _cs_mod
|
||
_cs_spec.loader.exec_module(_cs_mod) # type: ignore[union-attr]
|
||
|
||
# 加载真实 route_generator
|
||
_rg_path = REPO_ROOT / "src" / "chongming" / "app" / "core" / "route_generator.py"
|
||
_rg_spec = importlib.util.spec_from_file_location("chongming.app.core.route_generator", _rg_path)
|
||
_rg_mod = importlib.util.module_from_spec(_rg_spec)
|
||
sys.modules["chongming.app.core.route_generator"] = _rg_mod
|
||
_rg_spec.loader.exec_module(_rg_mod) # type: ignore[union-attr]
|
||
|
||
register_route_group = _rg_mod.register_route_group
|
||
record_call = _cs_mod.record_call
|
||
spawn = _cs_mod.spawn
|
||
|
||
# ---- 审计日志捕获(桩 logger 直接挂到标准库 audit logger) ----
|
||
_audit_records = []
|
||
|
||
|
||
class _AuditHandler(logging.Handler):
|
||
def emit(self, record):
|
||
_audit_records.append(record.getMessage())
|
||
|
||
|
||
_audit_logger = logging.getLogger("audit")
|
||
_audit_logger.setLevel(logging.INFO)
|
||
_audit_logger.addHandler(_AuditHandler())
|
||
_audit_logger.propagate = False
|
||
|
||
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 FakeWorkerManager:
|
||
"""模拟 WorkerManager:call_raw 返回原始字节;error="no_worker" 时抛 RuntimeError"""
|
||
|
||
def __init__(self, reply=b'{"ok": true}', error=None):
|
||
self.reply = reply
|
||
self.error = error
|
||
|
||
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False):
|
||
if self.error == "no_worker":
|
||
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
||
return self.reply
|
||
|
||
async def call_stream(self, worker_type, subject, payload, timeout=300.0, idle_timeout=10.0):
|
||
if self.error == "no_worker":
|
||
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
||
|
||
async def _gen():
|
||
yield orjson.dumps({"chunk": 1})
|
||
yield orjson.dumps({"chunk": 2})
|
||
|
||
return _gen() # 与真实实现一致:async 函数返回异步生成器
|
||
|
||
|
||
ROUTES = [
|
||
# body 端点(validate=true)
|
||
{"subject": "demo.echo", "path": "/echo", "method": "POST",
|
||
"params": ["msg: str"], "timeout": 3.0, "auth_required": False},
|
||
# raw body 端点(validate=false)
|
||
{"subject": "demo.raw", "path": "/raw/echo", "method": "POST",
|
||
"params": ["msg: str"], "validate": False, "timeout": 3.0, "auth_required": False},
|
||
# query 带参端点
|
||
{"subject": "demo.add", "path": "/add", "method": "GET",
|
||
"params": ["a: int", "b: int"], "timeout": 3.0, "auth_required": False},
|
||
# query 无参端点(_simple_endpoint 分支)
|
||
{"subject": "demo.ping", "path": "/ping", "method": "GET",
|
||
"timeout": 3.0, "auth_required": False},
|
||
# 流式端点
|
||
{"subject": "demo.stream", "path": "/stream", "method": "GET",
|
||
"stream": True, "timeout": 3.0, "auth_required": False},
|
||
]
|
||
|
||
|
||
def build_app(fwm):
|
||
app = FastAPI()
|
||
register_route_group(app, fwm, "demo", ROUTES, prefix="/api/v1")
|
||
return app
|
||
|
||
|
||
async def _drain_background():
|
||
"""等待并清理所有后台统计/审计任务(每个异步测试末尾调用)"""
|
||
tasks = list(_cs_mod._tasks)
|
||
if tasks:
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|
||
await _cs_mod.shutdown()
|
||
|
||
|
||
def _reset_stats():
|
||
"""隔离:每个端点测试前重置全局统计实例"""
|
||
_cs_mod._stats = _cs_mod.CallStats()
|
||
|
||
|
||
# ---- 1. CallStats 单元 ----
|
||
|
||
def test_stats_aggregation():
|
||
cs = _cs_mod.CallStats(max_recent=10)
|
||
cs.record({"worker_type": "demo", "subject": "demo.echo", "status": 200, "duration_ms": 10.0})
|
||
cs.record({"worker_type": "demo", "subject": "demo.echo", "status": 200, "duration_ms": 30.0})
|
||
cs.record({"worker_type": "demo", "subject": "demo.echo", "status": 503, "duration_ms": 5.0})
|
||
cs.record({"worker_type": "other", "subject": "other.x", "status": 200, "duration_ms": 2.0})
|
||
s = cs.summary()
|
||
check("total_calls", s["total_calls"] == 4, str(s["total_calls"]))
|
||
by_key = {(c["worker_type"], c["subject"], c["status"]): c["count"] for c in s["calls"]}
|
||
check(
|
||
"per-key 计数",
|
||
by_key == {
|
||
("demo", "demo.echo", 200): 2,
|
||
("demo", "demo.echo", 503): 1,
|
||
("other", "other.x", 200): 1,
|
||
},
|
||
str(by_key),
|
||
)
|
||
lat = {l["worker_type"]: l for l in s["latency"]}
|
||
check("延迟聚合 count", lat["demo"]["count"] == 3, str(lat["demo"]))
|
||
check("延迟聚合 avg", lat["demo"]["avg_ms"] == 15.0, str(lat["demo"]["avg_ms"]))
|
||
check("延迟聚合 max", lat["demo"]["max_ms"] == 30.0, str(lat["demo"]["max_ms"]))
|
||
check("recent 保留", len(s["recent"]) == 4, str(len(s["recent"])))
|
||
|
||
|
||
def test_recent_bounded():
|
||
cs = _cs_mod.CallStats(max_recent=5)
|
||
for _ in range(20):
|
||
cs.record({"worker_type": "demo", "subject": "demo.x", "status": 200, "duration_ms": 1.0})
|
||
s = cs.summary()
|
||
check("recent 环形缓冲上限", len(s["recent"]) == 5, str(len(s["recent"])))
|
||
check("计数不受缓冲影响", s["total_calls"] == 20, str(s["total_calls"]))
|
||
|
||
|
||
# ---- 2. spawn / shutdown ----
|
||
|
||
def test_spawn_shutdown():
|
||
async def run():
|
||
async def never():
|
||
await asyncio.sleep(3600)
|
||
|
||
t = spawn(never())
|
||
check("任务被跟踪", t in _cs_mod._tasks, str(list(_cs_mod._tasks)))
|
||
await _cs_mod.shutdown()
|
||
check("shutdown 后任务取消", t.cancelled(), str(t))
|
||
check("任务集合清空", len(_cs_mod._tasks) == 0, str(len(_cs_mod._tasks)))
|
||
|
||
asyncio.run(run())
|
||
|
||
|
||
# ---- 3. 端点集成:统计 + 审计 ----
|
||
|
||
def test_endpoint_records_stats_and_audit():
|
||
async def run():
|
||
_audit_records.clear()
|
||
_reset_stats()
|
||
fwm = FakeWorkerManager(reply=b'{"ok": true}')
|
||
app = build_app(fwm)
|
||
transport = httpx.ASGITransport(app=app)
|
||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
||
r1 = await ac.post("/api/v1/echo", json={"msg": "hi"})
|
||
r2 = await ac.post("/api/v1/raw/echo", json={"msg": "hi"})
|
||
r3 = await ac.get("/api/v1/add?a=1&b=2")
|
||
r4 = await ac.get("/api/v1/ping")
|
||
r5 = await ac.get("/api/v1/stream")
|
||
await _drain_background()
|
||
|
||
# 响应与改动前一致:worker 原始字节零再序列化
|
||
check("POST 响应字节透传", r1.status_code == 200 and r1.content == b'{"ok": true}', repr(r1.content))
|
||
check("raw POST 响应字节透传", r2.status_code == 200 and r2.content == b'{"ok": true}', repr(r2.content))
|
||
check("GET 响应字节透传", r3.status_code == 200 and r3.content == b'{"ok": true}', repr(r3.content))
|
||
check("无参 GET 响应字节透传", r4.status_code == 200 and r4.content == b'{"ok": true}', repr(r4.content))
|
||
check("流式响应 SSE 帧", r5.status_code == 200 and b"data:" in r5.content and b'"chunk":1' in r5.content, repr(r5.content[:80]))
|
||
|
||
s = _cs_mod.summary()
|
||
check("total_calls == 5", s["total_calls"] == 5, str(s["total_calls"]))
|
||
subjects = {c["subject"] for c in s["calls"]}
|
||
check(
|
||
"5 种调用均记录",
|
||
subjects == {"demo.echo", "demo.raw", "demo.add", "demo.ping", "demo.stream"},
|
||
str(subjects),
|
||
)
|
||
check("全部状态 200", all(c["status"] == 200 for c in s["calls"]), str(s["calls"]))
|
||
|
||
check("审计行数 == 5", len(_audit_records) == 5, str(len(_audit_records)))
|
||
echo_audit = next((a for a in _audit_records if "subject=demo.echo" in a), "")
|
||
check(
|
||
"审计行格式",
|
||
all(k in echo_audit for k in (
|
||
"AUDIT", "ts=", "worker=demo", "method=POST",
|
||
"path=/api/v1/echo", "status=200", "req_bytes=", "resp_bytes=",
|
||
)),
|
||
echo_audit,
|
||
)
|
||
stream_audit = next((a for a in _audit_records if "subject=demo.stream" in a), "")
|
||
check("流式审计已记录", "status=200" in stream_audit and "path=/api/v1/stream" in stream_audit, stream_audit)
|
||
|
||
asyncio.run(run())
|
||
|
||
|
||
def test_error_503_recorded():
|
||
async def run():
|
||
_audit_records.clear()
|
||
_reset_stats()
|
||
fwm = FakeWorkerManager(error="no_worker")
|
||
app = build_app(fwm)
|
||
transport = httpx.ASGITransport(app=app)
|
||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
||
r = await ac.get("/api/v1/ping")
|
||
await _drain_background()
|
||
|
||
check("503 响应", r.status_code == 503, str(r.status_code))
|
||
s = _cs_mod.summary()
|
||
check(
|
||
"503 计数 1",
|
||
any(c["subject"] == "demo.ping" and c["status"] == 503 and c["count"] == 1 for c in s["calls"]),
|
||
str(s["calls"]),
|
||
)
|
||
check(
|
||
"503 审计行",
|
||
any("subject=demo.ping" in a and "status=503" in a for a in _audit_records),
|
||
str(_audit_records),
|
||
)
|
||
|
||
asyncio.run(run())
|
||
|
||
|
||
# ---- 4. 后台任务失败兜底 ----
|
||
|
||
def test_background_failure_isolated():
|
||
async def run():
|
||
_audit_records.clear()
|
||
_reset_stats()
|
||
original = _cs_mod._stats.record
|
||
|
||
def boom(entry):
|
||
raise ValueError("stats broken")
|
||
|
||
_cs_mod._stats.record = boom
|
||
try:
|
||
# 后台执行体自身不冒泡(统计故障仅记 warning,审计仍写入)
|
||
await record_call({
|
||
"ts": 0.0, "worker_type": "demo", "subject": "demo.x", "status": 200,
|
||
"duration_ms": 1.0, "method": "GET", "path": "/x",
|
||
"user": None, "client": None, "req_bytes": 0, "resp_bytes": 0,
|
||
})
|
||
check("record_call 异常被兜底(不冒泡)", True)
|
||
check("审计仍写入", any("subject=demo.x" in a for a in _audit_records), str(_audit_records))
|
||
finally:
|
||
_cs_mod._stats.record = original
|
||
|
||
# 统计故障下端点响应不受影响
|
||
fwm = FakeWorkerManager(reply=b'{"ok": true}')
|
||
app = build_app(fwm)
|
||
_cs_mod._stats.record = boom
|
||
try:
|
||
transport = httpx.ASGITransport(app=app)
|
||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
||
r = await ac.get("/api/v1/ping")
|
||
await _drain_background()
|
||
check("统计故障下响应仍 200", r.status_code == 200 and r.content == b'{"ok": true}', repr(r.content))
|
||
finally:
|
||
_cs_mod._stats.record = original
|
||
|
||
asyncio.run(run())
|
||
|
||
|
||
# ---- 执行 ----
|
||
test_stats_aggregation()
|
||
test_recent_bounded()
|
||
test_endpoint_records_stats_and_audit()
|
||
test_error_503_recorded()
|
||
test_background_failure_isolated()
|
||
test_spawn_shutdown() # 最后执行:shutdown 会取消所有未完成任务
|
||
|
||
print()
|
||
if failures:
|
||
print(f"共 {len(failures)} 项失败: {failures}")
|
||
sys.exit(1)
|
||
print("全部通过")
|