Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1fed6f4b5 | ||
|
|
c87b7baa64 | ||
|
|
c92b34a55f | ||
|
|
2df1e7266c | ||
|
|
3a359fa485 |
@@ -42,3 +42,6 @@ config
|
|||||||
lib
|
lib
|
||||||
auto.crt
|
auto.crt
|
||||||
auto.key
|
auto.key
|
||||||
|
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
| 特性 | 说明 |
|
| 特性 | 说明 |
|
||||||
| --------------------- | -------------------------------------------------------------------- |
|
| --------------------- | -------------------------------------------------------------------- |
|
||||||
| **FastAPI + Uvicorn** | 高性能异步 Web 框架,自动生成 OpenAPI 文档 |
|
| **FastAPI + Uvicorn** | 高性能异步 Web 框架,自动生成 OpenAPI 文档 |
|
||||||
| **Zenoh 服务发现** | 基于 liveliness 的 Worker 注册与 RPC 调用,负载均衡 + 超时重试 |
|
| **Zenoh 服务发现** | 基于 liveliness 的 Worker 注册与 RPC 调用,负载均衡 + 实例定向 + 超时重试 |
|
||||||
| **MQTT 集成** | amqtt Broker(内嵌或外部),MQTT ↔ Worker 双向桥接 |
|
| **MQTT 集成** | amqtt Broker(内嵌或外部),MQTT ↔ Worker 双向桥接 |
|
||||||
| **动态路由生成** | 扫描 `config/workers/*/*.toml` 自动注册 HTTP 路由 |
|
| **动态路由生成** | 扫描 `config/workers/*/*.toml` 自动注册 HTTP 路由 |
|
||||||
| **Worker 框架** | `@rpc_method` 装饰器 + `Plugin` 基类统一生命周期 + Pydantic 模型绑定 |
|
| **Worker 框架** | `@rpc_method` 装饰器 + `Plugin` 基类统一生命周期 + Pydantic 模型绑定 |
|
||||||
@@ -194,7 +194,7 @@ graph TB
|
|||||||
MQTT[MQTT 客户端] -->|chongming/+/+| BRIDGE[MQTT Bridge<br/>纯客户端模式]
|
MQTT[MQTT 客户端] -->|chongming/+/+| BRIDGE[MQTT Bridge<br/>纯客户端模式]
|
||||||
|
|
||||||
REG[RouteRegistry<br/>订阅 chongming/routes/**] -->|动态挂载/摘除 HTTP 路由| API
|
REG[RouteRegistry<br/>订阅 chongming/routes/**] -->|动态挂载/摘除 HTTP 路由| API
|
||||||
API --> WM[WorkerManager<br/>随机负载均衡 + 超时重试]
|
API --> WM[WorkerManager<br/>随机负载均衡 / 实例定向 + 超时重试]
|
||||||
BRIDGE --> WM
|
BRIDGE --> WM
|
||||||
|
|
||||||
subgraph GW[网关进程 chongming]
|
subgraph GW[网关进程 chongming]
|
||||||
@@ -328,6 +328,30 @@ worker/<type>/<worker_id>/__admin__
|
|||||||
- **REST / UI**:`POST /admin/workers/<type>/scale_instance`(body `{instance_id, count}`)下发;`GET /admin/workers/<type>/instances` 供前端按实例分组展示当前副本数。前端用「调整数量」(全局对称)和「逐实例精细扩缩」表格(实例 id / 模式 / 当前副本数 / 目标 + 应用)两个入口。
|
- **REST / UI**:`POST /admin/workers/<type>/scale_instance`(body `{instance_id, count}`)下发;`GET /admin/workers/<type>/instances` 供前端按实例分组展示当前副本数。前端用「调整数量」(全局对称)和「逐实例精细扩缩」表格(实例 id / 模式 / 当前副本数 / 目标 + 应用)两个入口。
|
||||||
- **协调前提**:与外部广播缩放一致,需共享 `[cache]`,且外部进程用稳定的 `--instance` / `CHONGMING_WORKER_INSTANCE`(缺省 `{hostname}:{pid}` 虽唯一但重启会变,跨重启持续命中请显式指定)。
|
- **协调前提**:与外部广播缩放一致,需共享 `[cache]`,且外部进程用稳定的 `--instance` / `CHONGMING_WORKER_INSTANCE`(缺省 `{hostname}:{pid}` 虽唯一但重启会变,跨重启持续命中请显式指定)。
|
||||||
|
|
||||||
|
### 实例定向调用(把请求固定到某个进程实例)
|
||||||
|
|
||||||
|
默认情况下,同 type 的多个副本(含 inline 与 external 进程)逐请求**随机负载均衡**(`random.choice`),这是绝大多数无状态插件想要的。若某插件持有**只存在于某个进程本地**的状态(本地 SQLite/文件、串口或其他设备连接),需要请求固定打到那个进程上,可用**实例定向**:
|
||||||
|
|
||||||
|
- **指定方式(三种入口,语义一致)**:
|
||||||
|
- HTTP:请求头 `X-Chongming-Instance: <实例id>`
|
||||||
|
- MQTT:信封载荷顶层加控制字段 `target_instance`(网关读后**摘除**,业务 side 看不到该字段)
|
||||||
|
- 程序内调用:`worker_manager.call(...)` / `call_raw(...)` / `call_stream(...)` 的 `instance_id=` 参数(`utils/request` 的独立 Client 不支持定向)
|
||||||
|
- **实例 id 取值**:worker 侧按 `--instance` → `CHONGMING_WORKER_INSTANCE` → `CHONGMING_GATEWAY_ID` → `{hostname}:{pid}` 的顺序解析(`current_instance_id()`);网关侧定向寻址 inline 副本时用网关自身实例 id(`CHONGMING_GATEWAY_ID`,缺省 hostname)。两侧缺省值在容器环境(K8s 给网关进程注入 `CHONGMING_GATEWAY_ID`)下**自动一致**,无需额外配置;裸机同一台跑多个网关时请显式指定,否则默认实例 id 里的 pid 不同会导致对不上。人可读的取值列表见 `GET /api/v1/admin/workers/<type>/instances`。
|
||||||
|
- **粒度**:定向到**进程实例**;若该实例内有多个副本,请求仍在这几个副本之间随机均衡(即“实例固定、副本仍均衡”)。
|
||||||
|
- **实例离线时严格报错**:返回 HTTP 503 并附已识别实例列表,**不会静默回退到其它实例**——回退会让粘滞状态的插件跑在错误副本上,产生难查的数据错乱。MQTT 同理(响应主题收到 `{"error": ...}`)。
|
||||||
|
- **重试不跨实例**:调用失败重试只在同实例内换副本;实例内已无可换副本时立即失败,不重复打同一个副本。流式端点本身不重试,实例离线即失败。
|
||||||
|
- **缓存按实例隔离**:定向且 `cacheable=true` 时缓存键带实例前缀,避免取到其它实例的结果。
|
||||||
|
- **身份来源与开销**:副本的实例身份取自其 admin status 回复的 `instance_id`(Python/Rust 两种运行时都会返回),网关**按需查询并缓存**——未使用定向功能时零额外开销,不定向的请求路径与行为完全不变;首次定向到某实例时会对该类型的新副本各发一次轻量 status 查询(每个副本只查一次,inline 副本无需查询)。副本重启后 id 变化,会重新查询一次。
|
||||||
|
- **版本要求**:worker 端需运行带 `instance_id` 上报的运行时(当前 Python/Rust SDK 均满足);读不出身份的副本会被排除在定向之外(不定向的调用不受影响)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 等价于把这次请求固定到实例 edge-01 的那个进程
|
||||||
|
curl -H 'X-Chongming-Instance: edge-01' http://127.0.0.1:8000/api/v1/demo/state
|
||||||
|
|
||||||
|
# MQTT:载荷顶层带 target_instance(网关转发前会摘掉它)
|
||||||
|
# topic: chongming/demo/state payload: {"meta":{}, "data":{}, "target_instance":"edge-01"}
|
||||||
|
```
|
||||||
|
|
||||||
### inline worker 宿主级领导选举(gunicorn 多 worker 兼容)
|
### inline worker 宿主级领导选举(gunicorn 多 worker 兼容)
|
||||||
|
|
||||||
inline worker 的生命周期交给**基于分布式锁的宿主级领导选举**(取代“每 gunicorn worker 各自拉起”),key `inline_owner:<gateway_id>` 按网关隔离:
|
inline worker 的生命周期交给**基于分布式锁的宿主级领导选举**(取代“每 gunicorn worker 各自拉起”),key `inline_owner:<gateway_id>` 按网关隔离:
|
||||||
|
|||||||
+21
@@ -123,6 +123,27 @@ base = [
|
|||||||
"fastapi.middleware.cors",
|
"fastapi.middleware.cors",
|
||||||
"fastapi.middleware.gzip",
|
"fastapi.middleware.gzip",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
|
"granian", # 备选服务器(APP_SERVER=granian,见 public/main.py),含 Rust 内核
|
||||||
|
"granian._compat",
|
||||||
|
"granian._futures",
|
||||||
|
"granian._granian",
|
||||||
|
"granian._imports",
|
||||||
|
"granian._internal",
|
||||||
|
"granian._loops",
|
||||||
|
"granian._signals",
|
||||||
|
"granian._types",
|
||||||
|
"granian.asgi",
|
||||||
|
"granian.constants",
|
||||||
|
"granian.errors",
|
||||||
|
"granian.http",
|
||||||
|
"granian.log",
|
||||||
|
"granian.net",
|
||||||
|
"granian.rsgi",
|
||||||
|
"granian.server",
|
||||||
|
"granian.server.common",
|
||||||
|
"granian.server.mp",
|
||||||
|
"granian.server.mt",
|
||||||
|
"granian.wsgi",
|
||||||
"chongming_cache",
|
"chongming_cache",
|
||||||
"chongming_cache.backends",
|
"chongming_cache.backends",
|
||||||
"chongming_cache.backends.sqlite_",
|
"chongming_cache.backends.sqlite_",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
#
|
#
|
||||||
# 可选:/etc/chongming.env 中写 APP_WORKERS=<N> 覆盖 worker 数(默认取
|
# 可选:/etc/chongming.env 中写 APP_WORKERS=<N> 覆盖 worker 数(默认取
|
||||||
# config.toml [production.server].workers,打包版通常为 4)。
|
# config.toml [production.server].workers,打包版通常为 4)。
|
||||||
|
# 可选:APP_SERVER=granian|uvicorn|gunicorn 切换 HTTP 服务器(默认 Linux 下
|
||||||
|
# gunicorn,Windows 下 uvicorn);三者的启动参数同取自 [production.server]。
|
||||||
|
# 可选:CHONGMING_RPC_LOCAL=0 关闭 inline 插件的进程内直调快路径(默认开启;
|
||||||
|
# 仅影响本进程托管的 inline 副本,external 插件始终走 Zenoh)。
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Chongming Zenoh Gateway (FastAPI + Zenoh + MQTT)
|
Description=Chongming Zenoh Gateway (FastAPI + Zenoh + MQTT)
|
||||||
@@ -29,7 +33,8 @@ StartLimitBurst=5
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|
||||||
# 打包应用入口:Nuitka 编译产物,前台运行 gunicorn master(Linux 分支)
|
# 打包应用入口:Nuitka 编译产物,前台运行所选服务器 master(默认 Linux 分支为
|
||||||
|
# gunicorn,可用 APP_SERVER 切到 granian/uvicorn)
|
||||||
ExecStart=/data/chongming/chongming
|
ExecStart=/data/chongming/chongming
|
||||||
# 所有相对路径(config.toml / config/ / resources/*.mbank / static.svfs /
|
# 所有相对路径(config.toml / config/ / resources/*.mbank / static.svfs /
|
||||||
# data/ / logs/ / uploads/)均相对工作目录解析,必须指向安装目录
|
# data/ / logs/ / uploads/)均相对工作目录解析,必须指向安装目录
|
||||||
@@ -48,7 +53,7 @@ Environment=PYTHONUNBUFFERED=1
|
|||||||
# 私有动态库,再补一条 Environment=LD_LIBRARY_PATH=/opt/sophon/sophon-ffmpeg-latest/lib
|
# 私有动态库,再补一条 Environment=LD_LIBRARY_PATH=/opt/sophon/sophon-ffmpeg-latest/lib
|
||||||
Environment=PATH=/opt/sophon/sophon-ffmpeg-latest/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
Environment=PATH=/opt/sophon/sophon-ffmpeg-latest/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
# 优雅停机:SIGTERM 由 gunicorn 排空 worker,等在途请求/告警上传完成
|
# 优雅停机:SIGTERM 由服务器 master 排空 worker,等在途请求/告警上传完成
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
KillMode=control-group
|
KillMode=control-group
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ debug = false
|
|||||||
env = "production"
|
env = "production"
|
||||||
|
|
||||||
[production.server]
|
[production.server]
|
||||||
|
# 启动参数对各服务器通用(由 main.py 的转换函数映射,无需按服务器改配置);
|
||||||
|
# 服务器/副本数可用环境变量覆盖:APP_SERVER=granian|uvicorn|gunicorn、APP_WORKERS=<N>
|
||||||
access_log = false
|
access_log = false
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 8000
|
port = 8000
|
||||||
|
|||||||
+127
-44
@@ -3,23 +3,126 @@
|
|||||||
"""
|
"""
|
||||||
FastAPI chongming 示例
|
FastAPI chongming 示例
|
||||||
主入口文件,适配 chongming 打包系统
|
主入口文件,适配 chongming 打包系统
|
||||||
|
|
||||||
|
服务器选择(APP_SERVER 环境变量,默认见 DEFAULT_SERVER):
|
||||||
|
- gunicorn:现状保留(Linux 默认,uvloop worker + preload 多进程)
|
||||||
|
- uvicorn:Windows 默认(gunicorn 依赖 fork,Windows 不可用)
|
||||||
|
- granian:Rust HTTP 内核,多进程
|
||||||
|
|
||||||
|
启动参数保持 config.toml `[<env>.server]` 现状(host / port / workers /
|
||||||
|
reload / access_log / timeout_keep_alive),由 `_to_uvicorn`、`_to_granian`、
|
||||||
|
`_to_gunicorn_args` 转换为对应服务器的参数。
|
||||||
"""
|
"""
|
||||||
|
import asyncio # noqa: F401
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import platform
|
import platform
|
||||||
from utils.launch import launch
|
import sys
|
||||||
from multiprocessing import freeze_support
|
from multiprocessing import freeze_support
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import server # noqa: F401 # 预加载:master 内完成模块银行装载,fork 出的 worker 共享
|
||||||
|
import uvloop # noqa: F401 # 显式导入保证打包时收集 uvloop(uvicorn/granian loop 依赖)
|
||||||
|
from utils.launch import launch
|
||||||
from uvicorn.workers import UvicornWorker
|
from uvicorn.workers import UvicornWorker
|
||||||
import server
|
|
||||||
import uvloop
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
use_shell = platform.system() == "Windows"
|
use_shell = platform.system() == "Windows"
|
||||||
|
|
||||||
|
# 未设置 APP_SERVER 时的平台默认(保持现状)
|
||||||
|
DEFAULT_SERVER = "uvicorn" if use_shell else "gunicorn"
|
||||||
|
SUPPORTED_SERVERS = ("gunicorn", "uvicorn", "granian")
|
||||||
|
|
||||||
|
# 热重载(reload=true)监听的源码目录,避免 granian 默认扫 cwd 时被 logs/ 写日志触发
|
||||||
|
RELOAD_PATHS = [Path("src/chongming"), Path("src/plugins")]
|
||||||
|
|
||||||
|
|
||||||
class UvloopUvicornWorker(UvicornWorker):
|
class UvloopUvicornWorker(UvicornWorker):
|
||||||
CONFIG_KWARGS = {"loop": "uvloop"}
|
CONFIG_KWARGS = {"loop": "uvloop"}
|
||||||
|
|
||||||
|
|
||||||
|
def _server_params(app_config: dict) -> dict:
|
||||||
|
"""把 `[<env>.server]` 的现状参数整理为各服务器共用的启动参数。
|
||||||
|
|
||||||
|
只做缺省兜底与运行环境覆盖(打包环境强制禁 reload、APP_WORKERS 覆盖副本数),
|
||||||
|
键名保持 config.toml 现状,改名交给下面的转换函数。
|
||||||
|
"""
|
||||||
|
server_config = app_config.get("server", {})
|
||||||
|
# 打包环境强制禁用重载
|
||||||
|
is_frozen = getattr(sys, "frozen", False)
|
||||||
|
# 支持通过环境变量 APP_WORKERS 覆盖 worker 数量(方便嵌入式设备调优)
|
||||||
|
default_workers = server_config.get("workers", 4)
|
||||||
|
workers = int(os.environ.get("APP_WORKERS", str(default_workers)))
|
||||||
|
return {
|
||||||
|
"host": server_config.get("host", "0.0.0.0"),
|
||||||
|
"port": server_config.get("port", 8000),
|
||||||
|
"workers": workers,
|
||||||
|
"reload": False if is_frozen else server_config.get("reload", False),
|
||||||
|
"access_log": server_config.get("access_log", False),
|
||||||
|
"timeout_keep_alive": server_config.get("timeout_keep_alive", 5),
|
||||||
|
"log_level": "debug" if app_config.get("debug", False) else "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_uvicorn(params: dict) -> dict:
|
||||||
|
"""共用参数 → uvicorn.run 关键字参数(键名一致)。"""
|
||||||
|
return {
|
||||||
|
"host": params["host"],
|
||||||
|
"port": params["port"],
|
||||||
|
"reload": params["reload"],
|
||||||
|
"workers": params["workers"],
|
||||||
|
"log_level": params["log_level"],
|
||||||
|
"access_log": params["access_log"],
|
||||||
|
"timeout_keep_alive": params["timeout_keep_alive"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_granian(params: dict) -> dict:
|
||||||
|
"""共用参数 → granian.Granian 关键字参数。
|
||||||
|
|
||||||
|
仅两处改名:host → address、access_log → log_access;
|
||||||
|
timeout_keep_alive 在 granian 无对应项(HTTP/1 keep-alive 只有开关、无空闲
|
||||||
|
超时配置),忽略。
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"address": params["host"],
|
||||||
|
"port": params["port"],
|
||||||
|
"interface": "asgi",
|
||||||
|
"workers": params["workers"],
|
||||||
|
"reload": params["reload"],
|
||||||
|
"log_level": params["log_level"],
|
||||||
|
"log_access": params["access_log"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_gunicorn_args(params: dict) -> list:
|
||||||
|
"""共用参数 → gunicorn 命令行参数(保持现状:uvloop worker + preload)。"""
|
||||||
|
return [
|
||||||
|
"gunicorn",
|
||||||
|
"server:app",
|
||||||
|
"-k",
|
||||||
|
"__main__.UvloopUvicornWorker",
|
||||||
|
"--preload",
|
||||||
|
"-w",
|
||||||
|
str(params["workers"]),
|
||||||
|
"-b",
|
||||||
|
f"{params['host']}:{params['port']}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _select_server(logger) -> str:
|
||||||
|
"""按 APP_SERVER 环境变量选择服务器,非法值/平台不兼容时回退默认。"""
|
||||||
|
server_kind = (os.environ.get("APP_SERVER") or DEFAULT_SERVER).strip().lower()
|
||||||
|
if server_kind not in SUPPORTED_SERVERS:
|
||||||
|
logger.warning(
|
||||||
|
f"未知 APP_SERVER={server_kind!r}(可选: {', '.join(SUPPORTED_SERVERS)}),"
|
||||||
|
f"回退 {DEFAULT_SERVER}"
|
||||||
|
)
|
||||||
|
server_kind = DEFAULT_SERVER
|
||||||
|
if server_kind == "gunicorn" and use_shell:
|
||||||
|
logger.warning("Windows 不支持 gunicorn(依赖 fork),回退 uvicorn")
|
||||||
|
server_kind = "uvicorn"
|
||||||
|
return server_kind
|
||||||
|
|
||||||
|
|
||||||
def app_run(app_config: dict, default_config: dict):
|
def app_run(app_config: dict, default_config: dict):
|
||||||
"""
|
"""
|
||||||
应用运行函数 - 适配模块银行环境
|
应用运行函数 - 适配模块银行环境
|
||||||
@@ -35,18 +138,8 @@ def app_run(app_config: dict, default_config: dict):
|
|||||||
|
|
||||||
logger = get_logger("app")
|
logger = get_logger("app")
|
||||||
|
|
||||||
# 获取服务器配置
|
params = _server_params(app_config)
|
||||||
server_config = app_config.get("server", {})
|
server_kind = _select_server(logger)
|
||||||
host = server_config.get("host", "0.0.0.0")
|
|
||||||
port = server_config.get("port", 8000)
|
|
||||||
timeout_keep_alive = server_config.get("timeout_keep_alive", 5)
|
|
||||||
|
|
||||||
# 打包环境强制禁用重载
|
|
||||||
is_frozen = getattr(sys, "frozen", False)
|
|
||||||
reload = False if is_frozen else server_config.get("reload", False)
|
|
||||||
# 支持通过环境变量 APP_WORKERS 覆盖 worker 数量(方便嵌入式设备调优)
|
|
||||||
default_workers = server_config.get("workers", 4)
|
|
||||||
workers = int(os.environ.get("APP_WORKERS", str(default_workers)))
|
|
||||||
|
|
||||||
# 显示启动信息
|
# 显示启动信息
|
||||||
env = app_config.get("env", "development")
|
env = app_config.get("env", "development")
|
||||||
@@ -54,41 +147,31 @@ def app_run(app_config: dict, default_config: dict):
|
|||||||
logger.info(f"🚀 启动 {default_config.get('app.name', 'FastAPI 应用')}")
|
logger.info(f"🚀 启动 {default_config.get('app.name', 'FastAPI 应用')}")
|
||||||
logger.info(f"📦 版本: {default_config.get('app.version', '1.0.0')}")
|
logger.info(f"📦 版本: {default_config.get('app.version', '1.0.0')}")
|
||||||
logger.info(f"🌍 环境: {env}")
|
logger.info(f"🌍 环境: {env}")
|
||||||
logger.info(f"📍 地址: http://{host}:{port}")
|
logger.info(f"🖥️ 服务器: {server_kind}")
|
||||||
logger.info(f"📚 文档: http://{host}:{port}/docs")
|
logger.info(f"📍 地址: http://{params['host']}:{params['port']}")
|
||||||
|
logger.info(f"📚 文档: http://{params['host']}:{params['port']}/docs")
|
||||||
logger.info(f"🔧 调试: {'启用' if app_config.get('debug', False) else '禁用'}")
|
logger.info(f"🔧 调试: {'启用' if app_config.get('debug', False) else '禁用'}")
|
||||||
logger.info(f"🔄 重载: {'启用' if reload else '禁用'}")
|
logger.info(f"🔄 重载: {'启用' if params['reload'] else '禁用'}")
|
||||||
logger.info(f"👥 Workers: {workers}")
|
logger.info(f"👥 Workers: {params['workers']}")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|
||||||
# 根据操作系统选择不同的服务器启动方式
|
# 根据所选服务器启动(默认平台分支保持现状)
|
||||||
if use_shell:
|
if server_kind == "granian":
|
||||||
|
import granian
|
||||||
|
|
||||||
|
granian.Granian(
|
||||||
|
target="server:app",
|
||||||
|
**_to_granian(params),
|
||||||
|
reload_paths=RELOAD_PATHS,
|
||||||
|
).serve()
|
||||||
|
elif server_kind == "uvicorn":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run("server:app", **_to_uvicorn(params))
|
||||||
"server:app",
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
reload=reload,
|
|
||||||
workers=workers,
|
|
||||||
log_level="debug" if app_config.get("debug", False) else "info",
|
|
||||||
access_log=False,
|
|
||||||
timeout_keep_alive=timeout_keep_alive,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
from gunicorn.app.wsgiapp import run
|
from gunicorn.app.wsgiapp import run
|
||||||
|
|
||||||
sys.argv = [
|
sys.argv = _to_gunicorn_args(params)
|
||||||
"gunicorn",
|
|
||||||
"server:app",
|
|
||||||
"-k",
|
|
||||||
"__main__.UvloopUvicornWorker",
|
|
||||||
"--preload",
|
|
||||||
"-w",
|
|
||||||
str(workers),
|
|
||||||
"-b",
|
|
||||||
f"{host}:{port}",
|
|
||||||
]
|
|
||||||
run()
|
run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"eclipse-zenoh",
|
"eclipse-zenoh",
|
||||||
"fastapi[uvicorn]",
|
"fastapi[uvicorn]",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
|
"granian[reload,uvloop]>=2.8.4",
|
||||||
"module-bank",
|
"module-bank",
|
||||||
"openai",
|
"openai",
|
||||||
"python-multipart",
|
"python-multipart",
|
||||||
@@ -36,7 +37,14 @@ requires-python = ">=3.12"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["chongming-cli", "nuitka", "pyarmor", "pyinstaller", "ruff"]
|
dev = [
|
||||||
|
"chongming-cli",
|
||||||
|
"greenlet>=3.5.6",
|
||||||
|
"nuitka",
|
||||||
|
"pyarmor",
|
||||||
|
"pyinstaller",
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/chongming"]
|
packages = ["src/chongming"]
|
||||||
@@ -49,5 +57,6 @@ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
gunicorn = "chongming:gunicorn_serve"
|
gunicorn = "chongming:gunicorn_serve"
|
||||||
|
granian = "chongming:granian_serve"
|
||||||
serve = "chongming:serve"
|
serve = "chongming:serve"
|
||||||
worker_serve = "chongming_worker.process:worker_serve"
|
worker_serve = "chongming_worker.process:worker_serve"
|
||||||
|
|||||||
@@ -1,7 +1,66 @@
|
|||||||
|
"""chongming 网关启动入口(uvicorn / granian / gunicorn)。
|
||||||
|
|
||||||
|
config.toml 中 `[<env>.server]` 的启动参数保持现状(host / port / workers /
|
||||||
|
reload / access_log / timeout_keep_alive),由 `_to_uvicorn`、`_to_granian` 在
|
||||||
|
启动时转换为对应服务器的关键字参数,切换服务器不必改配置格式。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from chongming_config import load_config
|
from chongming_config import Server, load_config
|
||||||
|
|
||||||
from .app.core.logger import get_logger
|
from .app.core.logger import get_logger
|
||||||
|
|
||||||
|
# 开发期热重载(reload=true)监听的源码目录(granian 用;uvicorn 走其默认扫描)
|
||||||
|
RELOAD_PATHS = [Path("src/chongming"), Path("src/plugins")]
|
||||||
|
|
||||||
|
|
||||||
|
def _server_config_view(server_config: Server) -> Mapping[str, Any]:
|
||||||
|
"""[<env>.server] 的宽松视图:实际 TOML 可能带 Server 之外的键
|
||||||
|
(如 timeout_keep_alive,chongming_config.Server 尚未声明)。"""
|
||||||
|
return server_config
|
||||||
|
|
||||||
|
|
||||||
|
def _to_uvicorn(server_config: Server) -> dict:
|
||||||
|
"""把 [<env>.server] 的现状参数转为 uvicorn.run 的关键字参数。
|
||||||
|
|
||||||
|
uvicorn 的参数名与配置项一致,这里只做缺省兜底。
|
||||||
|
"""
|
||||||
|
cfg = _server_config_view(server_config)
|
||||||
|
converted: dict = {
|
||||||
|
"host": cfg.get("host", "0.0.0.0"),
|
||||||
|
"port": cfg.get("port", 8000),
|
||||||
|
"reload": cfg.get("reload", False),
|
||||||
|
}
|
||||||
|
for key in ("workers", "access_log", "timeout_keep_alive"):
|
||||||
|
if key in cfg:
|
||||||
|
converted[key] = cfg[key]
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
|
def _to_granian(server_config: Server) -> dict:
|
||||||
|
"""把 [<env>.server] 的现状参数转为 granian.Granian 的关键字参数。
|
||||||
|
|
||||||
|
仅两处改名:host → address、access_log → log_access;
|
||||||
|
timeout_keep_alive 在 granian 无对应项(HTTP/1 keep-alive 只有开关、无空闲
|
||||||
|
超时配置),忽略。
|
||||||
|
"""
|
||||||
|
cfg = _server_config_view(server_config)
|
||||||
|
converted: dict = {
|
||||||
|
"address": cfg.get("host", "0.0.0.0"),
|
||||||
|
"port": cfg.get("port", 8000),
|
||||||
|
"interface": "asgi",
|
||||||
|
}
|
||||||
|
for key in ("reload", "workers"):
|
||||||
|
if key in cfg:
|
||||||
|
converted[key] = cfg[key]
|
||||||
|
if "access_log" in cfg:
|
||||||
|
converted["log_access"] = cfg["access_log"]
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
def serve():
|
def serve():
|
||||||
"""
|
"""
|
||||||
@@ -20,7 +79,34 @@ def serve():
|
|||||||
logger = get_logger("app")
|
logger = get_logger("app")
|
||||||
logger.info(f"启动服务: {server_config}")
|
logger.info(f"启动服务: {server_config}")
|
||||||
logger.info(f"启动日志: {log_config}")
|
logger.info(f"启动日志: {log_config}")
|
||||||
uvicorn.run("chongming.app:app", **server_config)
|
uvicorn.run("chongming.app:app", **_to_uvicorn(server_config))
|
||||||
|
|
||||||
|
|
||||||
|
def granian_serve():
|
||||||
|
"""
|
||||||
|
使用 granian 启动服务
|
||||||
|
|
||||||
|
从配置文件中加载服务器配置和日志配置,然后使用 granian 启动应用服务。
|
||||||
|
该函数适用于开发环境或单进程部署场景。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None: 该函数不返回值,会阻塞运行直到服务停止
|
||||||
|
"""
|
||||||
|
# 延迟导入:未选用 granian 的部署环境不必安装它
|
||||||
|
import granian
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
env = config["default"]["env"]
|
||||||
|
server_config = config[env]["server"]
|
||||||
|
log_config = config[env]["logging"]
|
||||||
|
logger = get_logger("app")
|
||||||
|
logger.info(f"启动服务: {server_config}")
|
||||||
|
logger.info(f"启动日志: {log_config}")
|
||||||
|
granian.Granian(
|
||||||
|
target="chongming.app:app",
|
||||||
|
**_to_granian(server_config),
|
||||||
|
reload_paths=RELOAD_PATHS,
|
||||||
|
).serve()
|
||||||
|
|
||||||
|
|
||||||
def gunicorn_serve():
|
def gunicorn_serve():
|
||||||
@@ -36,6 +122,7 @@ def gunicorn_serve():
|
|||||||
None: 该函数不返回值,会阻塞运行直到服务停止
|
None: 该函数不返回值,会阻塞运行直到服务停止
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from gunicorn.app.wsgiapp import run
|
from gunicorn.app.wsgiapp import run
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ from fastapi.middleware.gzip import GZipMiddleware
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from chongming_config import get_config
|
from chongming_config import get_config
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
from .core.logger import get_logger
|
from .core.logger import get_logger
|
||||||
# from .core.static_files import SVFSStaticFiles
|
|
||||||
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles
|
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles
|
||||||
from .core import lifespan_setup, call_stats
|
from .core import lifespan_setup, call_stats
|
||||||
from .routes import storage_router, stats_router, worker_admin_router, auth_router
|
from .routes import stats_router, worker_admin_router, auth_router
|
||||||
|
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@@ -199,41 +199,67 @@ def _should_log_access(status_code: int, sample_rate: float = ACCESS_SAMPLE_RATE
|
|||||||
return next(_access_log_counter) % denom == 0
|
return next(_access_log_counter) % denom == 0
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
def _log_access(scope: Scope, status_code: int, start: float) -> None:
|
||||||
async def access_log_middleware(request: Request, call_next):
|
"""按采样规则写一条访问日志(成功按 access_sample_rate,错误 >=400 全量)"""
|
||||||
|
if not _should_log_access(status_code):
|
||||||
|
return
|
||||||
|
client = scope.get("client")
|
||||||
|
logger.info(
|
||||||
|
"%s %s status=%s duration=%.2fms client=%s",
|
||||||
|
scope.get("method", "-"), scope.get("path", "-"), status_code,
|
||||||
|
(time.perf_counter() - start) * 1000, client[0] if client else "-",
|
||||||
|
extra={"status_code": status_code},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLogMiddleware:
|
||||||
"""
|
"""
|
||||||
HTTP访问日志中间件,记录每个请求的详细信息。
|
HTTP访问日志中间件(纯 ASGI 实现),记录每个请求的详细信息。
|
||||||
|
|
||||||
记录的信息包括:
|
记录的信息包括:
|
||||||
- 请求方法和路径
|
- 请求方法和路径
|
||||||
- 响应状态码
|
- 响应状态码
|
||||||
- 请求处理时长(毫秒)
|
- 请求处理时长(毫秒,按慢路径:从进入中间件到响应头产出)
|
||||||
- 客户端IP地址
|
- 客户端IP地址
|
||||||
|
|
||||||
高吞吐优化:
|
高吞吐优化:
|
||||||
- 采样:成功请求按 access_sample_rate 记录,错误(>=400)全量
|
- 采样:成功请求按 access_sample_rate 记录,错误(>=400)全量
|
||||||
- 异步:% 参数化消息 + 队列日志(QueueHandler),格式化与文件 I/O
|
- 异步:% 参数化消息 + 队列日志(QueueHandler),格式化与文件 I/O
|
||||||
在后台监听线程执行,主线程仅入队
|
在后台监听线程执行,主线程仅入队
|
||||||
|
- 不用 BaseHTTPMiddleware(@app.middleware("http")):它会为每请求建 anyio
|
||||||
参数:
|
task group 并把响应体经内存流过一手,压测实测高并发下吃掉 30~50% 吞吐;
|
||||||
request: FastAPI请求对象
|
这里只包一层 send 截获 response.start,无缓冲、无额外任务
|
||||||
call_next: 下一个中间件或路由处理函数
|
|
||||||
|
|
||||||
返回:
|
|
||||||
Response: 处理后的响应对象
|
|
||||||
"""
|
"""
|
||||||
start = time.perf_counter()
|
|
||||||
response = await call_next(request)
|
|
||||||
duration = (time.perf_counter() - start) * 1000
|
|
||||||
client = request.client.host if request.client else "-"
|
|
||||||
|
|
||||||
if _should_log_access(response.status_code):
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
logger.info(
|
self.app = app
|
||||||
"%s %s status=%s duration=%.2fms client=%s",
|
|
||||||
request.method, request.url.path, response.status_code, duration, client,
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
extra={"status_code": response.status_code},
|
if scope["type"] != "http":
|
||||||
)
|
await self.app(scope, receive, send)
|
||||||
return response
|
return
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
logged = False
|
||||||
|
|
||||||
|
async def send_wrapper(message: Message) -> None:
|
||||||
|
nonlocal logged
|
||||||
|
# 响应头产出即记录(流式响应因此只计到首字节,不计整段流)
|
||||||
|
if message["type"] == "http.response.start" and not logged:
|
||||||
|
logged = True
|
||||||
|
_log_access(scope, message["status"], start)
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, receive, send_wrapper)
|
||||||
|
except Exception:
|
||||||
|
# 未产出响应就异常(外层 500):补一条全量错误日志后原样抛出
|
||||||
|
if not logged:
|
||||||
|
_log_access(scope, 500, start)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(AccessLogMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", summary="根路径")
|
@app.get("/", summary="根路径")
|
||||||
@@ -311,7 +337,6 @@ if vfs_db_path := config[env].get("file_system", {}).get("path", None):
|
|||||||
static_files_handler = SVFSStaticFiles(vfs_db_path=vfs_db_path)
|
static_files_handler = SVFSStaticFiles(vfs_db_path=vfs_db_path)
|
||||||
app.mount("/static", static_files_handler, name="static")
|
app.mount("/static", static_files_handler, name="static")
|
||||||
|
|
||||||
app.include_router(storage_router)
|
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
app.include_router(worker_admin_router)
|
app.include_router(worker_admin_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
@@ -21,6 +23,45 @@ _jwt_cache = Cache.from_config(get_config()["cache"])
|
|||||||
# 安全性由命中时的 exp 复核保证,TTL 不承担过期判断
|
# 安全性由命中时的 exp 复核保证,TTL 不承担过期判断
|
||||||
_JWT_CACHE_TTL = 60
|
_JWT_CACHE_TTL = 60
|
||||||
|
|
||||||
|
# ── 一级:进程内 JWT 结果缓存(LRU + TTL)────────────────────────────
|
||||||
|
# 鉴权依赖是同步依赖(def),每次请求都要走一遍共享缓存——backend=redis 时
|
||||||
|
# 即"每请求一次同步网络往返 + FastAPI 线程池 hop"。同一 token 在 TTL 内被
|
||||||
|
# 反复使用是常态,故在进程内加一层 L1:命中直接返回,不碰网络。
|
||||||
|
# 语义与 L2 完全一致:key 含密钥/算法指纹;命中仍复核 exp(过期=拒绝而非放行);
|
||||||
|
# 条目按 TTL 惰性清理并限长。CHONGMING_JWT_L1=0 可关闭(便于 A/B 与排障)。
|
||||||
|
_JWT_L1_ENABLED = os.environ.get("CHONGMING_JWT_L1", "1") != "0"
|
||||||
|
_JWT_L1_MAX = 1024
|
||||||
|
# 存 orjson 字节而非 dict:命中时反序列化出新副本,保证"调用方修改返回值
|
||||||
|
# 不污染缓存"这一 L2 既有契约(L2 同样是 JSON 往返后返回新对象)
|
||||||
|
_jwt_l1: "OrderedDict[str, tuple[bytes, float, float]]" = OrderedDict() # key → (payload_json, exp, 写入时刻)
|
||||||
|
|
||||||
|
|
||||||
|
def _l1_get(key: str):
|
||||||
|
"""命中返回 (payload_json: bytes, exp);条目超过共享缓存 TTL(运行时可调,便于测试/运维)视为过期"""
|
||||||
|
entry = _jwt_l1.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
_payload_json, _exp, ts = entry
|
||||||
|
if time.time() - ts >= _JWT_CACHE_TTL:
|
||||||
|
_jwt_l1.pop(key, None)
|
||||||
|
return None
|
||||||
|
_jwt_l1.move_to_end(key)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _l1_set(key: str, payload_json: bytes, exp: float) -> None:
|
||||||
|
_jwt_l1[key] = (payload_json, exp, time.time())
|
||||||
|
_jwt_l1.move_to_end(key)
|
||||||
|
while len(_jwt_l1) > _JWT_L1_MAX:
|
||||||
|
_jwt_l1.popitem(last=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _l1_sweep(now: float) -> None:
|
||||||
|
"""惰性清理:超 TTL 的整条丢弃(token 永不重复时防堆积),只在表较大时调用"""
|
||||||
|
stale = [k for k, v in _jwt_l1.items() if now - v[2] >= _JWT_CACHE_TTL]
|
||||||
|
for k in stale:
|
||||||
|
_jwt_l1.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
def _cache_key(token: str, secret: str, algorithm: str) -> str:
|
def _cache_key(token: str, secret: str, algorithm: str) -> str:
|
||||||
"""缓存 key:token 内容 + 密钥/算法指纹,不同密钥/算法不串用"""
|
"""缓存 key:token 内容 + 密钥/算法指纹,不同密钥/算法不串用"""
|
||||||
@@ -28,20 +69,33 @@ def _cache_key(token: str, secret: str, algorithm: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _cached_decode(token: str, secret: str, algorithm: str) -> tuple:
|
def _cached_decode(token: str, secret: str, algorithm: str) -> tuple:
|
||||||
"""JWT 解密结果缓存((payload, exp)),共享缓存避免高频请求重复密码学运算。
|
"""JWT 解密结果缓存((payload, exp)):一级进程内 LRU,二级共享缓存。
|
||||||
|
|
||||||
- 命中且未过期 -> 直接返回,跳过 RSA/ECDSA 验证与 JSON 解析
|
- 一级命中且未过期 -> 直接返回(零网络、零密码学);过期 -> 直接拒绝
|
||||||
- 命中但 exp 已过 -> 直接拒绝,不重解密(条目保留至 TTL 到期,
|
- 一级未命中 -> 查二级共享缓存;命中且未过期 -> 回填一级后返回
|
||||||
过期 token 的重复请求零密码学开销;缓存不延长 token 有效期)
|
- 二级也未命中 -> 完整解码(RSA/ECDSA 验证)后写二级 + 一级(TTL=60s/30s)
|
||||||
- 未命中 -> 完整解码后写入共享缓存(TTL=60s)
|
|
||||||
- decode 失败(无效 token)不缓存,每次都完整验证后拒绝
|
- decode 失败(无效 token)不缓存,每次都完整验证后拒绝
|
||||||
"""
|
"""
|
||||||
key = _cache_key(token, secret, algorithm)
|
key = _cache_key(token, secret, algorithm)
|
||||||
|
now = time.time()
|
||||||
|
if _JWT_L1_ENABLED:
|
||||||
|
hit = _l1_get(key)
|
||||||
|
if hit is not None:
|
||||||
|
payload_json, exp, _ts = hit
|
||||||
|
if not exp or exp >= now:
|
||||||
|
# 反序列化出新副本:调用方修改不影响缓存条目(与 L2 契约一致)
|
||||||
|
return orjson.loads(payload_json), exp
|
||||||
|
raise ValueError("Token 已过期")
|
||||||
|
if len(_jwt_l1) > _JWT_L1_MAX // 2: # 仅大表时惰性清理,避免常态 O(n)
|
||||||
|
_l1_sweep(now)
|
||||||
|
|
||||||
entry = _jwt_cache.get(key)
|
entry = _jwt_cache.get(key)
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
payload = entry["payload"]
|
payload = entry["payload"]
|
||||||
exp = entry.get("exp", 0)
|
exp = entry.get("exp", 0)
|
||||||
if not exp or exp >= time.time():
|
if not exp or exp >= time.time():
|
||||||
|
if _JWT_L1_ENABLED:
|
||||||
|
_l1_set(key, orjson.dumps(payload), exp)
|
||||||
return payload, exp
|
return payload, exp
|
||||||
raise ValueError("Token 已过期")
|
raise ValueError("Token 已过期")
|
||||||
jwt = JWT(secret, algorithm)
|
jwt = JWT(secret, algorithm)
|
||||||
@@ -49,6 +103,8 @@ def _cached_decode(token: str, secret: str, algorithm: str) -> tuple:
|
|||||||
payload = orjson.loads(payload_str.encode("utf-8"))
|
payload = orjson.loads(payload_str.encode("utf-8"))
|
||||||
exp = payload.get("exp", 0)
|
exp = payload.get("exp", 0)
|
||||||
_jwt_cache.set(key, {"payload": payload, "exp": exp}, expire=_JWT_CACHE_TTL)
|
_jwt_cache.set(key, {"payload": payload, "exp": exp}, expire=_JWT_CACHE_TTL)
|
||||||
|
if _JWT_L1_ENABLED:
|
||||||
|
_l1_set(key, orjson.dumps(payload), exp)
|
||||||
return payload, exp
|
return payload, exp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,15 @@ class MQTTBridge:
|
|||||||
except orjson.JSONDecodeError:
|
except orjson.JSONDecodeError:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 实例定向控制字段:与 HTTP 的 X-Chongming-Instance 头同义,指定请求发往的
|
||||||
|
# worker 进程实例。必须在下面归一化之前取出——归一化只保留 meta/data 两键,
|
||||||
|
# 之后控制字段就不在载荷里了。
|
||||||
|
target_instance: Optional[str] = None
|
||||||
|
if isinstance(pl, dict):
|
||||||
|
ti = pl.pop("target_instance", None)
|
||||||
|
if isinstance(ti, str) and ti.strip():
|
||||||
|
target_instance = ti.strip()
|
||||||
|
|
||||||
# 信封协议: {"meta": {...}, "data": {...}}
|
# 信封协议: {"meta": {...}, "data": {...}}
|
||||||
# 兼容非信封载荷(如第三方设备原始报文): 整包视为业务数据、meta 置空,
|
# 兼容非信封载荷(如第三方设备原始报文): 整包视为业务数据、meta 置空,
|
||||||
# 与 worker 运行时 runtime.py 的容错语义一致(method 默认 GET)——
|
# 与 worker 运行时 runtime.py 的容错语义一致(method 默认 GET)——
|
||||||
@@ -194,7 +203,7 @@ class MQTTBridge:
|
|||||||
for ct, compiled in self._compiled:
|
for ct, compiled in self._compiled:
|
||||||
matched = compiled.match(t)
|
matched = compiled.match(t)
|
||||||
if matched:
|
if matched:
|
||||||
await self._route_custom(ct, matched, pl, t)
|
await self._route_custom(ct, matched, pl, t, target_instance)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 2. 内置接口 chongming/{worker}/{operation}
|
# 2. 内置接口 chongming/{worker}/{operation}
|
||||||
@@ -204,7 +213,7 @@ class MQTTBridge:
|
|||||||
wt, op = parts[1], parts[2]
|
wt, op = parts[1], parts[2]
|
||||||
subj = f"{wt}.{op}"
|
subj = f"{wt}.{op}"
|
||||||
rt = f"{MQTT_TOPIC_PREFIX}/{wt}/{op}/response"
|
rt = f"{MQTT_TOPIC_PREFIX}/{wt}/{op}/response"
|
||||||
await self._route(wt, subj, pl, rt)
|
await self._route(wt, subj, pl, rt, target_instance)
|
||||||
|
|
||||||
def _fill(self, template: str, matched: re.Match) -> str:
|
def _fill(self, template: str, matched: re.Match) -> str:
|
||||||
"""用匹配到的通配符值填充模板中的 {1} {2}... 占位符"""
|
"""用匹配到的通配符值填充模板中的 {1} {2}... 占位符"""
|
||||||
@@ -214,7 +223,8 @@ class MQTTBridge:
|
|||||||
template = template.replace("{" + str(i) + "}", g)
|
template = template.replace("{" + str(i) + "}", g)
|
||||||
return template
|
return template
|
||||||
|
|
||||||
async def _route_custom(self, ct: dict, matched: re.Match, pl: dict, topic: str):
|
async def _route_custom(self, ct: dict, matched: re.Match, pl: dict, topic: str,
|
||||||
|
target_instance: Optional[str] = None):
|
||||||
worker = ct.get("worker", "")
|
worker = ct.get("worker", "")
|
||||||
# subject 默认 "<worker>.<topic 模板最后一段>",与内置 <插件>.<操作> 约定一致
|
# subject 默认 "<worker>.<topic 模板最后一段>",与内置 <插件>.<操作> 约定一致
|
||||||
subject_tpl = ct.get("subject")
|
subject_tpl = ct.get("subject")
|
||||||
@@ -225,15 +235,22 @@ class MQTTBridge:
|
|||||||
# response_topic 默认 "<实际匹配主题>/response"
|
# response_topic 默认 "<实际匹配主题>/response"
|
||||||
rt_tpl = ct.get("response_topic")
|
rt_tpl = ct.get("response_topic")
|
||||||
rt = self._fill(rt_tpl, matched) if rt_tpl else f"{topic}/response"
|
rt = self._fill(rt_tpl, matched) if rt_tpl else f"{topic}/response"
|
||||||
await self._route(worker, subject, pl, rt)
|
await self._route(worker, subject, pl, rt, target_instance)
|
||||||
|
|
||||||
async def _route(self, wt: str, subj: str, pl: dict, rt: str):
|
async def _route(self, wt: str, subj: str, pl: dict, rt: str,
|
||||||
|
target_instance: Optional[str] = None):
|
||||||
|
"""把一次 MQTT 请求转成 worker RPC。
|
||||||
|
|
||||||
|
target_instance 为实例定向目标(来自载荷顶层的 target_instance 控制字段,
|
||||||
|
已在 _handle 归一化前摘除);缺省时跨实例随机均衡,行为与以往一致。
|
||||||
|
"""
|
||||||
logger.info(f"MQTT -> {subj}")
|
logger.info(f"MQTT -> {subj}")
|
||||||
meta = pl.get("meta") or {}
|
meta = pl.get("meta") or {}
|
||||||
try:
|
try:
|
||||||
r = await self._wm.call(worker_type=wt, subject=subj, payload=pl,
|
r = await self._wm.call(worker_type=wt, subject=subj, payload=pl,
|
||||||
timeout=float(meta.get("timeout", 30)),
|
timeout=float(meta.get("timeout", 30)),
|
||||||
cacheable=bool(meta.get("cacheable", False)))
|
cacheable=bool(meta.get("cacheable", False)),
|
||||||
|
instance_id=target_instance or None)
|
||||||
if r is not None:
|
if r is not None:
|
||||||
await self._pub(rt, r)
|
await self._pub(rt, r)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ from .call_stats import spawn, record_call
|
|||||||
|
|
||||||
logger = get_logger("route_generator")
|
logger = get_logger("route_generator")
|
||||||
|
|
||||||
|
# 实例定向请求头:给定后该请求固定发往指定 worker 进程实例(同一实例下的多个副本
|
||||||
|
# 之间仍随机均衡),不参与跨实例的随机负载均衡。取值 = 实例身份(外部进程启动参数
|
||||||
|
# --instance / CHONGMING_WORKER_INSTANCE;inline 副本为网关自身实例 id)。
|
||||||
|
# 未携带该头时行为与以往完全一致(全类型随机均衡)。
|
||||||
|
INSTANCE_HEADER = "X-Chongming-Instance"
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_from_request(request: Request) -> Optional[str]:
|
||||||
|
"""从请求头读取定向实例 id;空串/未携带视为不定向。"""
|
||||||
|
value = request.headers.get(INSTANCE_HEADER)
|
||||||
|
if value:
|
||||||
|
value = value.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
# 简单类型映射
|
# 简单类型映射
|
||||||
TYPE_MAP: Dict[str, Any] = {
|
TYPE_MAP: Dict[str, Any] = {
|
||||||
"float": float,
|
"float": float,
|
||||||
@@ -348,6 +363,7 @@ def _make_body_endpoint(worker_manager: WorkerManager, rc: RouteConfig, worker_t
|
|||||||
payload=payload_bytes,
|
payload=payload_bytes,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
cacheable=cacheable,
|
cacheable=cacheable,
|
||||||
|
instance_id=_instance_from_request(request),
|
||||||
)
|
)
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
_spawn_call_record(
|
_spawn_call_record(
|
||||||
@@ -409,6 +425,7 @@ def _make_raw_body_endpoint(worker_manager: WorkerManager, rc: RouteConfig, work
|
|||||||
payload=payload,
|
payload=payload,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
cacheable=cacheable,
|
cacheable=cacheable,
|
||||||
|
instance_id=_instance_from_request(request),
|
||||||
)
|
)
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
_spawn_call_record(
|
_spawn_call_record(
|
||||||
@@ -472,6 +489,7 @@ def _make_query_endpoint(worker_manager: WorkerManager, rc: RouteConfig, worker_
|
|||||||
payload=payload_bytes,
|
payload=payload_bytes,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
cacheable=cacheable,
|
cacheable=cacheable,
|
||||||
|
instance_id=_instance_from_request(request),
|
||||||
)
|
)
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
_spawn_call_record(
|
_spawn_call_record(
|
||||||
@@ -515,6 +533,7 @@ def _make_query_endpoint(worker_manager: WorkerManager, rc: RouteConfig, worker_
|
|||||||
payload=payload_bytes,
|
payload=payload_bytes,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
cacheable=cacheable,
|
cacheable=cacheable,
|
||||||
|
instance_id=_instance_from_request(request),
|
||||||
)
|
)
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
_spawn_call_record(
|
_spawn_call_record(
|
||||||
@@ -648,6 +667,7 @@ def _make_stream_endpoint(
|
|||||||
payload=payload,
|
payload=payload,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
idle_timeout=idle_timeout,
|
idle_timeout=idle_timeout,
|
||||||
|
instance_id=_instance_from_request(request),
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
|
|||||||
@@ -88,7 +88,40 @@ class _MemLRU:
|
|||||||
self._d.popitem(last=False)
|
self._d.popitem(last=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 进程内直调(inline 快路径)─────────────────────────────────────────────
|
||||||
|
# inline 副本由本进程托管(与网关同事件循环),业务请求却仍要绕
|
||||||
|
# "zenoh 查询 → router → queryable 回调 → 跨线程回本 loop"。分派逻辑已统一到
|
||||||
|
# chongming_worker.dispatch,故此处可直接 await 同一个 dispatcher,省掉整圈往返。
|
||||||
|
# 语义不变:信封/错误通道/超时/重试/换副本/实例定向/in-flight 与 drain 全部沿用原逻辑;
|
||||||
|
# 非本地副本(external 插件、w>1 时非托管进程)与旧版 worker 包自动回退 zenoh。
|
||||||
|
_RPC_LOCAL = os.environ.get("CHONGMING_RPC_LOCAL", "1") != "0"
|
||||||
|
try: # 旧版 chongming-worker(<0.5)无 dispatch 模块:保持纯 zenoh 行为
|
||||||
|
from chongming_worker.dispatch import get_dispatcher as _get_local_dispatcher
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_get_local_dispatcher = None
|
||||||
|
|
||||||
|
|
||||||
|
def _local_dispatcher_for(key_expr: str) -> Optional[Any]:
|
||||||
|
"""'worker/<type>/<id>' → 本进程托管该副本的 dispatcher;非本地/不可用返回 None"""
|
||||||
|
if not _RPC_LOCAL or _get_local_dispatcher is None:
|
||||||
|
return None
|
||||||
|
parts = key_expr.split("/")
|
||||||
|
if len(parts) != 3 or parts[0] != "worker":
|
||||||
|
return None
|
||||||
|
return _get_local_dispatcher(parts[1], parts[2])
|
||||||
|
|
||||||
|
|
||||||
|
def _err_text(payload: Any) -> str:
|
||||||
|
"""把错误载荷归一为文本(兼容 zenoh ZBytes 与直调的 bytes)"""
|
||||||
|
if payload is None:
|
||||||
|
return "unknown error"
|
||||||
|
if isinstance(payload, (bytes, bytearray)):
|
||||||
|
return bytes(payload).decode("utf-8", "replace") or "unknown error"
|
||||||
|
return str(payload) or "unknown error"
|
||||||
|
|
||||||
|
|
||||||
class WorkerManager:
|
class WorkerManager:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
统一 Worker 管理器
|
统一 Worker 管理器
|
||||||
|
|
||||||
@@ -112,6 +145,15 @@ class WorkerManager:
|
|||||||
self._launch_worker_count = launch_worker_count
|
self._launch_worker_count = launch_worker_count
|
||||||
self._workers_cache: Dict[str, List[str]] = {} # type -> list of worker key_exprs
|
self._workers_cache: Dict[str, List[str]] = {} # type -> list of worker key_exprs
|
||||||
self._local_workers: Dict[str, List[str]] = {} # type -> list of locally registered workers
|
self._local_workers: Dict[str, List[str]] = {} # type -> list of locally registered workers
|
||||||
|
# ── 实例级定向(把请求固定到某个进程实例而非随机负载均衡)──
|
||||||
|
# 副本经 liveliness 只暴露 worker/<type>/<id>,实例身份取自副本 admin status
|
||||||
|
# 的 instance_id(Python/Rust 运行时均返回),故定向索引按需查询并缓存:
|
||||||
|
# _instance_index[type][instance_id] = [worker/<type>/<id>, ...]
|
||||||
|
# _instance_unknown[type] = [已查询但身份不可得的副本 key, ...]
|
||||||
|
# 只缓存已查询过的副本;每次按需刷新时整表重建并对新副本补查询、对离线副本剪枝。
|
||||||
|
self._instance_index: Dict[str, Dict[str, List[str]]] = {}
|
||||||
|
self._instance_unknown: Dict[str, List[str]] = {}
|
||||||
|
self._instance_locks: Dict[str, asyncio.Lock] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._refresh_interval = 15 # seconds
|
self._refresh_interval = 15 # seconds
|
||||||
# 当前网关心跳标识:优先环境变量(多实例需唯一,尤其同宿主多网关须显式指定),
|
# 当前网关心跳标识:优先环境变量(多实例需唯一,尤其同宿主多网关须显式指定),
|
||||||
@@ -406,23 +448,112 @@ class WorkerManager:
|
|||||||
logger.error(f"刷新 worker 列表失败: {e}")
|
logger.error(f"刷新 worker 列表失败: {e}")
|
||||||
await asyncio.sleep(self._refresh_interval)
|
await asyncio.sleep(self._refresh_interval)
|
||||||
|
|
||||||
async def get_worker(self, worker_type: str) -> Optional[str]:
|
def _all_workers(self, worker_type: str) -> List[str]:
|
||||||
"""获取一个可用的 worker 地址(简单随机负载均衡)
|
"""该类型当前全部候选副本(liveliness 列表优先,启动初期回退本地注册表)。"""
|
||||||
|
workers = list(self._workers_cache.get(worker_type, []))
|
||||||
|
if not workers:
|
||||||
|
workers = list(self._local_workers.get(worker_type, []))
|
||||||
|
return workers
|
||||||
|
|
||||||
|
async def _query_instance_id(self, worker_type: str, worker_key: str) -> Optional[str]:
|
||||||
|
"""问单个副本要它的实例身份(admin status 的 instance_id)。
|
||||||
|
|
||||||
|
复用单副本管理协议 worker/<type>/<id>/__admin__;查询失败或副本运行旧版
|
||||||
|
运行时(回复无 instance_id)返回 None,由调用方归入 unknown 组。
|
||||||
|
"""
|
||||||
|
wid = worker_key.split("/")[2] if "/" in worker_key else worker_key
|
||||||
|
try:
|
||||||
|
reply = await self._send_admin_cmd(worker_type, wid, "query", timeout=2.0)
|
||||||
|
except Exception as e: # noqa: BLE001 - 单副本查询失败不影响其它副本
|
||||||
|
logger.warning("[instance] %s/%s 身份查询失败: %s", worker_type, wid, e)
|
||||||
|
return None
|
||||||
|
iid = reply.get("instance_id") if isinstance(reply, dict) else None
|
||||||
|
return str(iid) if iid else None
|
||||||
|
|
||||||
|
async def _refresh_instance_map(self, worker_type: str) -> Dict[str, List[str]]:
|
||||||
|
"""重建某类型的「实例 → 副本」索引,并按需补查询新副本。
|
||||||
|
|
||||||
|
只对从未查询过的副本发一次 admin status(本地注册的 inline 副本直接返回
|
||||||
|
网关自身实例,零查询);已查询过的副本沿用缓存,因此常规路径是纯内存查询,
|
||||||
|
仅新上线副本会引入一次额外 RPC。离线副本在重建时被剪枝。
|
||||||
|
"""
|
||||||
|
workers = self._all_workers(worker_type)
|
||||||
|
known = self._instance_index.get(worker_type, {})
|
||||||
|
unknown = set(self._instance_unknown.get(worker_type, []))
|
||||||
|
# 反查已有归属:副本 -> 实例(副本 key 唯一,一个副本只属一个实例)
|
||||||
|
owner = {w: iid for iid, ws in known.items() for w in ws}
|
||||||
|
|
||||||
|
index: Dict[str, List[str]] = {}
|
||||||
|
still_unknown: List[str] = []
|
||||||
|
queried = owner.keys() | unknown
|
||||||
|
for w in workers:
|
||||||
|
if w in queried:
|
||||||
|
iid = owner.get(w)
|
||||||
|
else:
|
||||||
|
# 本地注册(inline)副本天然属于本网关实例,无需查询
|
||||||
|
iid = self._gateway_id if w in self._local_workers.get(worker_type, []) else None
|
||||||
|
if iid is None:
|
||||||
|
iid = await self._query_instance_id(worker_type, w)
|
||||||
|
if iid is None:
|
||||||
|
still_unknown.append(w)
|
||||||
|
if iid:
|
||||||
|
index.setdefault(iid, []).append(w)
|
||||||
|
|
||||||
|
self._instance_index[worker_type] = index
|
||||||
|
self._instance_unknown[worker_type] = still_unknown
|
||||||
|
return index
|
||||||
|
|
||||||
|
async def _instance_workers(self, worker_type: str, instance_id: str) -> List[str]:
|
||||||
|
"""取某实例下该类型的在线副本 key 列表(无则空表)。"""
|
||||||
|
index = self._instance_index.get(worker_type)
|
||||||
|
if index is None:
|
||||||
|
# 同类型并发请求只让一个跑刷新,其余等结果,避免重复查询同一批副本
|
||||||
|
lock = self._instance_locks.setdefault(worker_type, asyncio.Lock())
|
||||||
|
async with lock:
|
||||||
|
index = self._instance_index.get(worker_type)
|
||||||
|
if index is None:
|
||||||
|
index = await self._refresh_instance_map(worker_type)
|
||||||
|
return index.get(instance_id, [])
|
||||||
|
|
||||||
|
async def get_worker(
|
||||||
|
self, worker_type: str, instance_id: Optional[str] = None
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""获取一个可用的 worker 地址。
|
||||||
|
|
||||||
|
instance_id 为 None 时按简单随机负载均衡挑选(默认,行为不变);
|
||||||
|
指定实例时只在该实例的在线副本中挑选,把请求固定到那个进程实例。
|
||||||
|
实例无在线副本(或该实例在旧版运行时下无法识别身份)返回 None。
|
||||||
|
|
||||||
读路径无锁:_workers_cache 由 refresh_workers 整表原子替换,
|
读路径无锁:_workers_cache 由 refresh_workers 整表原子替换,
|
||||||
_local_workers 由同步方法修改,单事件循环内读取安全。
|
_local_workers 由同步方法修改,单事件循环内读取安全。
|
||||||
"""
|
"""
|
||||||
workers = self._workers_cache.get(worker_type, [])
|
if instance_id:
|
||||||
# 如果缓存为空,回退到本地注册表(适用于启动初期 refresh_workers 尚未执行时)
|
return await self._pick_from_instance(worker_type, instance_id)
|
||||||
if not workers:
|
workers = self._all_workers(worker_type)
|
||||||
local = self._local_workers.get(worker_type, [])
|
return random.choice(workers) if workers else None
|
||||||
if local:
|
|
||||||
return random.choice(local)
|
async def _pick_from_instance(
|
||||||
return None
|
self, worker_type: str, instance_id: str
|
||||||
return random.choice(workers)
|
) -> Optional[str]:
|
||||||
|
"""在指定实例的在线副本中随机挑一个(定向路径,永不跨实例回退)。
|
||||||
|
|
||||||
|
定向的语义就是"必须打在同一个进程实例上",因此实例离线时返回 None 让
|
||||||
|
调用方报错,而不是悄悄换成别的实例——后者会让粘滞状态类的插件跑在错误
|
||||||
|
副本上,产生难查的数据错乱。
|
||||||
|
"""
|
||||||
|
candidates = await self._instance_workers(worker_type, instance_id)
|
||||||
|
if candidates:
|
||||||
|
return random.choice(candidates)
|
||||||
|
logger.warning(
|
||||||
|
"[instance] 目标实例 %s 下无 %s 的在线副本(已识别实例: %s;%d 个副本读不出身份)",
|
||||||
|
instance_id, worker_type, sorted(self._instance_index.get(worker_type, {})),
|
||||||
|
len(self._instance_unknown.get(worker_type, [])),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _pick_other_worker(
|
async def _pick_other_worker(
|
||||||
self, worker_type: str, exclude: List[str]
|
self, worker_type: str, exclude: List[str],
|
||||||
|
instance_id: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
选择一个不同于已尝试列表的 worker。
|
选择一个不同于已尝试列表的 worker。
|
||||||
@@ -430,10 +561,17 @@ class WorkerManager:
|
|||||||
Args:
|
Args:
|
||||||
worker_type: Worker 类型
|
worker_type: Worker 类型
|
||||||
exclude: 已尝试(需排除)的 worker key_expr 列表
|
exclude: 已尝试(需排除)的 worker key_expr 列表
|
||||||
|
instance_id: 定向实例;给定时只在同实例内换副本,绝不跨实例
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
可用的 worker key_expr,若无其他可用则返回 None
|
可用的 worker key_expr,若无其他可用则返回 None
|
||||||
"""
|
"""
|
||||||
|
if instance_id:
|
||||||
|
candidates = [
|
||||||
|
w for w in await self._instance_workers(worker_type, instance_id)
|
||||||
|
if w not in exclude
|
||||||
|
]
|
||||||
|
return random.choice(candidates) if candidates else None
|
||||||
workers = self._workers_cache.get(worker_type, [])
|
workers = self._workers_cache.get(worker_type, [])
|
||||||
candidates = [w for w in workers if w not in exclude]
|
candidates = [w for w in workers if w not in exclude]
|
||||||
if candidates:
|
if candidates:
|
||||||
@@ -455,24 +593,37 @@ class WorkerManager:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _raise_no_worker(self, worker_type: str) -> None:
|
async def _no_worker_message(
|
||||||
|
self, worker_type: str, instance_id: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
无可用 Worker 时的统一诊断:列出当前已知的所有 worker 类型并抛 RuntimeError。
|
无可用 Worker 时的统一诊断文案。
|
||||||
|
|
||||||
供 call() / call_stream() 共用,保证两类调用错误信息一致。
|
供 call() / call_stream() 共用,保证两类调用错误信息一致;定向(instance_id)
|
||||||
|
调用额外列出当前已识别实例,便于区分"实例名写错"与"实例离线"。
|
||||||
"""
|
"""
|
||||||
all_known = list(self._workers_cache.keys())
|
all_known = list(self._workers_cache.keys())
|
||||||
local_known = list(self._local_workers.keys())
|
local_known = list(self._local_workers.keys())
|
||||||
all_types = sorted(set(all_known + local_known))
|
all_types = sorted(set(all_known + local_known))
|
||||||
msg = (
|
msg = f"类型 '{worker_type}' 没有可用 Worker。"
|
||||||
f"类型 '{worker_type}' 没有可用 Worker。"
|
if instance_id:
|
||||||
f"当前已知的 Worker 类型: {all_types if all_types else '(无)'}。"
|
msg = (
|
||||||
f"请检查: 1) config/workers/config.toml 是否配置了该类型 "
|
f"类型 '{worker_type}' 的实例 '{instance_id}' 没有在线副本(定向调用不"
|
||||||
f"2) 插件初始化是否成功(查看启动日志中的 '加载 Worker 插件失败' 错误)"
|
f"跨实例回退)。已识别实例: "
|
||||||
f"3) Worker 进程是否正常运行"
|
f"{sorted(self._instance_index.get(worker_type, {})) or '(无)'}。"
|
||||||
)
|
f"请检查: 1) 该实例 id 是否写错(取值见 GET "
|
||||||
logger.warning(msg)
|
f"/admin/workers/{worker_type}/instances 或副本启动时的 --instance)"
|
||||||
raise RuntimeError(msg)
|
f" 2) 该实例进程是否在线 3) 该实例的 worker 是否运行旧版运行时"
|
||||||
|
f"(不支持实例身份上报)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg += (
|
||||||
|
f"当前已知的 Worker 类型: {all_types if all_types else '(无)'}。"
|
||||||
|
f"请检查: 1) config/workers/config.toml 是否配置了该类型 "
|
||||||
|
f"2) 插件初始化是否成功(查看启动日志中的 '加载 Worker 插件失败' 错误)"
|
||||||
|
f"3) Worker 进程是否正常运行"
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
|
||||||
async def call_stream(
|
async def call_stream(
|
||||||
self,
|
self,
|
||||||
@@ -481,6 +632,7 @@ class WorkerManager:
|
|||||||
payload: Dict[str, Any],
|
payload: Dict[str, Any],
|
||||||
timeout: float = 300.0,
|
timeout: float = 300.0,
|
||||||
idle_timeout: float = 10.0,
|
idle_timeout: float = 10.0,
|
||||||
|
instance_id: Optional[str] = None,
|
||||||
) -> AsyncGenerator[bytes, None]:
|
) -> AsyncGenerator[bytes, None]:
|
||||||
"""
|
"""
|
||||||
流式调用:消费 Worker 的多回复流,逐块产出 JSON 字节。
|
流式调用:消费 Worker 的多回复流,逐块产出 JSON 字节。
|
||||||
@@ -499,21 +651,53 @@ class WorkerManager:
|
|||||||
payload: 请求参数(信封格式,由路由层构造)
|
payload: 请求参数(信封格式,由路由层构造)
|
||||||
timeout: 流总寿命上限(秒),需大于业务最长生成时长
|
timeout: 流总寿命上限(秒),需大于业务最长生成时长
|
||||||
idle_timeout: 空闲超时(秒),超时抛 StreamIdleTimeoutError
|
idle_timeout: 空闲超时(秒),超时抛 StreamIdleTimeoutError
|
||||||
|
instance_id: 定向进程实例(None=随机负载均衡);流本身不可重试,
|
||||||
|
故实例离线时直接报错,不会中途改派到别的实例
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
bytes: 每个回复块的 JSON 字节
|
bytes: 每个回复块的 JSON 字节
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: 无可用 worker
|
RuntimeError: 无可用 worker(含定向实例无在线副本)
|
||||||
StreamIdleTimeoutError: 空闲超时
|
StreamIdleTimeoutError: 空闲超时
|
||||||
"""
|
"""
|
||||||
# 急切解析 worker:无可用时立即抛错,让路由层映射 HTTP 503
|
# 急切解析 worker:无可用时立即抛错,让路由层映射 HTTP 503
|
||||||
# (异步生成器函数体在首次迭代才执行,故这里用普通 async 函数返回内部生成器)
|
# (异步生成器函数体在首次迭代才执行,故这里用普通 async 函数返回内部生成器)
|
||||||
worker_expr = await self.get_worker(worker_type)
|
worker_expr = await self.get_worker(worker_type, instance_id=instance_id)
|
||||||
if not worker_expr:
|
if not worker_expr:
|
||||||
await self._raise_no_worker(worker_type)
|
raise RuntimeError(await self._no_worker_message(worker_type, instance_id))
|
||||||
|
|
||||||
full_key = f"{worker_expr}/{subject}"
|
full_key = f"{worker_expr}/{subject}"
|
||||||
|
|
||||||
|
# 进程内直调(inline 快路径):与 zenoh 路径同语义(逐块产出、错误块终止、无重试)
|
||||||
|
local = _local_dispatcher_for(worker_expr)
|
||||||
|
if local is not None and local.has_subject(subject):
|
||||||
|
async def _local_stream():
|
||||||
|
agen = local.invoke_stream(subject, orjson.dumps(payload))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await asyncio.wait_for(agen.__anext__(), timeout=idle_timeout)
|
||||||
|
except StopAsyncIteration:
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise StreamIdleTimeoutError(
|
||||||
|
f"Worker {worker_expr} 流式空闲超时 ({idle_timeout}s 无数据)"
|
||||||
|
) from None
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
decoded = orjson.loads(data)
|
||||||
|
if isinstance(decoded, dict) and decoded.get("error"):
|
||||||
|
# Worker 错误收尾块:转发后终止流
|
||||||
|
yield data
|
||||||
|
return
|
||||||
|
yield data
|
||||||
|
finally:
|
||||||
|
# 消费方提前退出(SSE 断连/网关放弃):停流并取消在途派发
|
||||||
|
await agen.aclose()
|
||||||
|
|
||||||
|
return _local_stream()
|
||||||
|
|
||||||
reply_iter = self.session.get(
|
reply_iter = self.session.get(
|
||||||
full_key,
|
full_key,
|
||||||
payload=orjson.dumps(payload),
|
payload=orjson.dumps(payload),
|
||||||
@@ -602,6 +786,7 @@ class WorkerManager:
|
|||||||
timeout: float = 3.0,
|
timeout: float = 3.0,
|
||||||
retries: int = 1,
|
retries: int = 1,
|
||||||
cacheable: bool = False,
|
cacheable: bool = False,
|
||||||
|
instance_id: Optional[str] = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
统一调用入口(dict 语义,MQTT Bridge 等调用方使用)
|
统一调用入口(dict 语义,MQTT Bridge 等调用方使用)
|
||||||
@@ -613,6 +798,8 @@ class WorkerManager:
|
|||||||
timeout: 单次调用超时(秒)
|
timeout: 单次调用超时(秒)
|
||||||
retries: 失败重试次数
|
retries: 失败重试次数
|
||||||
cacheable: 是否允许缓存结果(若为 False 则跳过缓存读写)
|
cacheable: 是否允许缓存结果(若为 False 则跳过缓存读写)
|
||||||
|
instance_id: 定向进程实例;给定时请求固定发往该实例(不参与随机
|
||||||
|
负载均衡,重试也只在该实例内换副本),None=默认随机均衡
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict[str, Any]: 调用结果(worker 回复解析后的 dict)
|
Dict[str, Any]: 调用结果(worker 回复解析后的 dict)
|
||||||
@@ -620,10 +807,18 @@ class WorkerManager:
|
|||||||
# cacheable=False 时不计算 cache_key,省一次排序序列化
|
# cacheable=False 时不计算 cache_key,省一次排序序列化
|
||||||
cache_key = None
|
cache_key = None
|
||||||
if cacheable:
|
if cacheable:
|
||||||
cache_key = f"{worker_type}:{subject}:{orjson.dumps(payload, option=orjson.OPT_SORT_KEYS).decode()}"
|
# 定向调用的缓存键含实例名:定向通常用于读取该实例的本地状态
|
||||||
|
# (本地库/设备连接),共用缓存会拿到其它实例的结果
|
||||||
|
cache_key = (
|
||||||
|
f"{worker_type}:{subject}"
|
||||||
|
f":{orjson.dumps(payload, option=orjson.OPT_SORT_KEYS).decode()}"
|
||||||
|
)
|
||||||
|
if instance_id:
|
||||||
|
cache_key = f"{instance_id}@{cache_key}"
|
||||||
reply_bytes = await self._call_impl(
|
reply_bytes = await self._call_impl(
|
||||||
worker_type, subject, orjson.dumps(payload),
|
worker_type, subject, orjson.dumps(payload),
|
||||||
timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key,
|
timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
return orjson.loads(reply_bytes) if reply_bytes else {}
|
return orjson.loads(reply_bytes) if reply_bytes else {}
|
||||||
|
|
||||||
@@ -635,6 +830,7 @@ class WorkerManager:
|
|||||||
timeout: float = 3.0,
|
timeout: float = 3.0,
|
||||||
retries: int = 1,
|
retries: int = 1,
|
||||||
cacheable: bool = False,
|
cacheable: bool = False,
|
||||||
|
instance_id: Optional[str] = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
统一调用入口(bytes 语义,HTTP 端点透传用):返回 worker 回复的原始字节。
|
统一调用入口(bytes 语义,HTTP 端点透传用):返回 worker 回复的原始字节。
|
||||||
@@ -652,6 +848,7 @@ class WorkerManager:
|
|||||||
timeout: 单次调用超时(秒)
|
timeout: 单次调用超时(秒)
|
||||||
retries: 失败重试次数
|
retries: 失败重试次数
|
||||||
cacheable: 是否允许缓存结果(缓存原始字节)
|
cacheable: 是否允许缓存结果(缓存原始字节)
|
||||||
|
instance_id: 定向进程实例(见 call());None=默认随机均衡
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bytes: worker 回复的原始字节(空回复兜底 b"{}",与 call() 的 {} 语义一致)
|
bytes: worker 回复的原始字节(空回复兜底 b"{}",与 call() 的 {} 语义一致)
|
||||||
@@ -660,9 +857,12 @@ class WorkerManager:
|
|||||||
cache_key = None
|
cache_key = None
|
||||||
if cacheable:
|
if cacheable:
|
||||||
cache_key = f"{worker_type}:{subject}:{hashlib.sha256(payload).hexdigest()}"
|
cache_key = f"{worker_type}:{subject}:{hashlib.sha256(payload).hexdigest()}"
|
||||||
|
if instance_id:
|
||||||
|
cache_key = f"{instance_id}@{cache_key}"
|
||||||
return await self._call_impl(
|
return await self._call_impl(
|
||||||
worker_type, subject, payload,
|
worker_type, subject, payload,
|
||||||
timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key,
|
timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _call_impl(
|
async def _call_impl(
|
||||||
@@ -674,11 +874,13 @@ class WorkerManager:
|
|||||||
retries: int = 1,
|
retries: int = 1,
|
||||||
cacheable: bool = False,
|
cacheable: bool = False,
|
||||||
cache_key: Optional[str] = None,
|
cache_key: Optional[str] = None,
|
||||||
|
instance_id: Optional[str] = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
调用实现:缓存 > 负载均衡 > 超时重试,返回回复的原始字节。
|
调用实现:缓存 > 选副本(随机均衡/实例定向)> 超时重试,返回回复的原始字节。
|
||||||
|
|
||||||
call() / call_raw() 共用本实现,仅入参出参形态不同(dict ↔ bytes)。
|
call() / call_raw() 共用本实现,仅入参出参形态不同(dict ↔ bytes)。
|
||||||
|
instance_id 给定时选副本与重试都被约束在该实例内(定向语义,不跨实例回退)。
|
||||||
"""
|
"""
|
||||||
# 1. 缓存检查(仅在 cacheable=True 时检查)
|
# 1. 缓存检查(仅在 cacheable=True 时检查)
|
||||||
if cacheable and cache_key is not None:
|
if cacheable and cache_key is not None:
|
||||||
@@ -695,56 +897,92 @@ class WorkerManager:
|
|||||||
logger.debug(f"命中共享缓存: {cache_key}")
|
logger.debug(f"命中共享缓存: {cache_key}")
|
||||||
return disk_val
|
return disk_val
|
||||||
|
|
||||||
# 2. 获取可用 worker
|
# 2. 获取可用 worker(instance_id 给定时固定到该实例,否则随机均衡)
|
||||||
worker_expr = await self.get_worker(worker_type)
|
worker_expr = await self.get_worker(worker_type, instance_id=instance_id)
|
||||||
if not worker_expr:
|
if not worker_expr:
|
||||||
await self._raise_no_worker(worker_type)
|
raise RuntimeError(await self._no_worker_message(worker_type, instance_id))
|
||||||
|
|
||||||
full_key = f"{worker_expr}/{subject}"
|
full_key = f"{worker_expr}/{subject}"
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
tried_workers: List[str] = []
|
tried_workers: List[str] = []
|
||||||
|
|
||||||
|
async def _switch_worker(why: str) -> bool:
|
||||||
|
"""换一个副本重试;返回是否值得继续(定向调用只在同实例内换副本)。
|
||||||
|
|
||||||
|
换完更新闭包外层的 worker_expr / full_key。定向实例内已无未试副本时
|
||||||
|
返回 False —— 继续循环只会重复打同一个坏副本,白等一整轮超时。
|
||||||
|
"""
|
||||||
|
nonlocal worker_expr, full_key, last_error
|
||||||
|
new_worker = await self._pick_other_worker(
|
||||||
|
worker_type, tried_workers, instance_id=instance_id
|
||||||
|
)
|
||||||
|
if new_worker:
|
||||||
|
if new_worker != worker_expr:
|
||||||
|
logger.info(f"{why}切换 Worker: {worker_expr} -> {new_worker}")
|
||||||
|
worker_expr = new_worker
|
||||||
|
full_key = f"{worker_expr}/{subject}"
|
||||||
|
return True
|
||||||
|
# 没有任何未试过的副本可换:继续循环只会重复打同一个副本(若它已死,
|
||||||
|
# 白等一整轮超时;若它已回业务错误,结果必然相同),直接结束重试。
|
||||||
|
# 保留具体失败原因(last_error 已由调用方按场景写入),便于定位。
|
||||||
|
scope = f"实例 '{instance_id}' 内" if instance_id else ""
|
||||||
|
last_error = f"{scope}已无其它未试副本可换({why}):{last_error}"
|
||||||
|
return False
|
||||||
|
|
||||||
for attempt in range(retries + 1):
|
for attempt in range(retries + 1):
|
||||||
# 记录已尝试的 worker
|
# 记录已尝试的 worker
|
||||||
if worker_expr not in tried_workers:
|
if worker_expr not in tried_workers:
|
||||||
tried_workers.append(worker_expr)
|
tried_workers.append(worker_expr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reply_iter = self.session.get(
|
local = _local_dispatcher_for(worker_expr)
|
||||||
full_key,
|
if local is not None and local.has_subject(subject):
|
||||||
payload=payload_bytes,
|
# 进程内直调(inline 快路径):省 zenoh 往返,语义与下面 zenoh 路径一致
|
||||||
consolidation=zenoh.ConsolidationMode.NONE,
|
logger.debug(f"[local-rpc] {worker_expr}/{subject} 进程内直调")
|
||||||
timeout=timeout,
|
local_res = await asyncio.wait_for(
|
||||||
)
|
local.invoke_unary(subject, payload_bytes), timeout=timeout
|
||||||
# 使用辅助函数获取第一个回复,避免 StopIteration 在 asyncio.to_thread 中传播的问题
|
|
||||||
def _get_first(iterator):
|
|
||||||
"""线程安全地获取迭代器的第一个元素"""
|
|
||||||
for item in iterator:
|
|
||||||
return item
|
|
||||||
return _SENTINEL
|
|
||||||
|
|
||||||
# 走专用 _ZENOH_EXECUTOR 池(隔离于全局默认池,避免高并发下排队)
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
first_reply = await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(_ZENOH_EXECUTOR, _get_first, reply_iter),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
if first_reply is _SENTINEL:
|
|
||||||
logger.warning(
|
|
||||||
f"Worker {worker_expr} 无回复,尝试重试 {attempt+1}/{retries+1}"
|
|
||||||
)
|
)
|
||||||
last_error = f"Worker {worker_expr} 无回复"
|
reply_ok, reply_payload = local_res.ok, local_res.payload
|
||||||
# 换一个 worker 重试
|
else:
|
||||||
new_worker = await self._pick_other_worker(worker_type, tried_workers)
|
reply_iter = self.session.get(
|
||||||
if new_worker:
|
full_key,
|
||||||
worker_expr = new_worker
|
payload=payload_bytes,
|
||||||
full_key = f"{worker_expr}/{subject}"
|
consolidation=zenoh.ConsolidationMode.NONE,
|
||||||
continue
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
# 使用辅助函数获取第一个回复,避免 StopIteration 在 asyncio.to_thread 中传播的问题
|
||||||
|
def _get_first(iterator):
|
||||||
|
"""线程安全地获取迭代器的第一个元素"""
|
||||||
|
for item in iterator:
|
||||||
|
return item
|
||||||
|
return _SENTINEL
|
||||||
|
|
||||||
reply = first_reply
|
# 走专用 _ZENOH_EXECUTOR 池(隔离于全局默认池,避免高并发下排队)
|
||||||
if reply.ok: # type: ignore
|
loop = asyncio.get_running_loop()
|
||||||
reply_bytes = bytes(reply.ok.payload) if reply.ok.payload else b"{}"
|
first_reply = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(_ZENOH_EXECUTOR, _get_first, reply_iter),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if first_reply is _SENTINEL:
|
||||||
|
logger.warning(
|
||||||
|
f"Worker {worker_expr} 无回复,尝试重试 {attempt+1}/{retries+1}"
|
||||||
|
)
|
||||||
|
last_error = f"Worker {worker_expr} 无回复"
|
||||||
|
if not await _switch_worker("无回复"):
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
reply = first_reply
|
||||||
|
if reply.ok: # type: ignore
|
||||||
|
reply_ok = True
|
||||||
|
reply_payload = bytes(reply.ok.payload) if reply.ok.payload else b"{}"
|
||||||
|
else:
|
||||||
|
reply_ok = False
|
||||||
|
reply_payload = reply.err.payload # type: ignore
|
||||||
|
|
||||||
|
if reply_ok:
|
||||||
|
reply_bytes = bytes(reply_payload) if reply_payload else b"{}"
|
||||||
# 缓存写入(仅在 cacheable=True 时):解析一次做成功检查,
|
# 缓存写入(仅在 cacheable=True 时):解析一次做成功检查,
|
||||||
# 但缓存与返回均保留原始字节(网关侧零二次序列化)。
|
# 但缓存与返回均保留原始字节(网关侧零二次序列化)。
|
||||||
# 先写内存一级,再异步写共享二级(executor,不阻塞响应返回)
|
# 先写内存一级,再异步写共享二级(executor,不阻塞响应返回)
|
||||||
@@ -755,16 +993,12 @@ class WorkerManager:
|
|||||||
await self._cache_set(cache_key, reply_bytes)
|
await self._cache_set(cache_key, reply_bytes)
|
||||||
return reply_bytes
|
return reply_bytes
|
||||||
else:
|
else:
|
||||||
error_payload = reply.err.payload # type: ignore
|
error_msg = _err_text(reply_payload)
|
||||||
error_msg = str(error_payload) if error_payload else "unknown error"
|
|
||||||
logger.warning(f"Worker 返回错误: {error_msg} (尝试 {attempt+1}/{retries+1})")
|
logger.warning(f"Worker 返回错误: {error_msg} (尝试 {attempt+1}/{retries+1})")
|
||||||
last_error = f"Worker {worker_expr} 返回错误: {error_msg}"
|
last_error = f"Worker {worker_expr} 返回错误: {error_msg}"
|
||||||
# 错误回复(含 Zenoh 超时)也换一个 worker 重试
|
# 错误回复(含 Zenoh 超时)也换一个 worker 重试
|
||||||
new_worker = await self._pick_other_worker(worker_type, tried_workers)
|
if not await _switch_worker("错误后"):
|
||||||
if new_worker:
|
break
|
||||||
logger.info(f"错误后切换 Worker: {worker_expr} -> {new_worker}")
|
|
||||||
worker_expr = new_worker
|
|
||||||
full_key = f"{worker_expr}/{subject}"
|
|
||||||
continue # 重试
|
continue # 重试
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -772,11 +1006,8 @@ class WorkerManager:
|
|||||||
)
|
)
|
||||||
last_error = f"Worker {worker_expr} 超时 (>{timeout}s)"
|
last_error = f"Worker {worker_expr} 超时 (>{timeout}s)"
|
||||||
# 超时后换一个 worker 重试,避免打到仍在处理中的同一个 worker
|
# 超时后换一个 worker 重试,避免打到仍在处理中的同一个 worker
|
||||||
new_worker = await self._pick_other_worker(worker_type, tried_workers)
|
if not await _switch_worker("超时后"):
|
||||||
if new_worker:
|
break
|
||||||
logger.info(f"超时后切换 Worker: {worker_expr} -> {new_worker}")
|
|
||||||
worker_expr = new_worker
|
|
||||||
full_key = f"{worker_expr}/{subject}"
|
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"调用 worker 异常: {e}")
|
logger.error(f"调用 worker 异常: {e}")
|
||||||
@@ -784,7 +1015,7 @@ class WorkerManager:
|
|||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Worker '{worker_type}' 调用失败 (已尝试 {len(tried_workers)} 个 Worker, "
|
f"Worker '{worker_type}' 调用失败 (已尝试 {len(tried_workers)} 个 Worker, "
|
||||||
f"共 {retries + 1} 次, timeout={timeout}s): {last_error}"
|
f"实际尝试 {attempt + 1} 次/上限 {retries + 1} 次, timeout={timeout}s): {last_error}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from .storage import router as storage_router
|
|
||||||
from .stats import router as stats_router
|
from .stats import router as stats_router
|
||||||
from .worker_admin import router as worker_admin_router
|
from .worker_admin import router as worker_admin_router
|
||||||
from .auth import router as auth_router
|
from .auth import router as auth_router
|
||||||
|
|
||||||
__all__ = ["storage_router", "stats_router", "worker_admin_router", "auth_router"]
|
__all__ = ["stats_router", "worker_admin_router", "auth_router"]
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# src/chongming/app/routes/storage.py
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import urllib.parse
|
|
||||||
import aiofiles
|
|
||||||
|
|
||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Request
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/storage", tags=["Storage"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", summary="上传文件")
|
|
||||||
async def upload_file(
|
|
||||||
request: Request,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
worker_type: str = Form(...),
|
|
||||||
):
|
|
||||||
"""上传文件到本地存储"""
|
|
||||||
upload_root = request.app.state.upload_root
|
|
||||||
object_key = f"{worker_type}/{uuid.uuid4()}/{file.filename}"
|
|
||||||
dest = os.path.join(upload_root, object_key)
|
|
||||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
||||||
|
|
||||||
async with aiofiles.open(dest, "wb") as afp:
|
|
||||||
while chunk := await file.read(1024 * 1024):
|
|
||||||
await afp.write(chunk)
|
|
||||||
|
|
||||||
file_size = os.path.getsize(dest)
|
|
||||||
return {
|
|
||||||
"file_key": object_key,
|
|
||||||
"bucket": "local",
|
|
||||||
"filename": file.filename,
|
|
||||||
"content_type": file.content_type,
|
|
||||||
"size": file_size,
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/download/{file_key:path}")
|
|
||||||
async def download_file(request: Request, file_key: str):
|
|
||||||
"""从本地存储下载文件"""
|
|
||||||
upload_root = request.app.state.upload_root
|
|
||||||
path = os.path.join(upload_root, file_key)
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
raise HTTPException(404, "文件不存在")
|
|
||||||
|
|
||||||
raw_filename = file_key.split('/')[-1]
|
|
||||||
encoded = urllib.parse.quote(raw_filename, safe='')
|
|
||||||
return FileResponse(
|
|
||||||
path,
|
|
||||||
filename=raw_filename,
|
|
||||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"},
|
|
||||||
)
|
|
||||||
+10
-3
@@ -22,7 +22,7 @@ import httpx
|
|||||||
import orjson
|
import orjson
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||||||
|
|
||||||
@@ -53,6 +53,11 @@ _cs_mod = importlib.util.module_from_spec(_cs_spec)
|
|||||||
sys.modules["chongming.app.core.call_stats"] = _cs_mod
|
sys.modules["chongming.app.core.call_stats"] = _cs_mod
|
||||||
_cs_spec.loader.exec_module(_cs_mod) # type: ignore[union-attr]
|
_cs_spec.loader.exec_module(_cs_mod) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
# 审计采样率固定为全量:生产 config 的 access_sample_rate=0.1 下,成功请求的审计行
|
||||||
|
# 本就是按概率写的,断言"几条请求 = 几条审计行"会随机失败。本测试验证的是审计链路
|
||||||
|
# 是否工作,与采样策略无关,故固定为 1.0。
|
||||||
|
_cs_mod._AUDIT_SAMPLE_RATE = 1.0
|
||||||
|
|
||||||
# 加载真实 route_generator
|
# 加载真实 route_generator
|
||||||
_rg_path = REPO_ROOT / "src" / "chongming" / "app" / "core" / "route_generator.py"
|
_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_spec = importlib.util.spec_from_file_location("chongming.app.core.route_generator", _rg_path)
|
||||||
@@ -95,12 +100,14 @@ class FakeWorkerManager:
|
|||||||
self.reply = reply
|
self.reply = reply
|
||||||
self.error = error
|
self.error = error
|
||||||
|
|
||||||
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False):
|
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False,
|
||||||
|
instance_id=None):
|
||||||
if self.error == "no_worker":
|
if self.error == "no_worker":
|
||||||
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
||||||
return self.reply
|
return self.reply
|
||||||
|
|
||||||
async def call_stream(self, worker_type, subject, payload, timeout=300.0, idle_timeout=10.0):
|
async def call_stream(self, worker_type, subject, payload, timeout=300.0, idle_timeout=10.0,
|
||||||
|
instance_id=None):
|
||||||
if self.error == "no_worker":
|
if self.error == "no_worker":
|
||||||
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""inline 直调路径 与 zenoh 路径 的一致性测试。
|
||||||
|
|
||||||
|
守护"进程插件 ↔ 内置插件无缝迁移"契约:同一个 worker 副本,分别经
|
||||||
|
A) 进程内直调(chongming_worker.dispatch:网关 inline 快路径用的入口)
|
||||||
|
B) zenoh 查询(session.get → router → queryable 回调 → 原有路径)
|
||||||
|
发起同一批请求,逐字段比对两路结果(仅屏蔽时间戳这类天然不同的字段)。
|
||||||
|
|
||||||
|
覆盖:正常 GET / POST、模型校验失败(reply_err 通道)、未知 subject、
|
||||||
|
非信封旧式载荷、流式多块、流式生成器异常收尾块。
|
||||||
|
|
||||||
|
运行: uv run python test/test_inline_local_dispatch.py
|
||||||
|
(需要 zenoh router 在 config/zenoh_config.json5 指向的端点可达)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
# 优先本地框架 checkout(与 test_stream_worker.py 同约定)
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
|
|
||||||
|
import orjson # noqa: E402
|
||||||
|
import zenoh # noqa: E402
|
||||||
|
|
||||||
|
from chongming_worker.dispatch import get_dispatcher, unregister_dispatcher # noqa: E402
|
||||||
|
from chongming_worker.runtime import create_worker # noqa: E402
|
||||||
|
|
||||||
|
WORKER_TYPE = "example"
|
||||||
|
WORKER_ID = "worker-parity-0"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_timestamp(obj):
|
||||||
|
"""屏蔽时间戳类字段(两路调用时刻不同,天然不一致)"""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: (0 if k in ("timestamp", "time") else mask_timestamp(v)) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [mask_timestamp(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(payload: bytes):
|
||||||
|
try:
|
||||||
|
return mask_timestamp(orjson.loads(payload))
|
||||||
|
except Exception:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def envelope(method: str, data: dict) -> bytes:
|
||||||
|
return orjson.dumps({"meta": {"method": method, "user_info": None}, "data": data})
|
||||||
|
|
||||||
|
|
||||||
|
async def invoke_zenoh(session, subject: str, payload: bytes, timeout: float = 3.0):
|
||||||
|
"""经 zenoh 查询取首个回复:返回 ("ok", 载荷) / ("err", 载荷) / ("noreply", None)"""
|
||||||
|
key = f"worker/{WORKER_TYPE}/{WORKER_ID}/{subject}"
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _first():
|
||||||
|
for reply in session.get(key, payload=payload,
|
||||||
|
consolidation=zenoh.ConsolidationMode.NONE,
|
||||||
|
timeout=timeout):
|
||||||
|
return reply
|
||||||
|
return None
|
||||||
|
|
||||||
|
reply = await loop.run_in_executor(None, _first)
|
||||||
|
if reply is None:
|
||||||
|
return ("noreply", None)
|
||||||
|
if reply.ok:
|
||||||
|
return ("ok", bytes(reply.ok.payload) if reply.ok.payload else b"")
|
||||||
|
return ("err", bytes(reply.err.payload))
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_zenoh_stream(session, subject: str, payload: bytes, timeout: float = 5.0):
|
||||||
|
key = f"worker/{WORKER_TYPE}/{WORKER_ID}/{subject}"
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _all():
|
||||||
|
out = []
|
||||||
|
for reply in session.get(key, payload=payload,
|
||||||
|
consolidation=zenoh.ConsolidationMode.NONE,
|
||||||
|
timeout=timeout):
|
||||||
|
if reply.ok:
|
||||||
|
out.append(bytes(reply.ok.payload) if reply.ok.payload else b"")
|
||||||
|
else:
|
||||||
|
out.append(("err", bytes(reply.err.payload)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
return await loop.run_in_executor(None, _all)
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_local_stream(dispatcher, subject: str, payload: bytes):
|
||||||
|
out = []
|
||||||
|
async for chunk in dispatcher.invoke_stream(subject, payload):
|
||||||
|
out.append(chunk)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
# ── 起会话 + 一个真实 worker 副本(example 插件,纯计算端点)──
|
||||||
|
import plugins.example.router.add # noqa: F401
|
||||||
|
import plugins.example.router.subtract # noqa: F401
|
||||||
|
import plugins.example.router.stream_chat # noqa: F401
|
||||||
|
|
||||||
|
zconfig = zenoh.Config()
|
||||||
|
zconfig.from_file(str(REPO_ROOT / "config" / "zenoh_config.json5"))
|
||||||
|
session = zenoh.open(zconfig)
|
||||||
|
|
||||||
|
worker_task = asyncio.create_task(
|
||||||
|
create_worker(session, WORKER_TYPE, WORKER_ID, config={"data_dir": "./data"})
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1.5) # 等 liveliness / queryable / 直调注册都就绪
|
||||||
|
|
||||||
|
dispatcher = get_dispatcher(WORKER_TYPE, WORKER_ID)
|
||||||
|
check("直调入口已注册(register_dispatcher)", dispatcher is not None)
|
||||||
|
if dispatcher is None:
|
||||||
|
worker_task.cancel()
|
||||||
|
session.close()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── 1) 正常 GET:example.add?a&b ──
|
||||||
|
payload = envelope("GET", {"a": 2, "b": 3})
|
||||||
|
z = await invoke_zenoh(session, "example.add", payload)
|
||||||
|
l = await dispatcher.invoke_unary("example.add", payload)
|
||||||
|
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||||
|
check("GET 两路状态一致", z[0] == l_norm[0], f"zenoh={z[0]} local={l_norm[0]}")
|
||||||
|
check(
|
||||||
|
"GET 两路载荷逐字段一致",
|
||||||
|
normalize(z[1] or b"") == normalize(l_norm[1]),
|
||||||
|
f"zenoh={z[1]!r} local={l_norm[1]!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2) 正常 POST(同 subject 的另一方法)──
|
||||||
|
payload = envelope("POST", {"a": 5, "b": 7})
|
||||||
|
z = await invoke_zenoh(session, "example.add", payload)
|
||||||
|
l = await dispatcher.invoke_unary("example.add", payload)
|
||||||
|
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||||
|
check("POST 两路状态一致", z[0] == l_norm[0], f"zenoh={z[0]} local={l_norm[0]}")
|
||||||
|
check("POST 两路载荷逐字段一致", normalize(z[1] or b"") == normalize(l_norm[1]))
|
||||||
|
|
||||||
|
# ── 3) 模型校验失败 → 框架错误(zenoh reply_err ⇔ local ok=False)──
|
||||||
|
payload = envelope("GET", {"a": "not-a-number"})
|
||||||
|
z = await invoke_zenoh(session, "example.add", payload)
|
||||||
|
l = await dispatcher.invoke_unary("example.add", payload)
|
||||||
|
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||||
|
check("校验失败两路都走错误通道", z[0] == "err" and l_norm[0] == "err",
|
||||||
|
f"zenoh={z[0]} local={l_norm[0]}")
|
||||||
|
z_err, l_err = normalize(z[1] or b""), normalize(l_norm[1])
|
||||||
|
check("错误信封结构一致(operation=error 且含 error 文本)",
|
||||||
|
isinstance(z_err, dict) and isinstance(l_err, dict)
|
||||||
|
and z_err.get("operation") == l_err.get("operation") == "error"
|
||||||
|
and bool(z_err.get("error")) and bool(l_err.get("error")),
|
||||||
|
f"zenoh={z_err} local={l_err}")
|
||||||
|
|
||||||
|
# ── 4) 非信封旧式载荷(method 默认 GET)──
|
||||||
|
payload = orjson.dumps({"a": 1, "b": 1})
|
||||||
|
z = await invoke_zenoh(session, "example.subtract", payload)
|
||||||
|
l = await dispatcher.invoke_unary("example.subtract", payload)
|
||||||
|
l_norm = ("ok" if l.ok else "err", l.payload)
|
||||||
|
check("旧式载荷两路一致", z[0] == l_norm[0] and normalize(z[1] or b"") == normalize(l_norm[1]),
|
||||||
|
f"zenoh={z} local={l_norm}")
|
||||||
|
|
||||||
|
# ── 5) 未知 subject:两路都应失败(路径不同但终态一致:网关侧都会换副本重试后 503)──
|
||||||
|
z = await invoke_zenoh(session, "example.not_exists", envelope("GET", {}), timeout=1.0)
|
||||||
|
l = await dispatcher.invoke_unary("example.not_exists", envelope("GET", {}))
|
||||||
|
check("未知 subject 两路都失败(zenoh 无回复 / 直调返回错误)",
|
||||||
|
z[0] == "noreply" and l.ok is False, f"zenoh={z[0]} local.ok={l.ok}")
|
||||||
|
|
||||||
|
# ── 6) 流式多块 ──
|
||||||
|
payload = envelope("POST", {"prompt": "你好", "tokens": 4})
|
||||||
|
zs = await collect_zenoh_stream(session, "example.stream_chat", payload)
|
||||||
|
ls = await collect_local_stream(dispatcher, "example.stream_chat", payload)
|
||||||
|
check("流式块数一致", len(zs) == len(ls), f"zenoh={len(zs)} local={len(ls)}")
|
||||||
|
check("流式逐块内容一致",
|
||||||
|
[normalize(x) for x in zs] == [normalize(x) for x in ls],
|
||||||
|
f"zenoh={[normalize(x) for x in zs]} local={[normalize(x) for x in ls]}")
|
||||||
|
|
||||||
|
# ── 7) 流式生成器中途异常 → {"error": ...} 收尾块(两路都应以此结束)──
|
||||||
|
# 流终止语义(无 token 的空流)两路一致
|
||||||
|
payload = envelope("POST", {"prompt": "空", "tokens": 0})
|
||||||
|
zs = await collect_zenoh_stream(session, "example.stream_chat", payload)
|
||||||
|
ls = await collect_local_stream(dispatcher, "example.stream_chat", payload)
|
||||||
|
check("空流终止语义一致(不挂起、块数相同)",
|
||||||
|
len(zs) == len(ls), f"zenoh={len(zs)} local={len(ls)}")
|
||||||
|
finally:
|
||||||
|
worker_task.cancel()
|
||||||
|
try:
|
||||||
|
await worker_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
unregister_dispatcher(WORKER_TYPE, WORKER_ID)
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
print(f"\n结果: {len(failures)} 个失败" + (f" -> {failures}" if failures else ""))
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
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 # chongming-zenoh/
|
||||||
|
_PROJ = _HERE
|
||||||
|
for _p in (_PROJ / "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()))
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""
|
||||||
|
HTTP 实例定向头(X-Chongming-Instance)透传测试
|
||||||
|
|
||||||
|
依赖隔离:桩掉 chongming 包链、auth 与 chongming_cache,直接加载真实
|
||||||
|
route_generator.py 与 worker_manager.py,用 FastAPI TestClient 验证:
|
||||||
|
1. 携带 X-Chongming-Instance 的请求,该实例 id 透传到 call_raw / call_stream
|
||||||
|
2. 未携带该头时 instance_id 为 None(行为与改动前完全一致,兼容旧调用方)
|
||||||
|
3. 覆盖全部端点形态:GET query / POST body / validate=false 原样透传 / SSE 流式
|
||||||
|
4. 头值为空白时视为未指定
|
||||||
|
5. 定向到离线实例(RuntimeError)-> HTTP 503,错误信息透出
|
||||||
|
|
||||||
|
运行: .venv/bin/python test/test_instance_targeting_http.py
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
TEST_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = TEST_DIR.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||||||
|
|
||||||
|
# ---- 依赖隔离:桩掉 chongming 包链 / auth / chongming_cache ----
|
||||||
|
_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
|
||||||
|
_cache_stub = types.ModuleType("chongming_cache")
|
||||||
|
_cache_stub.Cache = object
|
||||||
|
_cache_stub.DistributedLock = object # worker_manager 顶层导入(本测试不触达)
|
||||||
|
for mod in (_pkg, _app, _core, _logger_stub, _auth_stub, _cache_stub):
|
||||||
|
sys.modules[mod.__name__] = mod
|
||||||
|
|
||||||
|
_wm_path = REPO_ROOT / "src" / "chongming" / "app" / "core" / "worker_manager.py"
|
||||||
|
_wm_spec = importlib.util.spec_from_file_location(
|
||||||
|
"chongming.app.core.worker_manager", _wm_path
|
||||||
|
)
|
||||||
|
_wm_mod = importlib.util.module_from_spec(_wm_spec)
|
||||||
|
sys.modules["chongming.app.core.worker_manager"] = _wm_mod
|
||||||
|
_wm_spec.loader.exec_module(_wm_mod) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
_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]
|
||||||
|
|
||||||
|
_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
|
||||||
|
INSTANCE_HEADER = _rg_mod.INSTANCE_HEADER
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""记录每次调用收到的 instance_id;可指定定点失败以验证 503 映射。"""
|
||||||
|
|
||||||
|
def __init__(self, fail_instance=None):
|
||||||
|
self.fail_instance = fail_instance # 指定实例报错(模拟实例离线)
|
||||||
|
self.raw_instances = [] # call_raw 收到的 instance_id
|
||||||
|
self.stream_instances = [] # call_stream 收到的 instance_id
|
||||||
|
|
||||||
|
async def call_raw(self, worker_type, subject, payload, timeout=3.0,
|
||||||
|
retries=1, cacheable=False, instance_id=None):
|
||||||
|
self.raw_instances.append(instance_id)
|
||||||
|
if instance_id and instance_id == self.fail_instance:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"类型 '{worker_type}' 的实例 '{instance_id}' 没有在线副本"
|
||||||
|
"(定向调用不跨实例回退)。"
|
||||||
|
)
|
||||||
|
return orjson.dumps({"op": subject, "instance": instance_id})
|
||||||
|
|
||||||
|
async def call_stream(self, worker_type, subject, payload, timeout=300.0,
|
||||||
|
idle_timeout=10.0, instance_id=None):
|
||||||
|
self.stream_instances.append(instance_id)
|
||||||
|
if instance_id and instance_id == self.fail_instance:
|
||||||
|
raise RuntimeError(f"实例 '{instance_id}' 没有在线副本")
|
||||||
|
|
||||||
|
async def _gen():
|
||||||
|
yield orjson.dumps({"instance": instance_id})
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
|
||||||
|
ROUTES = [
|
||||||
|
{"subject": "t.get", "path": "/calc/add", "method": "GET",
|
||||||
|
"params": ["a: float", "b: float"], "summary": "add",
|
||||||
|
"timeout": 3.0, "auth_required": False},
|
||||||
|
{"subject": "t.post", "path": "/calc/echo", "method": "POST",
|
||||||
|
"params": ["msg: str"], "summary": "echo",
|
||||||
|
"timeout": 3.0, "auth_required": False},
|
||||||
|
{"subject": "t.raw", "path": "/calc/raw", "method": "POST",
|
||||||
|
"params": ["payload: any"], "summary": "原样透传",
|
||||||
|
"validate": False, "timeout": 3.0, "auth_required": False},
|
||||||
|
{"subject": "t.stream", "path": "/stream/chat", "method": "POST",
|
||||||
|
"params": ["prompt: str"], "summary": "sse",
|
||||||
|
"stream": True, "timeout": 60.0, "stream_idle_timeout": 1.0,
|
||||||
|
"auth_required": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_client(fwm):
|
||||||
|
app = FastAPI()
|
||||||
|
register_route_group(app, fwm, "demotype", ROUTES, prefix="/api/v1")
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# ── 1. GET query 端点 ────────────────────────────────────
|
||||||
|
fwm = FakeWorkerManager()
|
||||||
|
c = build_client(fwm)
|
||||||
|
r = c.get("/api/v1/calc/add?a=1&b=2", headers={INSTANCE_HEADER: "edge-01"})
|
||||||
|
check("1.1 GET 携带头 -> 200", r.status_code == 200, f"status={r.status_code}")
|
||||||
|
check("1.2 GET 头透传到 call_raw", fwm.raw_instances == ["edge-01"],
|
||||||
|
f"instances={fwm.raw_instances}")
|
||||||
|
check("1.3 响应体回带实例(证明打到目标实例)",
|
||||||
|
r.json().get("instance") == "edge-01", f"body={r.json()}")
|
||||||
|
|
||||||
|
# ── 2. 未携带头:兼容旧调用方 ────────────────────────────
|
||||||
|
fwm = FakeWorkerManager()
|
||||||
|
c = build_client(fwm)
|
||||||
|
r = c.get("/api/v1/calc/add?a=1&b=2")
|
||||||
|
check("2.1 未携带头 -> 200(默认随机均衡路径不变)", r.status_code == 200)
|
||||||
|
check("2.2 未携带头时 instance_id=None", fwm.raw_instances == [None],
|
||||||
|
f"instances={fwm.raw_instances}")
|
||||||
|
check("2.3 响应体不含实例", r.json().get("instance") is None, f"body={r.json()}")
|
||||||
|
|
||||||
|
# ── 3. POST body 端点 ────────────────────────────────────
|
||||||
|
fwm = FakeWorkerManager()
|
||||||
|
c = build_client(fwm)
|
||||||
|
r = c.post("/api/v1/calc/echo", json={"msg": "hi"},
|
||||||
|
headers={INSTANCE_HEADER: "edge-02"})
|
||||||
|
check("3.1 POST body 头透传", fwm.raw_instances == ["edge-02"],
|
||||||
|
f"instances={fwm.raw_instances}")
|
||||||
|
|
||||||
|
# ── 4. validate=false 原样透传端点 ───────────────────────
|
||||||
|
fwm = FakeWorkerManager()
|
||||||
|
c = build_client(fwm)
|
||||||
|
r = c.post("/api/v1/calc/raw", content=b'{"anything": [1, 2]}',
|
||||||
|
headers={INSTANCE_HEADER: "edge-03",
|
||||||
|
"content-type": "application/json"})
|
||||||
|
check("4.1 原样透传端点头透传", fwm.raw_instances == ["edge-03"],
|
||||||
|
f"instances={fwm.raw_instances}")
|
||||||
|
|
||||||
|
# ── 5. SSE 流式端点 ──────────────────────────────────────
|
||||||
|
fwm = FakeWorkerManager()
|
||||||
|
c = build_client(fwm)
|
||||||
|
with c.stream("POST", "/api/v1/stream/chat", json={"prompt": "x"},
|
||||||
|
headers={INSTANCE_HEADER: "edge-04"}) as r:
|
||||||
|
body = b"".join(r.iter_bytes())
|
||||||
|
check("5.1 流式端点头透传", fwm.stream_instances == ["edge-04"],
|
||||||
|
f"instances={fwm.stream_instances}")
|
||||||
|
check("5.2 流式响应含目标实例", b"edge-04" in body, f"body={body[:80]!r}")
|
||||||
|
|
||||||
|
# ── 6. 空白头值视为未指定 ────────────────────────────────
|
||||||
|
fwm = FakeWorkerManager()
|
||||||
|
c = build_client(fwm)
|
||||||
|
r = c.get("/api/v1/calc/add?a=1&b=2", headers={INSTANCE_HEADER: " "})
|
||||||
|
check("6.1 空白头值 -> instance_id=None", fwm.raw_instances == [None],
|
||||||
|
f"instances={fwm.raw_instances}")
|
||||||
|
|
||||||
|
# ── 7. 定向实例离线 -> 503 ───────────────────────────────
|
||||||
|
fwm = FakeWorkerManager(fail_instance="edge-gone")
|
||||||
|
c = build_client(fwm)
|
||||||
|
r = c.get("/api/v1/calc/add?a=1&b=2", headers={INSTANCE_HEADER: "edge-gone"})
|
||||||
|
check("7.1 定向实例离线 -> 503", r.status_code == 503, f"status={r.status_code}")
|
||||||
|
check("7.2 503 详情点名实例", "edge-gone" in str(r.json().get("detail", "")),
|
||||||
|
f"detail={r.json().get('detail')}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"FAILED: {len(failures)} 项 -> {failures}")
|
||||||
|
return 1
|
||||||
|
print("全部通过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -33,7 +33,8 @@ class FakeWM:
|
|||||||
self.calls = []
|
self.calls = []
|
||||||
self.result = {"result": "ok"}
|
self.result = {"result": "ok"}
|
||||||
|
|
||||||
async def call(self, worker_type, subject, payload, timeout=30, cacheable=False):
|
async def call(self, worker_type, subject, payload, timeout=30, cacheable=False,
|
||||||
|
instance_id=None):
|
||||||
self.calls.append(
|
self.calls.append(
|
||||||
{
|
{
|
||||||
"worker_type": worker_type,
|
"worker_type": worker_type,
|
||||||
@@ -41,6 +42,7 @@ class FakeWM:
|
|||||||
"payload": payload,
|
"payload": payload,
|
||||||
"timeout": timeout,
|
"timeout": timeout,
|
||||||
"cacheable": cacheable,
|
"cacheable": cacheable,
|
||||||
|
"instance_id": instance_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.result
|
return self.result
|
||||||
@@ -153,6 +155,41 @@ async def test_flat_payload_routed():
|
|||||||
print("✅ 非信封(扁平)载荷按原始报文路由通过")
|
print("✅ 非信封(扁平)载荷按原始报文路由通过")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_target_instance():
|
||||||
|
"""实例定向:载荷顶层 target_instance 作为控制字段摘除并透传给 worker_manager.call"""
|
||||||
|
wm = FakeWM()
|
||||||
|
bridge, published = make_bridge(
|
||||||
|
wm, [{"topic": "devices/+/telemetry", "worker": "device"}]
|
||||||
|
)
|
||||||
|
# 1. 带 target_instance:透传实例名,且控制字段不进业务 payload
|
||||||
|
await bridge._handle(make_msg(
|
||||||
|
"devices/d1/telemetry",
|
||||||
|
{"meta": {}, "data": {"v": 1}, "target_instance": "edge-01"},
|
||||||
|
))
|
||||||
|
assert wm.calls[-1]["instance_id"] == "edge-01", wm.calls[-1]
|
||||||
|
assert "target_instance" not in wm.calls[-1]["payload"], wm.calls[-1]["payload"]
|
||||||
|
assert wm.calls[-1]["payload"]["data"] == {"v": 1}
|
||||||
|
|
||||||
|
# 2. 不带该字段:instance_id 为 None(跨实例随机均衡,与改动前一致)
|
||||||
|
await bridge._handle(make_msg("devices/d1/telemetry", {"meta": {}, "data": {"v": 1}}))
|
||||||
|
assert wm.calls[-1]["instance_id"] is None, wm.calls[-1]
|
||||||
|
|
||||||
|
# 3. 空白值视为未指定
|
||||||
|
await bridge._handle(make_msg(
|
||||||
|
"devices/d1/telemetry",
|
||||||
|
{"meta": {}, "data": {"v": 1}, "target_instance": " "},
|
||||||
|
))
|
||||||
|
assert wm.calls[-1]["instance_id"] is None, wm.calls[-1]
|
||||||
|
|
||||||
|
# 4. 内置 chongming/... 主题同样支持
|
||||||
|
await bridge._handle(make_msg(
|
||||||
|
f"{MQTT_TOPIC_PREFIX}/example/add",
|
||||||
|
{"meta": {}, "data": {"a": 1}, "target_instance": "edge-02"},
|
||||||
|
))
|
||||||
|
assert wm.calls[-1]["instance_id"] == "edge-02", wm.calls[-1]
|
||||||
|
print("✅ 实例定向(target_instance 控制字段)透传通过")
|
||||||
|
|
||||||
|
|
||||||
async def test_non_json_dropped():
|
async def test_non_json_dropped():
|
||||||
wm = FakeWM()
|
wm = FakeWM()
|
||||||
bridge, published = make_bridge(
|
bridge, published = make_bridge(
|
||||||
@@ -219,6 +256,7 @@ async def main():
|
|||||||
await test_custom_routing()
|
await test_custom_routing()
|
||||||
await test_envelope_meta_fields()
|
await test_envelope_meta_fields()
|
||||||
await test_flat_payload_routed()
|
await test_flat_payload_routed()
|
||||||
|
await test_target_instance()
|
||||||
await test_non_json_dropped()
|
await test_non_json_dropped()
|
||||||
await test_board_ping_topic()
|
await test_board_ping_topic()
|
||||||
test_plugin_config_driven_collection()
|
test_plugin_config_driven_collection()
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
D. WorkerManager 单测(FakeSession):
|
D. WorkerManager 单测(FakeSession):
|
||||||
9. call_raw 零解析返回原始字节;call() 仍解析为 dict(MQTT 语义保留)
|
9. call_raw 零解析返回原始字节;call() 仍解析为 dict(MQTT 语义保留)
|
||||||
10. cacheable: 缓存原始 bytes、二次命中不再发查询、业务错误不缓存
|
10. cacheable: 缓存原始 bytes、二次命中不再发查询、业务错误不缓存
|
||||||
11. reply.err -> 重试 -> RuntimeError;空回复兜底 b"{}"
|
11. reply.err -> 换副本重试/无副本可换时立即 RuntimeError;空回复兜底 b"{}"
|
||||||
|
|
||||||
运行: uv run python test_passthrough_optimization.py
|
运行: uv run python test_passthrough_optimization.py
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import random
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
@@ -32,7 +33,7 @@ import orjson
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ _auth_stub = types.ModuleType("chongming.app.core.auth")
|
|||||||
_auth_stub.store_user_to_request = lambda x: x # 仅用于 Depends,桩掉鉴权
|
_auth_stub.store_user_to_request = lambda x: x # 仅用于 Depends,桩掉鉴权
|
||||||
_cache_stub = types.ModuleType("chongming_cache")
|
_cache_stub = types.ModuleType("chongming_cache")
|
||||||
_cache_stub.Cache = object
|
_cache_stub.Cache = object
|
||||||
|
_cache_stub.DistributedLock = object # worker_manager 顶层导入(本测试不触达)
|
||||||
for mod in (_pkg, _app, _core, _logger_stub, _auth_stub, _cache_stub):
|
for mod in (_pkg, _app, _core, _logger_stub, _auth_stub, _cache_stub):
|
||||||
sys.modules[mod.__name__] = mod
|
sys.modules[mod.__name__] = mod
|
||||||
|
|
||||||
@@ -101,7 +103,8 @@ class FakeWorkerManager:
|
|||||||
self.error = error
|
self.error = error
|
||||||
self.call_raw_payloads = [] # 捕获的预序列化请求字节
|
self.call_raw_payloads = [] # 捕获的预序列化请求字节
|
||||||
|
|
||||||
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False):
|
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False,
|
||||||
|
instance_id=None):
|
||||||
self.call_raw_payloads.append(payload)
|
self.call_raw_payloads.append(payload)
|
||||||
if self.error == "no_worker":
|
if self.error == "no_worker":
|
||||||
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
||||||
@@ -282,16 +285,27 @@ def test_openapi_request_body_injected():
|
|||||||
# ---- D. WorkerManager 单测(FakeSession)----
|
# ---- D. WorkerManager 单测(FakeSession)----
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
"""模拟 zenoh Session:get() 返回单回复迭代器"""
|
"""模拟 zenoh Session:get() 返回单回复迭代器
|
||||||
|
|
||||||
def __init__(self, payload=b'{"result": 5}', err_payload=None):
|
err_payload 非空时整体报错;err_target 给定时只对该 key 前缀报错(其余正常回复),
|
||||||
|
用于验证「失败换副本重试」。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, payload=b'{"result": 5}', err_payload=None, err_target=None):
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.err_payload = err_payload
|
self.err_payload = err_payload
|
||||||
|
self.err_target = err_target
|
||||||
self.get_calls = 0
|
self.get_calls = 0
|
||||||
|
self.call_keys = []
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
self.get_calls += 1
|
self.get_calls += 1
|
||||||
if self.err_payload is not None:
|
key = args[0] if args else kwargs.get("key_expr", "")
|
||||||
|
self.call_keys.append(str(key))
|
||||||
|
failing = self.err_payload is not None and (
|
||||||
|
self.err_target is None or str(key).startswith(self.err_target)
|
||||||
|
)
|
||||||
|
if failing:
|
||||||
reply = SimpleNamespace(ok=None, err=SimpleNamespace(payload=self.err_payload))
|
reply = SimpleNamespace(ok=None, err=SimpleNamespace(payload=self.err_payload))
|
||||||
else:
|
else:
|
||||||
reply = SimpleNamespace(ok=SimpleNamespace(payload=self.payload), err=None)
|
reply = SimpleNamespace(ok=SimpleNamespace(payload=self.payload), err=None)
|
||||||
@@ -341,14 +355,49 @@ async def test_wm_cache_bytes():
|
|||||||
|
|
||||||
|
|
||||||
async def test_wm_reply_err_retry():
|
async def test_wm_reply_err_retry():
|
||||||
|
# 单副本:无其它副本可换 —— 立即失败(不空转重试同一个坏副本),保留具体错误
|
||||||
session = FakeSession(err_payload=b'{"error": "boom"}')
|
session = FakeSession(err_payload=b'{"error": "boom"}')
|
||||||
wm = make_wm(session)
|
wm = make_wm(session)
|
||||||
try:
|
try:
|
||||||
await wm.call_raw("demo", "calc.add", b'{}')
|
await wm.call_raw("demo", "calc.add", b'{}')
|
||||||
check("reply.err 应抛 RuntimeError", False)
|
check("reply.err 应抛 RuntimeError", False)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
check("reply.err 重试后抛 RuntimeError", "boom" in str(e), str(e))
|
check("reply.err 抛 RuntimeError 且保留具体错误", "boom" in str(e), str(e))
|
||||||
check("reply.err 触发重试(2 次查询)", session.get_calls == 2, str(session.get_calls))
|
check("单副本无其它可换时不空转重试(1 次查询)",
|
||||||
|
session.get_calls == 1, str(session.get_calls))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wm_reply_err_switches_worker():
|
||||||
|
"""多副本:先打的副本报错则换另一个副本重试,且不重复打已失败的副本。
|
||||||
|
|
||||||
|
首个副本由 random.choice 决定,故对多个 seed 试跑:必有一个覆盖「先失败后成功」
|
||||||
|
路径(另一个覆盖「首个即成功」),两条路径都不允许重复打同一个失败的副本。
|
||||||
|
"""
|
||||||
|
ok, detail = False, "无 seed 覆盖到失败换副本路径"
|
||||||
|
for seed in (0, 1):
|
||||||
|
random.seed(seed)
|
||||||
|
session = FakeSession(
|
||||||
|
payload=b'{"result": 5}', err_payload=b'{"error": "boom"}',
|
||||||
|
err_target="worker/demo/w-dead",
|
||||||
|
)
|
||||||
|
wm = make_wm(session)
|
||||||
|
wm._workers_cache = {"demo": ["worker/demo/w-dead", "worker/demo/w-alive"]}
|
||||||
|
try:
|
||||||
|
result = await wm.call_raw("demo", "calc.add", b'{}', retries=2)
|
||||||
|
except RuntimeError as e: # 未换到正常副本(不该发生)
|
||||||
|
detail = f"seed={seed} 意外失败: {e}"
|
||||||
|
continue
|
||||||
|
assert result == b'{"result": 5}', result
|
||||||
|
dead_calls = session.call_keys.count("worker/demo/w-dead/calc.add")
|
||||||
|
if dead_calls > 1:
|
||||||
|
detail = f"seed={seed} 重复打失败副本: {session.call_keys}"
|
||||||
|
continue
|
||||||
|
if session.call_keys == [
|
||||||
|
"worker/demo/w-dead/calc.add",
|
||||||
|
"worker/demo/w-alive/calc.add",
|
||||||
|
]:
|
||||||
|
ok, detail = True, f"seed={seed} keys={session.call_keys}"
|
||||||
|
check("失败副本 -> 换副本重试成功(且不重复打失败副本)", ok, detail)
|
||||||
|
|
||||||
|
|
||||||
async def test_wm_empty_reply_fallback():
|
async def test_wm_empty_reply_fallback():
|
||||||
@@ -375,6 +424,7 @@ def run():
|
|||||||
asyncio.run(test_wm_call_dict_semantics())
|
asyncio.run(test_wm_call_dict_semantics())
|
||||||
asyncio.run(test_wm_cache_bytes())
|
asyncio.run(test_wm_cache_bytes())
|
||||||
asyncio.run(test_wm_reply_err_retry())
|
asyncio.run(test_wm_reply_err_retry())
|
||||||
|
asyncio.run(test_wm_reply_err_switches_worker())
|
||||||
asyncio.run(test_wm_empty_reply_fallback())
|
asyncio.run(test_wm_empty_reply_fallback())
|
||||||
|
|
||||||
print(f"\n结果: {len(failures)} 个失败")
|
print(f"\n结果: {len(failures)} 个失败")
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
|||||||
import orjson
|
import orjson
|
||||||
import zenoh
|
import zenoh
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ _logger_stub = types.ModuleType("chongming.app.core.logger")
|
|||||||
_logger_stub.get_logger = lambda name: logging.getLogger(name)
|
_logger_stub.get_logger = lambda name: logging.getLogger(name)
|
||||||
_cache_stub = types.ModuleType("chongming_cache")
|
_cache_stub = types.ModuleType("chongming_cache")
|
||||||
_cache_stub.Cache = object
|
_cache_stub.Cache = object
|
||||||
|
_cache_stub.DistributedLock = object # worker_manager 顶层导入(本测试不触达)
|
||||||
for mod in (_pkg, _app, _core, _logger_stub, _cache_stub):
|
for mod in (_pkg, _app, _core, _logger_stub, _cache_stub):
|
||||||
sys.modules[mod.__name__] = mod
|
sys.modules[mod.__name__] = mod
|
||||||
|
|
||||||
@@ -67,7 +68,8 @@ def check(name, cond, detail=""):
|
|||||||
failures.append(name)
|
failures.append(name)
|
||||||
|
|
||||||
|
|
||||||
LISTEN_PORT = 7447
|
import os
|
||||||
|
LISTEN_PORT = int(os.environ.get("CM_STREAM_TEST_PORT", "7447"))
|
||||||
LISTEN = f"tcp/127.0.0.1:{LISTEN_PORT}"
|
LISTEN = f"tcp/127.0.0.1:{LISTEN_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import orjson
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ _auth_stub = types.ModuleType("chongming.app.core.auth")
|
|||||||
_auth_stub.store_user_to_request = lambda x: x # 仅用于 Depends,桩掉鉴权
|
_auth_stub.store_user_to_request = lambda x: x # 仅用于 Depends,桩掉鉴权
|
||||||
_cache_stub = types.ModuleType("chongming_cache")
|
_cache_stub = types.ModuleType("chongming_cache")
|
||||||
_cache_stub.Cache = object
|
_cache_stub.Cache = object
|
||||||
|
_cache_stub.DistributedLock = object # worker_manager 顶层导入(本测试不触达)
|
||||||
for mod in (_pkg, _app, _core, _logger_stub, _auth_stub, _cache_stub):
|
for mod in (_pkg, _app, _core, _logger_stub, _auth_stub, _cache_stub):
|
||||||
sys.modules[mod.__name__] = mod
|
sys.modules[mod.__name__] = mod
|
||||||
|
|
||||||
@@ -90,18 +91,21 @@ class FakeWorkerManager:
|
|||||||
self.call_payloads = []
|
self.call_payloads = []
|
||||||
self.stream_payloads = []
|
self.stream_payloads = []
|
||||||
|
|
||||||
async def call(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False):
|
async def call(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False,
|
||||||
|
instance_id=None):
|
||||||
self.call_payloads.append((worker_type, subject, payload))
|
self.call_payloads.append((worker_type, subject, payload))
|
||||||
data = payload["data"]
|
data = payload["data"]
|
||||||
return {"result": data.get("a", 0) + data.get("b", 0)}
|
return {"result": data.get("a", 0) + data.get("b", 0)}
|
||||||
|
|
||||||
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False):
|
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False,
|
||||||
|
instance_id=None):
|
||||||
# 网关透传路径:payload 为预序列化字节,回复原始字节
|
# 网关透传路径:payload 为预序列化字节,回复原始字节
|
||||||
self.call_payloads.append((worker_type, subject, orjson.loads(payload)))
|
self.call_payloads.append((worker_type, subject, orjson.loads(payload)))
|
||||||
data = orjson.loads(payload)["data"]
|
data = orjson.loads(payload)["data"]
|
||||||
return orjson.dumps({"result": data.get("a", 0) + data.get("b", 0)})
|
return orjson.dumps({"result": data.get("a", 0) + data.get("b", 0)})
|
||||||
|
|
||||||
async def call_stream(self, worker_type, subject, payload, timeout=300.0, idle_timeout=10.0):
|
async def call_stream(self, worker_type, subject, payload, timeout=300.0, idle_timeout=10.0,
|
||||||
|
instance_id=None):
|
||||||
if self.stream_error == "no_worker":
|
if self.stream_error == "no_worker":
|
||||||
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
|
||||||
self.stream_payloads.append((worker_type, subject, payload))
|
self.stream_payloads.append((worker_type, subject, payload))
|
||||||
|
|||||||
Reference in New Issue
Block a user