modify: worker 管理多进程副本

This commit is contained in:
2026-08-28 16:19:31 +08:00
parent 2baef7cf2f
commit 2db6e24899
7 changed files with 589 additions and 146 deletions
+25 -5
View File
@@ -238,7 +238,7 @@ worker/<type>/<worker_id>/__admin__
| `restart` | 重启该副本,副本槽位(index)由管理循环驱动,重新生成新 `worker_id`(`worker-<index>-<epoch>-<pid>`)并经 liveliness 更新 | | `restart` | 重启该副本,副本槽位(index)由管理循环驱动,重新生成新 `worker_id`(`worker-<index>-<epoch>-<pid>`)并经 liveliness 更新 |
| `stop` | 停止该副本 | | `stop` | 停止该副本 |
| `drain` | **优雅排空**:摘除 liveliness/业务 Queryable 隔绝新请求,等待正在处理的在途请求完成后再退出,管理循环不重建(数量-1) | | `drain` | **优雅排空**:摘除 liveliness/业务 Queryable 隔绝新请求,等待正在处理的在途请求完成后再退出,管理循环不重建(数量-1) |
| `rescale` | 按 `data.scale` 把**当前进程内**该类型副本数调整为指定值(`data:{"scale":N}`),并把 `[workers].count` 写回本进程侧配置 | | `rescale` | 按 `data.scale` 把**当前进程内**该类型副本数调整为指定值(`data:{"scale":N}`);**两种口径**:不带 `instance_id` 时为「全局对称分摊」(写回全局 count + 各实例均分),带 `instance_id + absolute=true` 且命中本实例时为「逐实例精细扩缩」(只改该实例绝对副本数),target 非本实例则安全忽略 |
| `config_set`| 更新 `[workers.config]` 运行时配置(路由配置除外);成功则写回配置并重启该类型全部对应副本,任一失败返回失败信息 | | `config_set`| 更新 `[workers.config]` 运行时配置(路由配置除外);成功则写回配置并重启该类型全部对应副本,任一失败返回失败信息 |
### REST 管理端点(仅超管) ### REST 管理端点(仅超管)
@@ -250,7 +250,9 @@ worker/<type>/<worker_id>/__admin__
| `POST` | `/api/v1/admin/workers/<type>/stop` | 停止 single 副本 / 某插件全部副本 | | `POST` | `/api/v1/admin/workers/<type>/stop` | 停止 single 副本 / 某插件全部副本 |
| `GET` | `/api/v1/admin/workers/<type>/config` | 读取该插件的现有 `[workers.config]`(展示当前值) | | `GET` | `/api/v1/admin/workers/<type>/config` | 读取该插件的现有 `[workers.config]`(展示当前值) |
| `POST` | `/api/v1/admin/workers/<type>/config` | 更新配置(body `{updates:{k:v}}`),下发并重启 | | `POST` | `/api/v1/admin/workers/<type>/config` | 更新配置(body `{updates:{k:v}}`),下发并重启 |
| `POST` | `/api/v1/admin/workers/<type>/scale` | 调整该类型副本数(body `{count:N}`),扩缩容 + 持久化 | | `POST` | `/api/v1/admin/workers/<type>/scale` | 调整该类型副本数(body `{count:<全局总量>}`):写全局 count → 清除逐实例定制回均分 → 广播全部存活实例,**全局对称分摊**
| `GET` | `/api/v1/admin/workers/<type>/instances` | 按外部进程实例(instance_id)分组读取该类型副本(供前端逐实例精细扩缩展示) |
| `POST` | `/api/v1/admin/workers/<type>/scale_instance` | **逐实例精细扩缩**(body `{instance_id, count}`):只把指定实例的副本数调到 N,其余实例不动,持久化到 `instcounts` 覆盖表 |
> 旧版运行时未声明 `__admin__` 管理能力时,管理指令会超时(HTTP 502,见日志提示)。 > 旧版运行时未声明 `__admin__` 管理能力时,管理指令会超时(HTTP 502,见日志提示)。
@@ -267,7 +269,8 @@ worker/<type>/<worker_id>/__admin__
- **扩容**:新增副本槽位(`WorkerPool` 新增 `_managed_worker` 管理循环,新副本注册 liveliness 后可被负载均衡)。 - **扩容**:新增副本槽位(`WorkerPool` 新增 `_managed_worker` 管理循环,新副本注册 liveliness 后可被负载均衡)。
- **缩容**:随机挑选多余副本触发 **`drain`(优雅排空)**——先摘除 liveliness/业务 Queryable **隔绝新请求**,等待正在处理的**在途请求完成**后再关闭该副本(`in-flight` 计数实现),避免中断业务;管理循环不重建。 - **缩容**:随机挑选多余副本触发 **`drain`(优雅排空)**——先摘除 liveliness/业务 Queryable **隔绝新请求**,等待正在处理的**在途请求完成**后再关闭该副本(`in-flight` 计数实现),避免中断业务;管理循环不重建。
- **目标数量持久化**:`rescale` 把 `[workers].count` 写回 `config.toml`,网关/进程重启后按新数量加载。 - **目标数量持久化**:`rescale` 把 `[workers].count` 写回 `config.toml`,网关/进程重启后按新数量加载。
- **统一协议**:inline 与 external 共用。网关对 inline 直接调整本进程 `WorkerPool`;对 external 经 Zenoh 向任一存活副本下发 `rescale`,由其**所在进程内**的池扩缩并写回自身配置(无需重启整个进程/容器)。 - **统一协议**:inline 与 external 共用。网关对 inline 直接调整本进程 `WorkerPool`;对 external 经 Zenoh **向该类型全部存活实例广播 `rescale`**(不只是挑一个副本),由各实例按其 quota 扩缩并写回自身配置(无需重启整个进程/容器)。
- **逐实例 vs 全局**:`/scale` 是对某个类型的**全局对称扩缩**;若外部进程有多个(多机/多容器),`/scale` 会把全局总量均分到每个存活实例。要单独调整**某一个**外部实例的副本数,见下文「外部进程实例粒度扩缩」。
### 全局副本数协调(多网关) ### 全局副本数协调(多网关)
@@ -286,7 +289,21 @@ worker/<type>/<worker_id>/__admin__
launch_worker_count = "toml" # "toml"(默认)=启动用 toml 的 count 覆盖并写回 cache;"cache"=cache 为单一事实来源,toml 仅在缺失时引导 launch_worker_count = "toml" # "toml"(默认)=启动用 toml 的 count 覆盖并写回 cache;"cache"=cache 为单一事实来源,toml 仅在缺失时引导
``` ```
- **进程插件(external)不受该开关影响**:网关只对 **inline** 插件用 cache 解析启动副本数(见「启动流程」第 5 步 `load_workers`);external 插件由外部编排按其自身配置 `config/workers/<type>/config.toml` 的 `count` 启动,不经网关、也不读 cache。运行时 `scale` 对 external 仍走「向存活副本下发 rescale」路径,同样与该开关无关。 - **进程插件(external)实例粒度参与分摊**:网关只对 **inline** 插件用 cache 解析启动副本数(见「启动流程」第 5 步 `load_workers`),external 插件的启动副本数由外部编排按自身配置决定;但**运行时的缩放**上,external 并不退化为“每进程全额”,而是同样在**共享 cache 上登记实例参与方**来分摊,避免多实例把副本翻倍:
- **实例登记 + 心跳租约**:每个 external 进程启动时向 `worker:cm:instances:<type>` 登记自身参与方(`[instance_id, ts]`),并每 15s 心跳续约;超过租约(60s)未心跳的死实例会被清理。`divmod` 按存活参与方 N 均分全局 count(余量补靠前实例),`N==1` 时退化为全额(单部署零影响)。
- **实例标识默认唯一**:`--instance` / 环境变量 `CHONGMING_WORKER_INSTANCE` 显式指定(跨重启稳定、逐实例精细扩缩可持续命中);缺省回退 `{hostname}:{pid}`——**同一机器并行多个未指定 `--instance` 的进程也能靠 pid 区分**,不会因为 id 相同被并成一个参与方导致副本翻倍或前端只显示一个实例。注意缺省 id 随进程重启变化。
- **广播 + 幂等收敛**:`/scale` 写全局 count 后向该类型**全部存活实例**广播 `rescale`,各实例读同一全局 count + 同一份参与方列表算出的 quota 一致,重复 rescale 也幂等,因此任意地收敛到同一预期状态。
- **前提**:external 需共用共享 `[cache]`(多实例 redis 或共享卷 diskcache);`cache is None` 时退化为全额(仅单实例安全)。
### 外部进程实例粒度扩缩(逐实例精细扩缩)
当同一个类型跑多个外部进程(多机/多容器)时,除了 `scale` 的**全局对称扩缩**,还可以只对**某一个实例**精确扩缩:
- **语义**:把目标实例的副本数**绝对值**设为 N,其它实例不动;与全局 `scale` 共存——全局对称是默认/一键复位,逐实例是定制覆盖。
- **覆盖表**:每个实例的绝对目标存 cache 键 `worker:cm:instcounts:<type>` = `{instance_id: 绝对副本数}`。有记录的实例按绝对数执行;没记录的实例回退到全局均分。一旦用 `scale_instance` 定制了某实例,之后 `scale`(全局)会**清除整张覆盖表**回到均分(显式“复位”),因此两者不会互相干扰。
- **命中判断**:rescale 的下发必须命中**本实例**才执行(`instance_id` 与 `register_instance` 一致),目标是其它实例则安全忽略,避免误缩。
- **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}` 虽唯一但重启会变,跨重启持续命中请显式指定)。
### inline worker 宿主级领导选举(gunicorn 多 worker 兼容) ### inline worker 宿主级领导选举(gunicorn 多 worker 兼容)
@@ -314,7 +331,10 @@ inline worker 的生命周期交给**基于分布式锁的宿主级领导选举*
- **登录**:经 auth 插件 `POST /auth/login` 领取管理 Token(无需手填 JWT),从 JWT 解析当前用户与超管标识。 - **登录**:经 auth 插件 `POST /auth/login` 领取管理 Token(无需手填 JWT),从 JWT 解析当前用户与超管标识。
- **鉴权**:仅 `is_superuser=true` 可管理;权限不足时前端/后端弹出对应提示(403)。 - **鉴权**:仅 `is_superuser=true` 可管理;权限不足时前端/后端弹出对应提示(403)。
- **列表分页**:展示副本(插件类型 / 副本 ID / 模式 inline|external / 状态),内置分页。 - **列表分页**:展示副本(插件类型 / 副本 ID / 模式 inline|external / 状态),内置分页。
- **操作**:单副本重启、批量重启(选中)、插件级重启全部副本、更新插件配置(弹窗展示现有配置值,点行带入编辑)、**调整副本数量**(当前数 + `el-input-number` 设目标 + 应用,可扩缩容)。 - **查询与筛选**:列表顶部统一筛选栏——关键字搜索(插件类型 / 副本 ID / 实例 ID)、模式筛选(全部 / inline / external)、副本数 ≥ N 过滤;**同时作用于下方「逐实例精细扩缩」分组表**(副本数过滤仅对分组表生效),筛选条件变化自动回到第一页。
- **操作**:单副本重启、批量重启(选中)、插件级重启全部副本、更新插件配置(弹窗展示现有配置值,点行带入编辑;支持对象/数组等嵌套值的**结构化 JSON 编辑与校验/格式化**)、**调整副本数量**(当前数 + `el-input-number` 设目标 + 应用,可扩缩容)。
- **逐实例精细扩缩**:按外部进程实例(instance_id)**分组**展示(实例 id / 模式 / 当前副本数),每行可设目标副本数并「应用」——仅调整该实例,其余实例不动;全局「调整数量」后分组表也会自动刷新。
- **自动刷新**:页面每 10s 轮询总副本列表**与**逐实例分组表,扩缩容/重启/停止等异步结果无需手动刷新即可反映。
- **auth 核心插件**:配置不可更改,不在列表中展示(`Worker` 列表自动过滤 `worker_type == "auth"`)。 - **auth 核心插件**:配置不可更改,不在列表中展示(`Worker` 列表自动过滤 `worker_type == "auth"`)。
- 调小副本数会**触发优雅排空**并在前端确认提示中说明。 - 调小副本数会**触发优雅排空**并在前端确认提示中说明。
+1 -1
View File
@@ -446,7 +446,7 @@ async def load_workers(app: FastAPI, session: zenoh.Session, manager: WorkerMana
factory_name = wc["factory"] factory_name = wc["factory"]
# count 以 chongming-cache 为单一事实来源:cache 有值时用 cache 的全局总量, # count 以 chongming-cache 为单一事实来源:cache 有值时用 cache 的全局总量,
# 否则用配置值引导并写回 cache(多网关共享同一 cache 时自动对齐)。 # 否则用配置值引导并写回 cache(多网关共享同一 cache 时自动对齐)。
count = manager.resolved_count(wc["type"], wc["count"]) count = wc["count"]
worker_type = wc["type"] worker_type = wc["type"]
worker_config = wc["config"] worker_config = wc["config"]
+242 -102
View File
@@ -6,6 +6,7 @@ import random
import socket import socket
import time import time
import orjson import orjson
from collections import OrderedDict
from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional
import zenoh import zenoh
from chongming_cache import Cache, DistributedLock from chongming_cache import Cache, DistributedLock
@@ -16,9 +17,7 @@ logger = get_logger("worker_manager")
# ── 全局副本数(chongming-cache 单一事实来源)── # ── 全局副本数(chongming-cache 单一事实来源)──
# 多网关共用同一 cache(单实例=本地 diskcache,多实例=共享卷/redis), # 多网关共用同一 cache(单实例=本地 diskcache,多实例=共享卷/redis),
# 「count」语义 = 该类 worker 的**全局目标总量**,各网关按在线数量分摊。 # 「count」语义 = 该类 worker 的**全局目标总量**,各网关按在线数量分摊。
_SCALE_DESIRED_KEY = "worker:cm:counts:{type}" # 全局目标总量(int)
_SCALE_GATEWAYS_KEY = "worker:cm:gateways" # 在线网关列表:[[id, 最近心跳 ts], ...](JSON) _SCALE_GATEWAYS_KEY = "worker:cm:gateways" # 在线网关列表:[[id, 最近心跳 ts], ...](JSON)
_SCALE_QUOTA_KEY = "worker:cm:quota:{type}:{gw}" # 各网关分摊配额(int)
# 在线网关租约(秒):网关心跳每 refresh_interval(15s) 更新一次自身 ts,超过 LEASE 未刷新 # 在线网关租约(秒):网关心跳每 refresh_interval(15s) 更新一次自身 ts,超过 LEASE 未刷新
# 视为离线的死网关,在下一次读写时被清理。容忍多次漏跳,且 crash/kill -9 不残留。 # 视为离线的死网关,在下一次读写时被清理。容忍多次漏跳,且 crash/kill -9 不残留。
@@ -52,6 +51,38 @@ def _next_reply(iterator):
return _SENTINEL return _SENTINEL
class _MemLRU:
"""极简进程内一级缓存:LRU + TTL。
仅度热路径(cacheable RPC 命中)/JWT 等高频只读复用。单事件循环内读写、
OrderedDict 实现,O(1) 无锁;作为 diskcache/redis 二级共享缓存的进程级第一级,
命中时完全绕过网络/磁盘与 Zenoh,降低热点请求延迟与后端压力。
maxsize 达到上限时淘汰最久未用(LRU)条目;TTL 削防陈旧数据长期驻留。
"""
def __init__(self, maxsize: int = 2048, ttl: float = 60.0):
self._d: "OrderedDict[str, tuple]" = OrderedDict()
self._maxsize = maxsize
self._ttl = ttl
def get(self, key: str):
item = self._d.get(key)
if item is None:
return None
val, expire = item
if expire < time.monotonic():
self._d.pop(key, None)
return None
self._d.move_to_end(key) # 更新为最近使用
return val
def set(self, key: str, val) -> None:
self._d[key] = (val, time.monotonic() + self._ttl)
self._d.move_to_end(key)
if len(self._d) > self._maxsize:
self._d.popitem(last=False)
class WorkerManager: class WorkerManager:
""" """
统一 Worker 管理器 统一 Worker 管理器
@@ -68,6 +99,9 @@ class WorkerManager:
launch_worker_count: str = "toml"): launch_worker_count: str = "toml"):
self.session = session self.session = session
self.cache = cache self.cache = cache
# RPC 缓存进程内一级(LRU+TTL),兜底并加速 cacheable 命中;
# 二级为共享 cache(diskcache/redis),I/O 移出事件循环走 executor。
self._rpc_mem_cache = _MemLRU()
# 启动时全局副本数的优先来源:"toml"=用 toml 的 count 覆盖 cache(经典模式); # 启动时全局副本数的优先来源:"toml"=用 toml 的 count 覆盖 cache(经典模式);
# "cache"=以 cache 为单一事实来源。对应 config.toml [cache].launch_worker_count。 # "cache"=以 cache 为单一事实来源。对应 config.toml [cache].launch_worker_count。
self._launch_worker_count = launch_worker_count self._launch_worker_count = launch_worker_count
@@ -90,12 +124,8 @@ class WorkerManager:
self._started_extra: List[Any] = [] self._started_extra: List[Any] = []
# ── 全局副本数协调(chongming-cache 单一事实来源)──────────── # ── 全局副本数协调(chongming-cache 单一事实来源)────────────
# count/quota 的读写与分摊已收归 worker 层(chongming_worker.runtime):
def _scale_desired_key(self, worker_type: str) -> str: # 网关不再直写这些缓存键,仅维护「在线网关列表」(register/heartbeat/unregister)。
return _SCALE_DESIRED_KEY.format(type=worker_type)
def _scale_quota_key(self, worker_type: str) -> str:
return _SCALE_QUOTA_KEY.format(type=worker_type, gw=self._gateway_id)
def _read_gateways_raw(self) -> List[List]: def _read_gateways_raw(self) -> List[List]:
"""读取在线网关原始列表 [[id, ts], ...]。""" """读取在线网关原始列表 [[id, ts], ...]。"""
@@ -249,44 +279,6 @@ class WorkerManager:
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.error("[heal] 拉起 %s 失败: %s", wtype, e) logger.error("[heal] 拉起 %s 失败: %s", wtype, e)
def resolved_count(self, worker_type: str, default: int) -> int:
"""获取该类 worker 的全局目标总量。
依 [cache].launch_worker_count 决定启动优先级:
- "toml"(经典):始终用 toml 的 count(default)覆盖 cache 并写回为新的全局总量;
- "cache"(全局):cache 为单一事实来源,缺失时才用 toml 引导并写回。
"""
if self.cache is None:
return int(default)
key = self._scale_desired_key(worker_type)
total = max(0, int(default))
if self._launch_worker_count == "toml":
# 经典模式:toml count 覆盖并写回 cache
self.cache[key] = total
return total
if key in self.cache:
return int(self.cache[key])
self.cache[key] = total
return total
def own_quota(self, worker_type: str, total: int) -> int:
"""在「count=全局总量」语义下,计算当前网关应承担的副本数(分摊)。
单网关(本场景)即总量本身;多网关按在线 id 排序均分,余量按序补前若干。
结果写回 cache(observability),并在全局下发给各网关后各自按其 quota 调整。
"""
total = max(0, int(total))
gws = sorted(self._gateways())
if not gws:
quota = total
else:
base, rem = divmod(total, len(gws))
idx = gws.index(self._gateway_id) if self._gateway_id in gws else 0
quota = base + (1 if idx < rem else 0)
if self.cache is not None:
self.cache[self._scale_quota_key(worker_type)] = quota
return quota
def register_worker(self, worker_type: str, worker_id: str): def register_worker(self, worker_type: str, worker_id: str):
""" """
直接注册一个本地 worker(同进程内使用) 直接注册一个本地 worker(同进程内使用)
@@ -511,6 +503,36 @@ class WorkerManager:
return _stream() return _stream()
# ── 共享缓存二级 I/O(executor 化,不阻塞事件循环)────────────
# 进程内一级(_rpc_mem_cache)在事件循环内 O(1) 直读;二级 diskcache/redis
# 的 get/set 是同步 I/O,统一丢到 _ZENOH_EXECUTOR 线程执行,避免高并发下
# 磁盘/网络 I/O 卡死事件循环。值恒为 bytes,选 None 作为未命中哨兵安全。
async def _cache_get(self, key: str) -> Optional[bytes]:
if self.cache is None:
return None
loop = asyncio.get_running_loop()
return await loop.run_in_executor(_ZENOH_EXECUTOR, self._sync_cache_get, key)
def _sync_cache_get(self, key: str) -> Optional[bytes]:
try:
return self.cache.get(key) if self.cache is not None else None
except Exception:
return None
async def _cache_set(self, key: str, value: bytes) -> None:
if self.cache is None:
return
loop = asyncio.get_running_loop()
await loop.run_in_executor(_ZENOH_EXECUTOR, self._sync_cache_set, key, value)
def _sync_cache_set(self, key: str, value: bytes) -> None:
try:
if self.cache is not None:
self.cache[key] = value
except Exception:
pass
async def call( async def call(
self, self,
worker_type: str, worker_type: str,
@@ -598,10 +620,19 @@ class WorkerManager:
call() / call_raw() 共用本实现,仅入参出参形态不同(dict ↔ bytes)。 call() / call_raw() 共用本实现,仅入参出参形态不同(dict ↔ bytes)。
""" """
# 1. 缓存检查(仅在 cacheable=True 时检查) # 1. 缓存检查(仅在 cacheable=True 时检查)
if cacheable and cache_key is not None and self.cache is not None: if cacheable and cache_key is not None:
if cache_key in self.cache: # 一级:进程内内存 LRU(事件循环内 O(1),零 I/O,命中完全绕过 Zenoh)
logger.debug(f"命中缓存: {cache_key}") mem_val = self._rpc_mem_cache.get(cache_key)
return self.cache[cache_key] if mem_val is not None:
logger.debug(f"命中内存缓存: {cache_key}")
return mem_val
# 二级:共享缓存(diskcache/redis),I/O 移到 executor,不阻塞事件循环
if self.cache is not None:
disk_val = await self._cache_get(cache_key)
if disk_val is not None:
self._rpc_mem_cache.set(cache_key, disk_val)
logger.debug(f"命中共享缓存: {cache_key}")
return disk_val
# 2. 获取可用 worker # 2. 获取可用 worker
worker_expr = await self.get_worker(worker_type) worker_expr = await self.get_worker(worker_type)
@@ -654,11 +685,13 @@ class WorkerManager:
if reply.ok: # type: ignore if reply.ok: # type: ignore
reply_bytes = bytes(reply.ok.payload) if reply.ok.payload else b"{}" reply_bytes = bytes(reply.ok.payload) if reply.ok.payload else b"{}"
# 缓存写入(仅在 cacheable=True 时):解析一次做成功检查, # 缓存写入(仅在 cacheable=True 时):解析一次做成功检查,
# 但缓存与返回均保留原始字节(网关侧零二次序列化) # 但缓存与返回均保留原始字节(网关侧零二次序列化)。
if cacheable and cache_key is not None and self.cache is not None and reply_bytes: # 先写内存一级,再异步写共享二级(executor,不阻塞响应返回)
if cacheable and cache_key is not None and reply_bytes:
parsed = orjson.loads(reply_bytes) parsed = orjson.loads(reply_bytes)
if self.is_success_response(parsed): if self.is_success_response(parsed):
self.cache[cache_key] = reply_bytes self._rpc_mem_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_payload = reply.err.payload # type: ignore
@@ -824,59 +857,168 @@ class WorkerManager:
"""把某类型的副本数调整为 count(扩缩容)并持久化到配置。 """把某类型的副本数调整为 count(扩缩容)并持久化到配置。
下发逻辑(与 inline/external 的统一管理协议一致): 下发逻辑(与 inline/external 的统一管理协议一致):
- inline:类型在本进程内有 WorkerPool,可直接调用其 set_target(进程内扩缩, - inline:类型在本进程内有 WorkerPool,只下发本网关进程内的池(进程内扩缩,
无需经过 Zenoh 往返); 无需跨进程广播,副本按其所在网关分摊配额);
- external:类型由独立进程/容器运行,向该类型任一存活副本经 Zenoh 下发 - external:类型由独立进程/容器运行,向该类型**全部存活**副本经 Zenoh 广播
rescale 指令,由其**所在进程内**的 WorkerPool 扩缩并写回自身配置。 rescale 指令,由各进程内 WorkerPool 按「全局 count ÷ 存活实例数」重新分摊
并各写回自身配置(避免只缩/扩其中一个实例造成不一致)。
全局缩放同时清除各实例的「逐实例绝对副本数」覆盖,回到对称分摊——因此它是
「一键复位对称」入口;逐实例精细缩放走 scale_instance(见后)。
缩容走优雅排空:摘除 liveliness/业务 Queryable 隔绝新请求,等正在处理的 缩容走优雅排空:摘除 liveliness/业务 Queryable 隔绝新请求,等正在处理的
在途请求完成后再关闭副本(避免中断业务)。 在途请求完成后再关闭副本(避免中断业务)。
Args: Args:
worker_type: 插件类型 worker_type: 插件类型
count: 目标副本数(>= 0) count: 目标副本总数(全局总量,>= 0)
timeout: 下发超时(秒) timeout: 下发超时(秒)
Returns: Returns:
{"worker_type", "target"(全局总量), "quota", "mode"..., "persist"|...} {"worker_type", "target"(全局总量), "mode", "sent_to", "reply"}
Raises: Raises:
RuntimeError: count 非法 / 类型无可用副本或本进程无其池 RuntimeError: count 非法 / 类型无可用副本
""" """
count = max(0, int(count)) count = max(0, int(count))
# 「count=全局总量」:写全局 cache 单一来源;本网关按在线网关分摊承担 quota。 # 「count=全局总量」收归 worker 层:网关不再直写 count/quota 缓存、不再直调进程池。
if self.cache is not None: # 统一经 rescale 下发全局 target,由 worker 读取在线网关分摊、写缓存并扩缩本进程副本;
self.cache[self._scale_desired_key(worker_type)] = count # inline/external 口径一致(inline 副本在本进程,经 Zenoh 直达其 __admin__)。
quota = self.own_quota(worker_type, count) inline = False
logger.info(
"[scale] %s 全局目标=%d,本网关分摊 quota=%d", worker_type, count, quota
)
# inline:优先走本进程内池(按分摊配额),直接且即时应答
try: try:
from chongming_worker.runtime import get_process_pool from chongming_worker.runtime import get_process_pool
pool = get_process_pool(worker_type) inline = get_process_pool(worker_type) is not None
except Exception: # noqa: BLE001 - 运行时缺依赖时退回 Zenoh 下发 except Exception: # noqa: BLE001 - 运行时缺依赖视为非 inline
pool = None inline = False
if pool is not None: candidates = [
await pool.set_target(quota) w for w in self.list_workers()
persist = "未持久化" if w["worker_type"] == worker_type and w.get("alive")
if self.cache is not None: ]
persist = f"count={count}(全局)已写 cache,本网关 quota={quota}" if not candidates:
else: raise RuntimeError(f"类型 '{worker_type}' 没有可用副本,无法调整数量")
persist = f"cache 未启用,quota={quota}(重启后将按配置还原)"
logger.info(
"[scale] %s inline 池已调整至 %d 副本(全局目标 %d)",
worker_type, quota, count,
)
return {
"worker_type": worker_type,
"target": count,
"quota": quota,
"mode": "inline",
"reply": {"status": "accepted", "cmd": "rescale", "persist": persist},
}
# external(或本进程无池):下发 rescale 给任一存活副本,由其进程内池扩缩+持久化 # 目标选择:
# - inline:只下发本网关进程内的池(任意一个 inline 候选都会落到本地池,幂等);
# - external:同一类型可能有多个独立进程/实例,必须广播到全部存活实例,由各自按
# 「全局 count ÷ 存活实例数」重新分摊,避免只缩/扩其中一个进程造成不一致。
if inline:
inline_cands = [c for c in candidates if c.get("mode") == "inline"] or candidates
targets = [inline_cands[0]]
else:
targets = candidates
replies: Dict[str, Any] = {}
for cand in targets:
try:
replies[cand["worker_id"]] = await self._send_admin_cmd(
worker_type, cand["worker_id"], "rescale",
timeout=timeout, data_extra={"scale": count},
)
except Exception as e: # noqa: BLE001 - 单个实例失败不影响其它实例
logger.warning("[scale] %s 下发失败 %s: %s", worker_type, cand["worker_id"], e)
if not replies:
raise RuntimeError(f"类型 '{worker_type}' 没有副本响应 rescale,无法调整数量")
logger.info(
"[scale] %s 全局目标=%d 下发 %d 个实例(%s)",
worker_type, count, len(replies), "inline" if inline else "external",
)
return {
"worker_type": worker_type,
"target": count,
"mode": "inline" if inline else "external",
"sent_to": list(replies.keys()),
"reply": replies,
}
async def list_instances(
self,
worker_type: str,
timeout: float = 3.0,
) -> List[Dict[str, Any]]:
"""按「外部进程实例」分组盘点某类型的落地实体。
对类型下每个存活副本发轻量 status 查询,从回复取 instance_id 归组;
一个副本组 = 一个能独立承载若干副本的执行单元(inline=本网关进程/网关 host,
external=独立进程/容器)。副本数=组内在线副本个数,worker_ids 供精细缩放寻址。
"""
workers = [
w for w in self.list_workers()
if w["worker_type"] == worker_type and w.get("alive")
]
groups: Dict[str, Dict[str, Any]] = {}
for w in workers:
try:
reply = await self._send_admin_cmd(
worker_type, w["worker_id"], "query", timeout=timeout
)
iid = reply.get("instance_id") or "unknown"
except Exception as e: # noqa: BLE001 - 单副本查询失败不阻塞整体
logger.warning("[instances] %s/%s 查询失败: %s", worker_type, w["worker_id"], e)
iid = "unknown"
g = groups.setdefault(iid, {
"worker_type": worker_type,
"instance_id": iid,
"mode": w.get("mode", "external"),
"replicas": 0,
"worker_ids": [],
})
g["replicas"] += 1
g["worker_ids"].append(w["worker_id"])
groups.pop("unknown", None) # 拿不到身份的一律归入 unknown,避免误当独立实例
return list(groups.values())
async def scale_instance(
self,
worker_type: str,
instance_id: str,
count: int,
timeout: float = 5.0,
) -> Dict[str, Any]:
"""把某个外部进程/**实例**精确调到 count 个副本(绝对数,不影响其它实例)。
经该实例任一副本的 admin Queryable 下发 `rescale{scale:count, instance_id,
absolute:true}`,由其进程内 WorkerPool 直接 set_target(count) 并写入 instcounts
覆盖表(进程重启后仍生效);目标其它实例的指令被 worker 安全忽略。
返回与原 /scale 结构兼容:{worker_type, target, mode, sent_to, reply}。
"""
count = max(0, int(count))
insts = await self.list_instances(worker_type)
inst = next((i for i in insts if i["instance_id"] == instance_id), None)
if inst is None or not inst["worker_ids"]:
raise RuntimeError(
f"类型 '{worker_type}' 下没有在线副本归属实例 '{instance_id}',无法调整数量"
)
wid = inst["worker_ids"][0]
reply = await self._send_admin_cmd(
worker_type, wid, "rescale",
timeout=timeout,
data_extra={"scale": count, "instance_id": instance_id, "absolute": True},
)
logger.info(
"[scale_instance] %s 实例=%s 目标=%d → 下发 %s", worker_type, instance_id, count, wid,
)
return {
"worker_type": worker_type,
"instance_id": instance_id,
"target": count,
"mode": inst.get("mode", "external"),
"sent_to": wid,
"reply": reply,
}
async def get_worker_config(
self,
worker_type: str,
timeout: float = 5.0,
) -> Dict[str, Any]:
"""经 Zenoh 向该类型任一存活副本查询其 [workers.config]。
由 worker 访问自身缓存(或回退 toml 种子)返回;网关不直接接触外部 worker 的缓存后端。
Returns:
{"status", "config": {...}, "source": "cache"|"toml", "worker_id", ...}
Raises:
RuntimeError: 无存活副本 / 副本未响应(可能运行旧版运行时无 get_config 能力)
"""
candidate = next( candidate = next(
( (
w for w in self.list_workers() w for w in self.list_workers()
@@ -885,18 +1027,16 @@ class WorkerManager:
None, None,
) )
if candidate is None: if candidate is None:
raise RuntimeError(f"类型 '{worker_type}' 没有可用副本,无法调整数量") raise RuntimeError(f"类型 '{worker_type}' 没有在线副本,无法读取其配置")
reply = await self._send_admin_cmd( reply = await self._send_admin_cmd(
worker_type, candidate["worker_id"], "rescale", worker_type, candidate["worker_id"], "get_config", timeout=timeout
timeout=timeout, data_extra={"scale": count},
) )
return { return {
"worker_type": worker_type, "worker_type": worker_type,
"target": count, "worker_id": candidate["worker_id"],
"mode": "external", "status": reply.get("status"),
"sent_to": candidate["worker_id"], "config": reply.get("config"),
"reply": reply, "source": reply.get("source", "toml"),
} }
async def update_worker_config( async def update_worker_config(
@@ -908,12 +1048,12 @@ class WorkerManager:
"""经 Zenoh 下发 config_set 指令,让目标类型的全部副本修改配置并自我重启。 """经 Zenoh 下发 config_set 指令,让目标类型的全部副本修改配置并自我重启。
worker 侧行为(见 chongming_worker.runtime): worker 侧行为(见 chongming_worker.runtime):
- 仅 index0 副本把 updates 写入插件 config.toml 的 [workers.config](原子写回, - 仅 index0 副本把 updates 写入共享缓存的 [workers.config](key
路由等保留键会被拒绝); worker:cm:configs:{type},全局唯一,路由/保留键会被拒绝);不再回写 config.toml。
- 所有副本合并 updates 后以新配置重启(新 worker_id)。 - 所有副本合并 updates 后以新配置重启(新 worker_id)。
本方法只负责"下发 + 汇总",写操作由 worker 触发执行,故进程插件位于 本方法只负责"下发 + 汇总",写操作由 worker 访问其自身缓存触发执行,故进程插件
不同路径/宿主机时也可正确写回各自的配置文件。 位于不同路径/宿主机时也能把配置持久化到各自的共享缓存后端。
Args: Args:
worker_type: 插件类型 worker_type: 插件类型
+86 -1
View File
@@ -166,6 +166,72 @@ async def scale_workers(
return {"success": True, "result": result} return {"success": True, "result": result}
class ScaleInstanceBody(BaseModel):
"""逐实例精细扩缩请求体:目标实例 + 该实例的目标副本数。"""
instance_id: str = Field(..., description="外部进程/实例身份(list_instances 返回)")
count: int = Field(..., ge=0, description="该实例的目标副本数(绝对数,>=0)")
@router.get(
"/admin/workers/{worker_type}/instances",
summary="按实例分组列出某类型 worker(精细扩缩寻址)",
)
async def list_worker_instances(
request: Request,
worker_type: str,
_admin: dict = Depends(require_admin),
):
"""把某类型的在线副本按「外部进程实例」分组返回。
每个实例 = 一个可独立承载副本的执行单元(inline=本网关进程/host,
external=独立进程/容器),含当前副本数与可选中的 worker_id。
"""
manager = _get_manager(request)
try:
instances = await manager.list_instances(worker_type)
except Exception as e: # noqa: BLE001
logger.warning("列出实例失败: %s 错误=%s", worker_type, e)
raise HTTPException(status_code=404, detail=str(e))
return {"success": True, "result": instances}
@router.post(
"/admin/workers/{worker_type}/scale_instance",
summary="把某实例精确扩缩到指定副本数(精细扩缩容)",
)
async def scale_worker_instance(
request: Request,
worker_type: str,
body: ScaleInstanceBody,
_admin: dict = Depends(require_admin),
):
"""把指定外部进程/**实例**精确调整到 body.count 个副本(绝对数),不影响其它实例。
经该实例任一副本下发 rescale{scale, instance_id, absolute:true},由其进程内
WorkerPool 直接扩缩并记入逐实例覆盖表;重启后仍保持该绝对数。
"""
manager = _get_manager(request)
try:
result = await manager.scale_instance(
worker_type, body.instance_id, body.count
)
except Exception as e: # noqa: BLE001
logger.warning(
"逐实例扩缩失败: %s instance=%s -> %d 用户=%s 错误=%s",
worker_type, body.instance_id, body.count,
_admin.get("username", _admin.get("sub", "-")),
e,
)
raise HTTPException(status_code=404, detail=str(e))
logger.info(
"逐实例扩缩: %s instance=%s -> %d 用户=%s 结果=%s",
worker_type, body.instance_id, body.count,
_admin.get("username", _admin.get("sub", "-")),
result,
)
return {"success": True, "result": result}
class ConfigUpdateBody(BaseModel): class ConfigUpdateBody(BaseModel):
"""配置更新请求体:写目标插件 config.toml 的 [workers.config] 段。""" """配置更新请求体:写目标插件 config.toml 的 [workers.config] 段。"""
updates: dict = Field(..., description="待写入 [workers.config] 的键值(路由/加载控制键除外)") updates: dict = Field(..., description="待写入 [workers.config] 的键值(路由/加载控制键除外)")
@@ -179,11 +245,29 @@ async def read_worker_config(
): ):
"""读取某插件 config.toml 的 [workers.config] 现有值(供前端展示)。 """读取某插件 config.toml 的 [workers.config] 现有值(供前端展示)。
直接从插件配置文件读取,反映最近一次落盘结果(含 worker 写回后的值)。 经 Zenoh 向该类任一存活副本下发 get_config,由其访问自身缓存(或回退 toml 种子)
返回 —— 网关不直接接触 external worker 的缓存后端。无存活副本 / 旧版运行时
不支持 get_config 时,回退回直读 config/workers/<type>/config.toml 的 [workers].config。
路由配置(add.toml 等)不在此范围。 路由配置(add.toml 等)不在此范围。
- worker_type: 插件类型(如 example) - worker_type: 插件类型(如 example)
""" """
manager = _get_manager(request)
try:
info = await manager.get_worker_config(worker_type)
cfg = info.get("config")
if isinstance(cfg, dict) and info.get("status") in ("ok", None):
return {
"worker_type": worker_type,
"worker_id": info.get("worker_id"),
"source": info.get("source", "cache"),
"config": cfg,
"success": True,
}
logger.debug("worker get_config 未返回有效配置(%s),回退读 toml", info)
except Exception as e: # noqa: BLE001 - 副本未响应等一律回退读 toml
logger.debug("worker get_config 查询失败,回退读 toml: %s", e)
path = pathlib.Path("config/workers") / worker_type / "config.toml" path = pathlib.Path("config/workers") / worker_type / "config.toml"
if not path.exists(): if not path.exists():
raise HTTPException(status_code=404, detail=f"插件 {worker_type} 的配置文件不存在: {path}") raise HTTPException(status_code=404, detail=f"插件 {worker_type} 的配置文件不存在: {path}")
@@ -194,6 +278,7 @@ async def read_worker_config(
return { return {
"worker_type": worker_type, "worker_type": worker_type,
"config": cfg if isinstance(cfg, dict) else {}, "config": cfg if isinstance(cfg, dict) else {},
"source": "toml",
"success": True, "success": True,
} }
+20
View File
@@ -0,0 +1,20 @@
"""
原生 FastAPI 对照服务(无 Zenoh / 无网关中间件 / 无 worker)
用于与 chongming 网关的 /api/v1/demo/hello 做性能对照:
同样的 GET 返回一个 JSON(字段形状与 demo/hello 一致),排除业务与 RPC 链路,
仅度量纯 FastAPI+Uvicorn 框架本身的吞吐与延迟。
启动:
uv run uvicorn native_fastapi:app --host 0.0.0.0 --port 8001 --no-access-log
"""
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/hello")
async def hello(name: str = "bench"):
return {"greeting": f"Hello, {name}!", "worker_id": "native", "timestamp": time.time()}
+187
View File
@@ -0,0 +1,187 @@
"""
外部进程多实例全局分摊 —— distribute_quota 逻辑单测(无需网关/缓存后端)。
用 dict 作缓存桩,模拟同类型多个外部进程实例注册,验证扩缩容时全局 count 按
「存活实例数 N」分摊(而非每个进程都全额),以及实例退出/租约过期后的接管行为。
"""
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../utils/worker/src"))
import chongming_worker.runtime as r
PASS = 0
FAIL = 0
def report(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f"✅ {name}" + (f" — {detail}" if detail else ""))
else:
FAIL += 1
print(f"❌ {name}" + (f" — {detail}" if detail else ""))
def fake_cache():
return {}
def as_instance(iid, cache, worker_type="demo"):
"""把当前身份设为 iid,并在缓存登记该实例参与方。"""
old = os.environ.get("CHONGMING_WORKER_INSTANCE")
os.environ["CHONGMING_WORKER_INSTANCE"] = iid
try:
r.register_instance(worker_type, cache)
finally:
if old is None:
os.environ.pop("CHONGMING_WORKER_INSTANCE", None)
else:
os.environ["CHONGMING_WORKER_INSTANCE"] = old
def quota_as(iid, worker_type, total, cache):
"""以指定实例身份计算其分摊配额(external 分支)。"""
old = os.environ.get("CHONGMING_WORKER_INSTANCE")
os.environ["CHONGMING_WORKER_INSTANCE"] = iid
try:
return r.distribute_quota(cache, worker_type, total, gw_id=None)
finally:
if old is None:
os.environ.pop("CHONGMING_WORKER_INSTANCE", None)
else:
os.environ["CHONGMING_WORKER_INSTANCE"] = old
def unreg_instance(iid, cache, worker_type="demo"):
"""以指定实例身份从参与方列表移除它。"""
old = os.environ.get("CHONGMING_WORKER_INSTANCE")
os.environ["CHONGMING_WORKER_INSTANCE"] = iid
try:
r.unregister_instance(worker_type, cache)
finally:
if old is None:
os.environ.pop("CHONGMING_WORKER_INSTANCE", None)
else:
os.environ["CHONGMING_WORKER_INSTANCE"] = old
def run():
cache = fake_cache()
wt = "demo"
# 1. 单实例(未启用实例注册,参与方为空)→ 兜底全额(兼容旧行为)
q = r.distribute_quota(cache, wt, 6, gw_id=None)
report("单实例无参与方 → 全额承担 6", q == 6, f"got={q}")
# 2. 两个实例并存:全局 6 分摊 → 各 3
as_instance("inst-a", cache, wt)
as_instance("inst-b", cache, wt)
qa = quota_as("inst-a", wt, 6, cache)
qb = quota_as("inst-b", wt, 6, cache)
report("两个实例并存 → 各分 3", qa == 3 and qb == 3, f"a={qa}, b={qb}")
# 3. 三个实例、总数不能整除:7 // 3 余 1 → 按序给第一个补 1
as_instance("inst-c", cache, wt)
qs = {i: quota_as(i, wt, 7, cache) for i in ("inst-a", "inst-b", "inst-c")}
report(
"三个实例 ≠整除 → 总和=7 且余量补靠前实例",
sum(qs.values()) == 7 and set(qs.values()) == {2, 2, 3},
f"{qs}",
)
# 4. 一个实例退出(unregister)→ 其余接管全部
unreg_instance("inst-c", cache, wt)
qa2 = quota_as("inst-a", wt, 6, cache)
qb2 = quota_as("inst-b", wt, 6, cache)
report(
"实例退出后由剩余接管 → 各分 3",
qa2 == 3 and qb2 == 3,
f"a={qa2}, b={qb2}",
)
# 5. 租约过期清理:伪造一个过期的参与方,读实例列表应排除它
cache[r._SCALE_INSTANCES_KEY.format(type=wt)] = [
["inst-stale", time.time() - r._INSTANCE_LEASE - 10],
["inst-fresh", time.time()],
]
ids = r._read_instances(cache, wt)
report(
"死实例按租约剔除",
ids == ["inst-fresh"],
f"instances={ids}",
)
# 6. 实例数=1 时全额(不误分摊)
cache[r._SCALE_INSTANCES_KEY.format(type=wt)] = [
[r.current_instance_id(), time.time()]
]
q1 = r.distribute_quota(cache, wt, 5, gw_id=None)
report("单存活实例 → 全额 5", q1 == 5, f"got={q1}")
# 7. inline(有网关身份)路径回归:按在线网关分摊不受影响
cache[r._SCALE_GATEWAYS_KEY] = [["gw-a", time.time()], ["gw-b", time.time()]]
qa_in = r.distribute_quota(cache, wt, 6, "gw-a")
qb_in = r.distribute_quota(cache, wt, 6, "gw-b")
report(
"inline 按网关分摊回归 → 各 3",
qa_in == 3 and qb_in == 3,
f"gw-a={qa_in}, gw-b={qb_in}",
)
# 8. 逐实例绝对覆盖:写入后能被读回且供启动解析(external)使用
r._write_own_instance_count(cache, wt, 4, "inst-a")
r._write_own_instance_count(cache, wt, 2, "inst-b")
report(
"instcounts 覆盖写入/读回",
r._own_instance_count(cache, wt, "inst-a") == 4
and r._own_instance_count(cache, wt, "inst-b") == 2,
f"{r._read_instance_counts(cache, wt)}",
)
# 启动解析:external(gw_id=None)有绝对覆盖时直接采用,而非被全局均分
old = os.environ.get("CHONGMING_WORKER_INSTANCE")
os.environ["CHONGMING_WORKER_INSTANCE"] = "inst-a"
try:
restored = r.resolve_worker_count(wt, 6, cache, "cache", gw_id=None)
finally:
if old is None:
os.environ.pop("CHONGMING_WORKER_INSTANCE", None)
else:
os.environ["CHONGMING_WORKER_INSTANCE"] = old
report("resolve_worker_count 启动时采用本实例绝对数", restored == 4, f"got={restored}")
# 9. 清除覆盖后回退全局均分
r._clear_instance_counts(cache, wt)
report(
"清除覆盖后覆盖表为空",
r._read_instance_counts(cache, wt) == {},
f"{r._read_instance_counts(cache, wt)}",
)
# 10. 缺省实例身份唯一(hostname:pid):同机未指定 --instance 的多进程不会并成一个参与方
r.set_current_instance_id(None)
old_env = os.environ.pop("CHONGMING_WORKER_INSTANCE", None)
try:
default_iid = r.current_instance_id()
finally:
if old_env is not None:
os.environ["CHONGMING_WORKER_INSTANCE"] = old_env
else:
os.environ.pop("CHONGMING_WORKER_INSTANCE", None)
import socket as _socket
report(
"缺省实例身份含进程区分({host}:{pid})",
default_iid == f"{_socket.gethostname()}:{os.getpid()}",
f"got={default_iid}",
)
print("\n" + "=" * 56)
print(f"测试结果: ✅ {PASS} 通过 ❌ {FAIL} 失败")
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(run())
Generated
+28 -37
View File
@@ -541,18 +541,18 @@ wheels = [
[[package]] [[package]]
name = "chongming-worker" name = "chongming-worker"
version = "0.4.0" version = "0.4.1"
source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" }
dependencies = [ dependencies = [
{ name = "chongming-cache" },
{ name = "eclipse-zenoh" }, { name = "eclipse-zenoh" },
{ name = "minio" }, { name = "minio" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "tomli" }, { name = "tomli" },
{ name = "tomli-w" },
] ]
sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-worker/0.4.0/chongming_worker-0.4.0.tar.gz", hash = "sha256:c2b13fc1c2eb262f0ca5a1b64035bff1881a912c5a95446a9ce77b1ba968b03b" } sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-worker/0.4.1/chongming_worker-0.4.1.tar.gz", hash = "sha256:ea8c1017e2c3c517475bc66ebdfecfc662b318fdb19a02e9003987637299ee3c" }
wheels = [ wheels = [
{ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-worker/0.4.0/chongming_worker-0.4.0-py3-none-any.whl", hash = "sha256:96e1dd6b8c22a294b83b54d8ac3723f4555865625ed210a6e1974bc6ac3c4a9d" }, { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-worker/0.4.1/chongming_worker-0.4.1-py3-none-any.whl", hash = "sha256:fd8202890fb124a0180e04592566882b335c3ddc456b7509a50932ddcc008279" },
] ]
[[package]] [[package]]
@@ -1098,9 +1098,9 @@ wheels = [
[[package]] [[package]]
name = "nuitka" name = "nuitka"
version = "4.1.3" version = "4.2"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/d8/bdb7febea4b4fe5d3d6fe2610f946771f03e792e05a5e8ec00b62c00b265/nuitka-4.1.3.tar.gz", hash = "sha256:838ff8899dc2f0b652d4fcf6c5d7466cb7ad5abcb005668ac622d1e40f4d8a8d", size = 4565475, upload-time = "2026-06-21T10:48:04.369Z" } sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/14/f7/305d1eca71f554f52cac820b8d1765aeb5fb2b3732d7f49f5a32aa4d97d2/nuitka-4.2.tar.gz", hash = "sha256:b6112c10a5d0431e2b4309780257a03049a42605290e83f771d57a067a387410", size = 4628471, upload-time = "2026-08-27T14:17:30.409Z" }
[[package]] [[package]]
name = "openai" name = "openai"
@@ -1608,27 +1608,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.4" version = "0.16.5"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
wheels = [ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
] ]
[[package]] [[package]]
@@ -1783,15 +1783,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
] ]
[[package]]
name = "tomli-w"
version = "1.2.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]
[[package]] [[package]]
name = "transitions" name = "transitions"
version = "0.9.3" version = "0.9.3"
@@ -1905,7 +1896,7 @@ wheels = [
[[package]] [[package]]
name = "volcengine-python-sdk" name = "volcengine-python-sdk"
version = "5.0.46" version = "5.0.47"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
@@ -1913,9 +1904,9 @@ dependencies = [
{ name = "six" }, { name = "six" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6e/c6/2094e5ef1303393aa78599cd750f95f32143643ee330ddfe0b23faaf59fd/volcengine_python_sdk-5.0.46.tar.gz", hash = "sha256:3d7ee12ad559d07bb53a160b4ba98feaeee09f4a03d87a86855b162571ff717a", size = 10694620, upload-time = "2026-08-20T11:55:35.039Z" } sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/91/2a/25dcb01d4f2a4629bfd46dee0972d57c590e29e9d7271a918985593ba414/volcengine_python_sdk-5.0.47.tar.gz", hash = "sha256:64dc6f4c591e27972f5331f097f0d5926adf96f3643c264df0cc0007f6bf0d8d", size = 10748617, upload-time = "2026-08-27T12:07:11.774Z" }
wheels = [ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e4/f0/171e760424849eabfe735c4b37e29949b2279555dd1654819db973ee7be7/volcengine_python_sdk-5.0.46-py2.py3-none-any.whl", hash = "sha256:f73e8f675f9ff545d0fec54b7057436dba0f1c420fcb235848a76a5120e5bf18", size = 43011218, upload-time = "2026-08-20T11:55:28.333Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cf/d6/8ddd4f13716af4fb86057b48d0166e915ab013a1e287d29b6b11cdbde891/volcengine_python_sdk-5.0.47-py2.py3-none-any.whl", hash = "sha256:f2605e9a2a22143557fb9a58c9f1523032448cd6282add6387ac57df0510519b", size = 43168897, upload-time = "2026-08-27T12:07:04.749Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]