add: worker 侧运行时下沉(route_config/contracts/routes_publisher/process 入口,零网关依赖)

This commit is contained in:
2026-08-14 17:50:08 +08:00
parent 4908378e59
commit 589c2534d6
5 changed files with 575 additions and 0 deletions
+4
View File
@@ -5,8 +5,12 @@ requires = ["hatchling"]
[project] [project]
dependencies = [ dependencies = [
"eclipse-zenoh>=1.9.0", "eclipse-zenoh>=1.9.0",
"pydantic>=2.0",
"tomli>=2.4.1", "tomli>=2.4.1",
] ]
[project.scripts]
chongming-worker = "chongming_worker.process:worker_serve"
description = "chongming worker" description = "chongming worker"
name = "chongming-worker" name = "chongming-worker"
readme = "README.md" readme = "README.md"
+189
View File
@@ -0,0 +1,189 @@
"""
Worker 侧契约校验(网关与 worker 进程共享,唯一实现)
校验三方一致性:config/workers TOML 路由 ↔ @rpc_method 注册表 ↔ gen_models 模型。
网关 inline 模式启动时对 inline 插件批量校验(见网关 contracts.py 封装);
worker 进程 external 模式在自己进程内对自身校验(validate_worker_contracts)。
策略:
- 硬失败(汇总后 raise):
HTTP 路由(GET/POST/PUT/DELETE/PATCH 且 internal != true)在注册表中
缺少对应 subject#METHOD 的 @rpc_method → 该路由运行时必然 503。
- 软警告(仅日志汇总,不阻断):
[MQTT] / internal 路由缺少注册、已注册方法无 TOML 路由、
TOML 声明 params/response_model 但生成模型缺失(gen_models 过期)、孤儿模型。
"""
import importlib
import logging
from typing import Any, Dict, List, Optional, Set
from chongming_worker.decorators import get_all_rpc_methods
from chongming_worker.route_config import load_worker_routes_from_dir, model_name
logger = logging.getLogger("chongming_worker.contracts")
# 参与 HTTP 路由注册的方法(与网关 route_generator 的注册行为一致)
HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")
def load_plugin_models(worker_type: str) -> Optional[Set[str]]:
"""
加载插件 models.pydantic 中所有 Pydantic 模型的类名集合。
按序尝试 inline 插件(src/plugins/)与进程插件(workers/)路径;
返回 None 表示模块不存在(插件无生成模型,跳过模型相关校验)。
注: gen_models 生成的 `<Plugin>Response` 联合类型——多模型时为 UnionType
(自动被 isinstance(type) 过滤),单模型时是类的别名(与真实模型同一对象,
用 obj.__name__ 收敛到真实模型名,别名不会抢占排序位)。
"""
from pydantic import BaseModel
for mod_name in (
f"plugins.{worker_type}.models.pydantic",
f"src.plugins.{worker_type}.models.pydantic",
f"workers.{worker_type}.models.pydantic",
):
try:
mod = importlib.import_module(mod_name)
except ImportError:
continue
names: Set[str] = set()
seen_objs: Set[int] = set()
for name in dir(mod):
obj = getattr(mod, name)
if (
isinstance(obj, type)
and issubclass(obj, BaseModel)
and obj is not BaseModel
and id(obj) not in seen_objs
):
seen_objs.add(id(obj))
names.add(obj.__name__)
return names
return None
def check_plugin(
worker_type: str,
toml_routes: List[Dict[str, Any]],
registered: Dict[str, Any],
model_names: Optional[Set[str]],
) -> tuple[List[str], List[str]]:
"""
对单个插件执行 TOML ↔ 注册表 ↔ 生成模型一致性校验。
Args:
worker_type: 插件类型
toml_routes: load_worker_routes_from_dir() 输出的路由列表
registered: get_all_rpc_methods().get(worker_type, {}) 注册表
model_names: 插件 models.pydantic 的模型名集合(None 表示无生成模型)
Returns:
(hard_errors, warnings): 硬错误与软警告消息列表
"""
hard_errors: List[str] = []
warnings: List[str] = []
toml_keys = {f"{r['subject']}#{r['method']}" for r in toml_routes}
for r in toml_routes:
key = f"{r['subject']}#{r['method']}"
route_desc = f"{r['method']} {r['path']} -> subject '{r['subject']}'"
if key in registered:
continue
if r["method"] in HTTP_METHODS and not r.get("internal", False):
# 硬失败:运行时必然 503
hint = ""
if not registered:
hint = "(该插件注册表为空,请检查插件导入日志)"
hard_errors.append(
f"[{worker_type}] {route_desc} 缺少对应的 @rpc_method 注册{hint}"
)
else:
# 软警告:MQTT / internal 端点缺失注册
warnings.append(
f"[{worker_type}] {route_desc} 无对应的 @rpc_method(method='{r['method']}') 注册"
)
# 已注册方法无 TOML 路由(反向漂移检测)
for key in registered:
if key not in toml_keys:
warnings.append(
f"[{worker_type}] 已注册 '{key}' 但无对应 TOML 路由定义"
)
# 模型一致性(仅当插件存在生成模型时校验)
if model_names is not None:
referenced: Set[str] = set()
for r in toml_routes:
route_name = r.get("route_name", "")
if not route_name:
continue
method = r["method"]
if r.get("params"):
expected = model_name(route_name, method, "Request")
referenced.add(expected)
if expected not in model_names:
warnings.append(
f"[{worker_type}] '{r['subject']}' [{method}] 声明了 params,"
f"但模型 {expected} 不存在(可能未运行 gen_models)"
)
if r.get("response_model"):
expected = model_name(route_name, method, "Response")
referenced.add(expected)
if expected not in model_names:
warnings.append(
f"[{worker_type}] '{r['subject']}' [{method}] 声明了 response_model,"
f"但模型 {expected} 不存在(可能未运行 gen_models)"
)
for name in sorted(model_names - referenced):
warnings.append(
f"[{worker_type}] 模型 {name} 未被任何 TOML 路由引用"
f"(孤儿模型,可重新运行 gen_models 清理)"
)
return hard_errors, warnings
def validate_worker_contracts(worker_type: str) -> List[Dict[str, Any]]:
"""
Worker 进程侧契约校验(external 模式):校验单个插件并返回可发布的路由描述符。
调用时机:worker 进程 import 插件模块后(@rpc_method 注册表就绪)、
发布路由描述符之前。
Args:
worker_type: 插件类型(与 config/workers/<type> 目录一致)
Returns:
通过校验的 TOML 路由描述符列表(直接用于 RoutePublisher 发布)
Raises:
RuntimeError: 存在硬失败项(HTTP 路由缺失 @rpc_method 注册)时,
调用方应终止 worker 进程,避免发布残缺路由。
"""
registered = get_all_rpc_methods().get(worker_type, {})
toml_routes = load_worker_routes_from_dir(worker_type)
model_names = load_plugin_models(worker_type)
hard_errors, warnings = check_plugin(
worker_type, toml_routes, registered, model_names
)
for w in warnings:
logger.warning(" ⚠ %s", w)
if hard_errors:
logger.error("Worker 契约校验失败,发现 %d 个硬性错误:", len(hard_errors))
for e in hard_errors:
logger.error(" ✗ %s", e)
raise RuntimeError(
f"[{worker_type}] 契约校验失败: " + "; ".join(hard_errors)
)
logger.info(
"[%s] 契约校验通过,可发布 %d 个路由描述符", worker_type, len(toml_routes)
)
return toml_routes
+190
View File
@@ -0,0 +1,190 @@
"""
独立 Worker 进程运行时(与网关完全隔离)
进程插件与网关零导入关系:本模块只依赖 chongming_worker 自身、zenoh 与
标准库,不 import chongming(网关包)。进程插件容器 = 插件代码 + chongming-worker。
进程入口(console script):
chongming-worker --type <name> [--count N] [--config config.toml]
流程:
1. 读全局 config.toml(仅 [zenoh].json5_path)→ 打开 Zenoh 会话
2. 自读 config/workers/<type>/config.toml 的 [workers] 节(插件注册 + 运行时配置)
3. import 插件模块(plugins.<name> / workers.<name>)→ @rpc_method 注册
4. 进程内契约校验(TOML ↔ 注册表 ↔ 生成模型),硬失败则退出
5. create_workers 创建副本(count 进程内保留)
6. 经 Zenoh 发布路由描述符(chongming/routes/<type>/...),每 10s 周期重发布自愈
7. SIGTERM/SIGINT → 摘除路由 + cleanup + 关闭会话
"""
import argparse
import asyncio
import inspect
import logging
import os
import signal
import sys
import tomllib
import zenoh
from chongming_worker.contracts import validate_worker_contracts
from chongming_worker.routes_publisher import RoutePublisher
logger = logging.getLogger("chongming_worker.process")
# 路由描述符周期性重发布间隔(秒):网关后启动/样本丢失时自愈
ANNOUNCE_INTERVAL = 10.0
def load_plugin_config(worker_type: str, config_dir: str = "config/workers") -> dict:
"""
读取单个插件的 config.toml [workers] 节并展平(与网关 _discover_plugins
的展平规则一致,worker 侧独立实现,不依赖网关)。
Returns:
{"type", "module", "factory", "cleanup", "count", "load_mode",
"mqtt_topics", "config": 展平后的运行时配置}
"""
import pathlib
path = pathlib.Path(config_dir) / worker_type / "config.toml"
with open(path, "rb") as f:
plugin_conf = tomllib.load(f)
workers_section = plugin_conf.get("workers", {})
if not workers_section:
raise ValueError(f"{path} 缺少 [workers] 节")
plugin_type = workers_section.get("type", worker_type)
load_mode = workers_section.get("load_mode", "inline")
mqtt_topics = []
for t in workers_section.get("mqtt_topics", []) or []:
if not isinstance(t, dict) or not t.get("topic"):
continue
t = dict(t)
t.setdefault("worker", plugin_type)
mqtt_topics.append(t)
workers_flat = {k: v for k, v in workers_section.items()
if k not in ("config", "mqtt_topics", "load_mode")}
runtime_config = {
**plugin_conf,
**workers_flat,
**workers_section.get("config", {}),
}
return {
"type": plugin_type,
"module": workers_section.get("module", f"plugins.{plugin_type}"),
"factory": workers_section.get("factory", "create_workers"),
"cleanup": workers_section.get("cleanup", "cleanup"),
"count": workers_section.get("count", 1),
"load_mode": load_mode,
"mqtt_topics": mqtt_topics,
"config": runtime_config,
}
def worker_serve():
"""独立 Worker 进程入口(console script: chongming-worker / worker_serve)。"""
parser = argparse.ArgumentParser(description="chongming 独立 Worker 进程")
parser.add_argument("--type", required=True, help="插件类型(如 demo / example)")
parser.add_argument("--count", type=int, default=None, help="副本数(覆盖配置中的 count)")
parser.add_argument("--config", default="config.toml", help="全局配置文件路径(读取 [zenoh] 节)")
args = parser.parse_args()
# console script 启动时 sys.path 不含当前目录,显式加入以保证
# plugins.<name>(inline 插件)与 workers.<name>(进程插件)均可导入
project_root = os.path.abspath(os.getcwd())
if project_root not in sys.path:
sys.path.insert(0, project_root)
try:
wc = load_plugin_config(args.type)
except FileNotFoundError as e:
logger.error("未找到插件配置: %s", e)
sys.exit(1)
except ValueError as e:
logger.error("%s", e)
sys.exit(1)
if wc.get("load_mode", "inline") != "external":
logger.warning(
"[%s] 插件配置 load_mode 不是 \"external\":网关也会在进程内加载该插件,"
"外部进程将与网关内副本重复服务。若以独立进程运行,请在插件 "
"config.toml 的 [workers] 节设置 load_mode = \"external\"。",
args.type,
)
# 全局配置只读 [zenoh] 节(worker 不依赖网关的配置结构)
with open(args.config, "rb") as f:
global_config = tomllib.load(f)
json5_path = global_config.get("zenoh", {}).get("json5_path", "config/zenoh_config.json5")
async def _run():
# 1. Zenoh 会话
zconfig = zenoh.Config()
zconfig.from_file(json5_path)
session = zenoh.open(zconfig)
logger.info("[%s] Zenoh 会话已建立", args.type)
try:
# 2. import 插件(触发 @rpc_method 注册),然后契约校验
module = __import__(wc["module"], fromlist=[wc["factory"]])
routes = validate_worker_contracts(args.type)
# 3. 创建 Worker 副本(跨进程经 liveliness 发现,无需本地注册)
factory = getattr(module, wc["factory"])
count = args.count or wc.get("count", 1)
if inspect.iscoroutinefunction(factory):
tasks = await factory(session, wc["config"], count, worker_manager=None)
else:
tasks = factory(session, wc["config"], count, worker_manager=None)
# 4. 发布路由描述符(网关 RouteRegistry 订阅后动态挂载 HTTP 路由)
publisher = RoutePublisher(session, args.type)
mqtt_topics = wc.get("mqtt_topics") or []
publisher.publish_routes(routes, mqtt_topics)
# 5. 等待退出信号;期间周期性重发布(网关后启动/样本丢失时自愈)
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop_event.set)
async def _announce():
while not stop_event.is_set():
try:
await asyncio.wait_for(stop_event.wait(), timeout=ANNOUNCE_INTERVAL)
return
except asyncio.TimeoutError:
publisher.publish_routes(routes, mqtt_topics)
announce_task = asyncio.create_task(_announce())
logger.info("[%s] Worker 进程就绪,等待退出信号...", args.type)
await stop_event.wait()
announce_task.cancel()
await asyncio.gather(announce_task, return_exceptions=True)
# 6. 优雅退出:摘除路由 → cleanup → 取消副本任务 → 关闭会话
publisher.unpublish()
cleanup = getattr(module, wc.get("cleanup", ""), None)
if cleanup is not None:
worker_id = f"worker/{args.type}/worker-i-{os.getpid()}"
if inspect.iscoroutinefunction(cleanup):
await cleanup(worker_id)
else:
cleanup(worker_id)
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info("[%s] Worker 进程已退出", args.type)
finally:
session.close()
try:
asyncio.run(_run())
except KeyboardInterrupt:
pass
+103
View File
@@ -0,0 +1,103 @@
"""
Worker 路由 TOML 配置解析(网关与 worker 进程共享,唯一实现)
从 config/workers/<type>/ 目录解析路由描述符(TOML 兼容格式),
网关 inline 模式用于盘扫注册 FastAPI 路由,worker 进程 external 模式用于
自读配置 + 契约校验 + 发布描述符。两边共用本模块保证格式一致。
"""
import logging
import tomllib
from pathlib import Path
from typing import Any, Dict, List
logger = logging.getLogger("chongming_worker.route_config")
# 默认的 worker 配置目录
WORKER_CONFIG_DIR = "config/workers"
def model_name(base: str, method: str, suffix: str) -> str:
"""gen_models 模型命名约定: <Op><Method><Suffix>"""
method_title = "Mqtt" if method == "MQTT" else method.title()
return f"{base.title().replace('-', '').replace('_', '')}{method_title}{suffix}"
def load_worker_routes_from_dir(worker_type: str) -> List[Dict[str, Any]]:
"""
从 config/workers/{worker_type}/ 目录加载路由配置。
目录结构:
config/workers/{worker_type}/
config.toml # [worker] 分组信息 + registration + defaults
add.toml # 接口名 = 文件名(不含扩展名)
[GET]
summary = ...
params = [...]
response_model = { ... }
[POST]
... # 同一个接口支持多个请求方法
[MQTT]
... # MQTT 专用端点(不注册 HTTP 路由)
返回: List[Dict], 每个 dict 包含 flatten 后的路由配置,
每个方法节(如 GET/POST/MQTT) 生成一个独立的路由条目。
"""
base_dir = Path(WORKER_CONFIG_DIR) / worker_type
if not base_dir.is_dir():
logger.warning(f"Worker 配置目录不存在: {base_dir}")
return []
# 1. 读取分组配置
group_cfg_path = base_dir / "config.toml"
group_defaults: Dict[str, Any] = {}
registration: Dict[str, Any] = {}
router_prefix: str = ""
if group_cfg_path.exists():
with open(group_cfg_path, "rb") as f:
cfg = tomllib.load(f)
w = cfg.get("worker", {})
group_defaults = w.get("defaults", {})
registration = w.get("registration", {})
router_prefix = registration.get("router_prefix", "")
# 2. 遍历所有接口 toml 文件
routes: List[Dict[str, Any]] = []
for route_file in sorted(base_dir.glob("*.toml")):
if route_file.name == "config.toml":
continue
# 文件名(不含 .toml)作为路径名
route_name = route_file.stem # 例如 "add"
base_path = router_prefix + "/" + route_name # 例如 "/calc/add"
with open(route_file, "rb") as f:
route_data = tomllib.load(f)
# 每个方法节(GET/POST/PUT/DELETE)生成一个独立路由
for method, spec in route_data.items():
if not isinstance(spec, dict):
logger.warning(f"忽略非 dict 的方法节: {route_file.name}[{method}]")
continue
# 合并: spec 显式字段优先,然后 group_defaults 兜底
route = dict(spec)
for key, default_val in group_defaults.items():
if key not in route:
route[key] = default_val
# 覆盖关键字段 - method 始终从 TOML 节键获取,不被 defaults 覆盖
route["method"] = method.upper()
route["path"] = route.get("path", base_path)
if "subject" not in route:
# 自动生成 subject: worker_type.route_name (如 "calc.add")
route["subject"] = f"{worker_type}.{route_name}"
# 记录来源文件名(契约校验用其推断 gen_models 模型命名)
route["route_name"] = route_name
routes.append(route)
logger.debug(
f" 已加载: {route['method']} {route['path']} -> {route['subject']}"
)
logger.info(f"从 {base_dir} 加载了 {len(routes)} 个路由")
return routes
+89
View File
@@ -0,0 +1,89 @@
"""
路由描述符发布器(worker 进程侧,协议唯一实现)
独立 worker 进程在完成契约校验后,将路由描述符 put 到 Zenoh 的
`chongming/routes/<type>/...` 命名空间;进程优雅退出时 delete 摘除。
网关 RouteRegistry 订阅同名空间并动态挂载 HTTP 路由。
描述符 schema_version=1,字段与网关 RouteConfig 完全对位(TOML 兼容 dict)。
"""
import json
import logging
from typing import Any, Dict, List, Optional
import zenoh
logger = logging.getLogger("chongming_worker.routes_publisher")
ROUTES_PREFIX = "chongming/routes"
META_KEY = "__meta__"
SCHEMA_VERSION = 1
def route_id(subject: str, method: str) -> str:
"""route_id = subject 与 method 的编码(避免 keyexpr 特殊字符歧义)"""
return f"{subject.replace('.', '_')}__{method.upper()}"
class RoutePublisher:
"""发布/摘除某 worker_type 的路由描述符。"""
def __init__(self, session: zenoh.Session, worker_type: str):
self._session = session
self._worker_type = worker_type
self._keys: List[str] = []
def publish_routes(
self,
routes: List[Dict[str, Any]],
mqtt_topics: Optional[List[Dict[str, Any]]] = None,
):
"""
发布路由描述符。
Args:
routes: 契约校验通过的路由描述符列表(TOML 兼容格式)
mqtt_topics: 插件声明的 [[workers.mqtt_topics]](可空)
"""
for r in routes:
subject = r.get("subject", "")
method = r.get("method", "GET")
if not subject:
logger.warning("跳过无 subject 的路由描述符: %s", r)
continue
descriptor = dict(r)
descriptor["schema_version"] = SCHEMA_VERSION
descriptor["type"] = self._worker_type
key = f"{ROUTES_PREFIX}/{self._worker_type}/{route_id(subject, method)}"
self._session.put(key, json.dumps(descriptor, ensure_ascii=False).encode("utf-8"))
self._keys.append(key)
logger.info(
"已发布路由: %s %s (subject=%s)",
method.upper(), descriptor.get("path", "?"), subject,
)
if mqtt_topics:
meta_key = f"{ROUTES_PREFIX}/{self._worker_type}/{META_KEY}"
meta = {"schema_version": SCHEMA_VERSION, "mqtt_topics": mqtt_topics}
self._session.put(
meta_key, json.dumps(meta, ensure_ascii=False).encode("utf-8")
)
self._keys.append(meta_key)
logger.info("已发布插件元信息: %d 个自定义 MQTT 主题", len(mqtt_topics))
# 去重(周期性重发布会重复调用本方法)
self._keys = list(dict.fromkeys(self._keys))
logger.info(
"[%s] 路由发布完成: %d 端点 + %s", self._worker_type,
len(self._keys), "元信息" if mqtt_topics else "无元信息",
)
def unpublish(self):
"""摘除全部已发布的路由描述符(优雅退出时调用)。"""
for key in self._keys:
try:
self._session.delete(key)
except Exception as e: # pragma: no cover
logger.warning("摘除路由失败 [%s]: %s", key, e)
logger.info("[%s] 已摘除 %d 个路由描述符", self._worker_type, len(self._keys))
self._keys.clear()