feat(gateway): 全局串行化(并发=1) + 记录入参/出参/耗时日志
并发模型从「每worker并发1 + 多worker并行」改为全局串行:同一时间 只处理1个请求,其余排队;多 worker 仅作热备(主 worker 坏了才用备机)。 接口4(face/features) 走豆包、不占 GPU,不纳入串行。 - pool: asyncio.Semaphore(max_global_concurrency=1) + acquire/release_global_slot (依赖单进程 uvicorn 部署,已在注释中标注) - forward: proxy_request 最外层 acquire 全局槽、try/finally 全路径释放; 入参 multipart 解析挂 request.state;原重试/故障转移逻辑抽到 _dispatch_with_retries - reqlog(新): 标量入参保留;图片(file/base64)存盘转URL,绝不内嵌base64; 出参递归摘要截断(landmarks/长串/大数组) - logging_middleware: RequestLogEntry 加 request_params/response_data, jsonl 全量记录;_load_from_logfile 同步映射防重启丢字段;get_stats recent 暴露 - /gateway-health 暴露 global_max/global_busy/global_waiting - config: dispatch 新增 max_global_concurrency / max_queue_wait_seconds - tests: test_reqlog + test_gateway_serialization(9 用例) 顺带提交此前未提交的网关统计(daily stats 接口)与耗时看板(api_timing_dashboard.html)。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""gateway.pool 全局串行槽单测:验证 max_global_concurrency=1 时同一时间只跑1个请求、
|
||||
排队超时返回 False。
|
||||
|
||||
用 asyncio.run 套同步测试,避免引入 pytest-asyncio 依赖。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from gateway import pool
|
||||
|
||||
_CFG = {"dispatch": {"max_global_concurrency": 1, "max_queue_wait_seconds": 590}}
|
||||
_CFG_FAST_TIMEOUT = {"dispatch": {"max_global_concurrency": 1, "max_queue_wait_seconds": 0.2}}
|
||||
|
||||
|
||||
def test_global_slot_serializes_to_one():
|
||||
pool.init_global_slot(_CFG)
|
||||
|
||||
state = {"active": 0, "peak": 0}
|
||||
|
||||
async def task():
|
||||
got = await pool.acquire_global_slot(_CFG)
|
||||
assert got is True
|
||||
state["active"] += 1
|
||||
state["peak"] = max(state["peak"], state["active"])
|
||||
await asyncio.sleep(0.03)
|
||||
state["active"] -= 1
|
||||
pool.release_global_slot()
|
||||
|
||||
async def run():
|
||||
await asyncio.gather(*(task() for _ in range(4)))
|
||||
|
||||
asyncio.run(run())
|
||||
assert state["peak"] == 1, f"期望峰值并发=1,实际={state['peak']}"
|
||||
assert pool.get_pool_status()["global_busy"] == 0
|
||||
|
||||
|
||||
def test_global_slot_timeout_returns_false():
|
||||
pool.init_global_slot(_CFG_FAST_TIMEOUT)
|
||||
|
||||
async def run():
|
||||
first = await pool.acquire_global_slot(_CFG_FAST_TIMEOUT)
|
||||
assert first is True
|
||||
# 槽已被占用,第二个应在 0.2s 内超时
|
||||
second = await pool.acquire_global_slot(_CFG_FAST_TIMEOUT)
|
||||
assert second is False
|
||||
pool.release_global_slot()
|
||||
|
||||
asyncio.run(run())
|
||||
assert pool.get_pool_status()["global_busy"] == 0
|
||||
|
||||
|
||||
def test_get_pool_status_has_global_fields():
|
||||
pool.init_global_slot(_CFG)
|
||||
status = pool.get_pool_status()
|
||||
assert "global_max" in status
|
||||
assert "global_busy" in status
|
||||
assert "global_waiting" in status
|
||||
assert status["global_max"] == 1
|
||||
@@ -0,0 +1,109 @@
|
||||
"""gateway.reqlog 单测:multipart 入参解析(标量 + 图片存盘转 URL)+ 出参摘要。
|
||||
|
||||
不依赖网络 / GPU,纯函数级。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from gateway.reqlog import extract_form_params, summarize_for_log
|
||||
|
||||
# 1x1 透明 PNG(合法 magic byte)
|
||||
_PNG = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01"
|
||||
b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def _multipart(fields, files, boundary=b"----testbnd"):
|
||||
"""手搓 multipart/form-data body。fields: {name:str}; files: {name:(fname,ctype,bytes)}."""
|
||||
parts = []
|
||||
for name, val in fields.items():
|
||||
parts.append(b"--" + boundary + b"\r\n")
|
||||
parts.append(b'Content-Disposition: form-data; name="' + name.encode() + b'"\r\n\r\n')
|
||||
parts.append(val.encode("utf-8") + b"\r\n")
|
||||
for name, (fname, ctype, data) in files.items():
|
||||
parts.append(b"--" + boundary + b"\r\n")
|
||||
parts.append(b'Content-Disposition: form-data; name="' + name.encode() + b'"; filename="'
|
||||
+ fname.encode() + b'"\r\n')
|
||||
parts.append(b"Content-Type: " + ctype.encode() + b"\r\n\r\n")
|
||||
parts.append(data + b"\r\n")
|
||||
parts.append(b"--" + boundary + b"--\r\n")
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
def test_extract_scalars_and_images(tmp_path):
|
||||
b64 = "data:image/png;base64," + base64.b64encode(_PNG).decode()
|
||||
body = _multipart(
|
||||
fields={"gender": "female", "hair_style": "1,3", "use_mask": "True",
|
||||
"prompt": "填充遮罩区域的头发", "image_base64": b64},
|
||||
files={"image_file": ("x.png", "image/png", _PNG)},
|
||||
)
|
||||
ct = "multipart/form-data; boundary=----testbnd"
|
||||
|
||||
res = extract_form_params(ct, body, str(tmp_path), "https://test.local")
|
||||
|
||||
# 标量原样
|
||||
assert res["gender"] == "female"
|
||||
assert res["hair_style"] == "1,3"
|
||||
assert res["use_mask"] == "True"
|
||||
|
||||
# 图片字段是元信息 dict(不是 base64 文本)
|
||||
assert res["image_file"]["_image"] is True
|
||||
assert res["image_file"]["source"] == "file"
|
||||
assert res["image_file"]["filename"] == "x.png"
|
||||
assert res["image_file"]["url"] is not None
|
||||
assert res["image_base64"]["_image"] is True
|
||||
assert res["image_base64"]["source"] == "base64"
|
||||
assert res["image_base64"]["url"] is not None
|
||||
|
||||
# 图片已落盘
|
||||
saved = [p.name for p in tmp_path.iterdir()]
|
||||
assert any(n.startswith("in_") and n.endswith(".png") for n in saved)
|
||||
# URL 指向正确路径
|
||||
assert res["image_file"]["url"].startswith("https://test.local/static/annotations/in_")
|
||||
|
||||
# 关键:日志里绝不包含 base64 文本
|
||||
blob = json.dumps(res, ensure_ascii=False)
|
||||
assert b64.split(",", 1)[1][:40] not in blob
|
||||
|
||||
|
||||
def test_extract_non_multipart(tmp_path):
|
||||
res = extract_form_params("application/json", b'{"a":1}', str(tmp_path), "https://t.local")
|
||||
assert res == {"_content_type": "application/json", "_body_bytes": 7}
|
||||
|
||||
|
||||
def test_extract_empty_content_type(tmp_path):
|
||||
res = extract_form_params("", b"hello", str(tmp_path), "https://t.local")
|
||||
assert res["_body_bytes"] == 5
|
||||
|
||||
|
||||
def test_summarize_truncates_long_values():
|
||||
big = {
|
||||
"landmarks": [[i, i] for i in range(500)],
|
||||
"name": "x" * 1000,
|
||||
"deep": {"a": {"b": {"c": {"d": {"e": {"f": {"g": 1}}}}}}},
|
||||
}
|
||||
sm = summarize_for_log(big)
|
||||
# 长数组被截断 + 标注剩余数
|
||||
assert isinstance(sm["landmarks"], list)
|
||||
assert sm["landmarks"][-1].startswith("…(+")
|
||||
assert len(sm["landmarks"]) == 6 # 5 项 + 1 个标注
|
||||
# 长字符串被截断
|
||||
assert "…(+" in sm["name"]
|
||||
# 总体积受控
|
||||
assert len(json.dumps(sm, ensure_ascii=False).encode()) <= 8192
|
||||
|
||||
|
||||
def test_summarize_keeps_small_intact():
|
||||
obj = {"code": 0, "data": {"hairline_images": [{"order": 1, "image_middle_url": "https://x/y.png"}]}}
|
||||
assert summarize_for_log(obj) == obj
|
||||
|
||||
|
||||
def test_summarize_byte_cap_stub():
|
||||
# 构造大量小字段触发字节上限
|
||||
obj = {f"k{i}": {f"j{j}": "abcdefghij" for j in range(50)} for i in range(50)}
|
||||
sm = summarize_for_log(obj, max_bytes=2048)
|
||||
assert isinstance(sm, dict) and sm.get("_truncated") is True
|
||||
assert "size_bytes" in sm
|
||||
Reference in New Issue
Block a user