diff --git a/README.md b/README.md index 0fc7804..9688e7e 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ worker///__admin__ | `restart` | 重启该副本,副本槽位(index)由管理循环驱动,重新生成新 `worker_id`(`worker---`)并经 liveliness 更新 | | `stop` | 停止该副本 | | `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]` 运行时配置(路由配置除外);成功则写回配置并重启该类型全部对应副本,任一失败返回失败信息 | ### REST 管理端点(仅超管) @@ -250,7 +250,9 @@ worker///__admin__ | `POST` | `/api/v1/admin/workers//stop` | 停止 single 副本 / 某插件全部副本 | | `GET` | `/api/v1/admin/workers//config` | 读取该插件的现有 `[workers.config]`(展示当前值) | | `POST` | `/api/v1/admin/workers//config` | 更新配置(body `{updates:{k:v}}`),下发并重启 | -| `POST` | `/api/v1/admin/workers//scale` | 调整该类型副本数(body `{count:N}`),扩缩容 + 持久化 | +| `POST` | `/api/v1/admin/workers//scale` | 调整该类型副本数(body `{count:<全局总量>}`):写全局 count → 清除逐实例定制回均分 → 广播全部存活实例,**全局对称分摊** +| `GET` | `/api/v1/admin/workers//instances` | 按外部进程实例(instance_id)分组读取该类型副本(供前端逐实例精细扩缩展示) | +| `POST` | `/api/v1/admin/workers//scale_instance` | **逐实例精细扩缩**(body `{instance_id, count}`):只把指定实例的副本数调到 N,其余实例不动,持久化到 `instcounts` 覆盖表 | > 旧版运行时未声明 `__admin__` 管理能力时,管理指令会超时(HTTP 502,见日志提示)。 @@ -267,7 +269,8 @@ worker///__admin__ - **扩容**:新增副本槽位(`WorkerPool` 新增 `_managed_worker` 管理循环,新副本注册 liveliness 后可被负载均衡)。 - **缩容**:随机挑选多余副本触发 **`drain`(优雅排空)**——先摘除 liveliness/业务 Queryable **隔绝新请求**,等待正在处理的**在途请求完成**后再关闭该副本(`in-flight` 计数实现),避免中断业务;管理循环不重建。 - **目标数量持久化**:`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///__admin__ launch_worker_count = "toml" # "toml"(默认)=启动用 toml 的 count 覆盖并写回 cache;"cache"=cache 为单一事实来源,toml 仅在缺失时引导 ``` -- **进程插件(external)不受该开关影响**:网关只对 **inline** 插件用 cache 解析启动副本数(见「启动流程」第 5 步 `load_workers`);external 插件由外部编排按其自身配置 `config/workers//config.toml` 的 `count` 启动,不经网关、也不读 cache。运行时 `scale` 对 external 仍走「向存活副本下发 rescale」路径,同样与该开关无关。 +- **进程插件(external)实例粒度参与分摊**:网关只对 **inline** 插件用 cache 解析启动副本数(见「启动流程」第 5 步 `load_workers`),external 插件的启动副本数由外部编排按自身配置决定;但**运行时的缩放**上,external 并不退化为“每进程全额”,而是同样在**共享 cache 上登记实例参与方**来分摊,避免多实例把副本翻倍: + - **实例登记 + 心跳租约**:每个 external 进程启动时向 `worker:cm:instances:` 登记自身参与方(`[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:` = `{instance_id: 绝对副本数}`。有记录的实例按绝对数执行;没记录的实例回退到全局均分。一旦用 `scale_instance` 定制了某实例,之后 `scale`(全局)会**清除整张覆盖表**回到均分(显式“复位”),因此两者不会互相干扰。 +- **命中判断**:rescale 的下发必须命中**本实例**才执行(`instance_id` 与 `register_instance` 一致),目标是其它实例则安全忽略,避免误缩。 +- **REST / UI**:`POST /admin/workers//scale_instance`(body `{instance_id, count}`)下发;`GET /admin/workers//instances` 供前端按实例分组展示当前副本数。前端用「调整数量」(全局对称)和「逐实例精细扩缩」表格(实例 id / 模式 / 当前副本数 / 目标 + 应用)两个入口。 +- **协调前提**:与外部广播缩放一致,需共享 `[cache]`,且外部进程用稳定的 `--instance` / `CHONGMING_WORKER_INSTANCE`(缺省 `{hostname}:{pid}` 虽唯一但重启会变,跨重启持续命中请显式指定)。 ### inline worker 宿主级领导选举(gunicorn 多 worker 兼容) @@ -314,7 +331,10 @@ inline worker 的生命周期交给**基于分布式锁的宿主级领导选举* - **登录**:经 auth 插件 `POST /auth/login` 领取管理 Token(无需手填 JWT),从 JWT 解析当前用户与超管标识。 - **鉴权**:仅 `is_superuser=true` 可管理;权限不足时前端/后端弹出对应提示(403)。 - **列表分页**:展示副本(插件类型 / 副本 ID / 模式 inline|external / 状态),内置分页。 -- **操作**:单副本重启、批量重启(选中)、插件级重启全部副本、更新插件配置(弹窗展示现有配置值,点行带入编辑)、**调整副本数量**(当前数 + `el-input-number` 设目标 + 应用,可扩缩容)。 +- **查询与筛选**:列表顶部统一筛选栏——关键字搜索(插件类型 / 副本 ID / 实例 ID)、模式筛选(全部 / inline / external)、副本数 ≥ N 过滤;**同时作用于下方「逐实例精细扩缩」分组表**(副本数过滤仅对分组表生效),筛选条件变化自动回到第一页。 +- **操作**:单副本重启、批量重启(选中)、插件级重启全部副本、更新插件配置(弹窗展示现有配置值,点行带入编辑;支持对象/数组等嵌套值的**结构化 JSON 编辑与校验/格式化**)、**调整副本数量**(当前数 + `el-input-number` 设目标 + 应用,可扩缩容)。 +- **逐实例精细扩缩**:按外部进程实例(instance_id)**分组**展示(实例 id / 模式 / 当前副本数),每行可设目标副本数并「应用」——仅调整该实例,其余实例不动;全局「调整数量」后分组表也会自动刷新。 +- **自动刷新**:页面每 10s 轮询总副本列表**与**逐实例分组表,扩缩容/重启/停止等异步结果无需手动刷新即可反映。 - **auth 核心插件**:配置不可更改,不在列表中展示(`Worker` 列表自动过滤 `worker_type == "auth"`)。 - 调小副本数会**触发优雅排空**并在前端确认提示中说明。 diff --git a/src/chongming/app/core/lifespan_setup.py b/src/chongming/app/core/lifespan_setup.py index c6de62b..2414913 100644 --- a/src/chongming/app/core/lifespan_setup.py +++ b/src/chongming/app/core/lifespan_setup.py @@ -446,7 +446,7 @@ async def load_workers(app: FastAPI, session: zenoh.Session, manager: WorkerMana factory_name = wc["factory"] # count 以 chongming-cache 为单一事实来源:cache 有值时用 cache 的全局总量, # 否则用配置值引导并写回 cache(多网关共享同一 cache 时自动对齐)。 - count = manager.resolved_count(wc["type"], wc["count"]) + count = wc["count"] worker_type = wc["type"] worker_config = wc["config"] diff --git a/src/chongming/app/core/worker_manager.py b/src/chongming/app/core/worker_manager.py index 9cbb5e5..e71dfdf 100644 --- a/src/chongming/app/core/worker_manager.py +++ b/src/chongming/app/core/worker_manager.py @@ -6,6 +6,7 @@ import random import socket import time import orjson +from collections import OrderedDict from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional import zenoh from chongming_cache import Cache, DistributedLock @@ -16,9 +17,7 @@ logger = get_logger("worker_manager") # ── 全局副本数(chongming-cache 单一事实来源)── # 多网关共用同一 cache(单实例=本地 diskcache,多实例=共享卷/redis), # 「count」语义 = 该类 worker 的**全局目标总量**,各网关按在线数量分摊。 -_SCALE_DESIRED_KEY = "worker:cm:counts:{type}" # 全局目标总量(int) _SCALE_GATEWAYS_KEY = "worker:cm:gateways" # 在线网关列表:[[id, 最近心跳 ts], ...](JSON) -_SCALE_QUOTA_KEY = "worker:cm:quota:{type}:{gw}" # 各网关分摊配额(int) # 在线网关租约(秒):网关心跳每 refresh_interval(15s) 更新一次自身 ts,超过 LEASE 未刷新 # 视为离线的死网关,在下一次读写时被清理。容忍多次漏跳,且 crash/kill -9 不残留。 @@ -52,6 +51,38 @@ def _next_reply(iterator): 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: """ 统一 Worker 管理器 @@ -68,6 +99,9 @@ class WorkerManager: launch_worker_count: str = "toml"): self.session = session self.cache = cache + # RPC 缓存进程内一级(LRU+TTL),兜底并加速 cacheable 命中; + # 二级为共享 cache(diskcache/redis),I/O 移出事件循环走 executor。 + self._rpc_mem_cache = _MemLRU() # 启动时全局副本数的优先来源:"toml"=用 toml 的 count 覆盖 cache(经典模式); # "cache"=以 cache 为单一事实来源。对应 config.toml [cache].launch_worker_count。 self._launch_worker_count = launch_worker_count @@ -90,12 +124,8 @@ class WorkerManager: self._started_extra: List[Any] = [] # ── 全局副本数协调(chongming-cache 单一事实来源)──────────── - - def _scale_desired_key(self, worker_type: str) -> str: - 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) + # count/quota 的读写与分摊已收归 worker 层(chongming_worker.runtime): + # 网关不再直写这些缓存键,仅维护「在线网关列表」(register/heartbeat/unregister)。 def _read_gateways_raw(self) -> List[List]: """读取在线网关原始列表 [[id, ts], ...]。""" @@ -249,44 +279,6 @@ class WorkerManager: except Exception as e: # noqa: BLE001 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): """ 直接注册一个本地 worker(同进程内使用) @@ -511,6 +503,36 @@ class WorkerManager: 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( self, worker_type: str, @@ -598,10 +620,19 @@ class WorkerManager: call() / call_raw() 共用本实现,仅入参出参形态不同(dict ↔ bytes)。 """ # 1. 缓存检查(仅在 cacheable=True 时检查) - if cacheable and cache_key is not None and self.cache is not None: - if cache_key in self.cache: - logger.debug(f"命中缓存: {cache_key}") - return self.cache[cache_key] + if cacheable and cache_key is not None: + # 一级:进程内内存 LRU(事件循环内 O(1),零 I/O,命中完全绕过 Zenoh) + mem_val = self._rpc_mem_cache.get(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 worker_expr = await self.get_worker(worker_type) @@ -654,11 +685,13 @@ class WorkerManager: if reply.ok: # type: ignore reply_bytes = bytes(reply.ok.payload) if reply.ok.payload else b"{}" # 缓存写入(仅在 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) 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 else: error_payload = reply.err.payload # type: ignore @@ -824,59 +857,168 @@ class WorkerManager: """把某类型的副本数调整为 count(扩缩容)并持久化到配置。 下发逻辑(与 inline/external 的统一管理协议一致): - - inline:类型在本进程内有 WorkerPool,可直接调用其 set_target(进程内扩缩, - 无需经过 Zenoh 往返); - - external:类型由独立进程/容器运行,向该类型任一存活副本经 Zenoh 下发 - rescale 指令,由其**所在进程内**的 WorkerPool 扩缩并写回自身配置。 + - inline:类型在本进程内有 WorkerPool,只下发本网关进程内的池(进程内扩缩, + 无需跨进程广播,副本按其所在网关分摊配额); + - external:类型由独立进程/容器运行,向该类型**全部存活**副本经 Zenoh 广播 + rescale 指令,由各进程内 WorkerPool 按「全局 count ÷ 存活实例数」重新分摊 + 并各写回自身配置(避免只缩/扩其中一个实例造成不一致)。 + 全局缩放同时清除各实例的「逐实例绝对副本数」覆盖,回到对称分摊——因此它是 + 「一键复位对称」入口;逐实例精细缩放走 scale_instance(见后)。 缩容走优雅排空:摘除 liveliness/业务 Queryable 隔绝新请求,等正在处理的 在途请求完成后再关闭副本(避免中断业务)。 Args: worker_type: 插件类型 - count: 目标副本数(>= 0) + count: 目标副本总数(全局总量,>= 0) timeout: 下发超时(秒) Returns: - {"worker_type", "target"(全局总量), "quota", "mode"..., "persist"|...} + {"worker_type", "target"(全局总量), "mode", "sent_to", "reply"} Raises: - RuntimeError: count 非法 / 类型无可用副本或本进程无其池 + RuntimeError: count 非法 / 类型无可用副本 """ count = max(0, int(count)) - # 「count=全局总量」:写全局 cache 单一来源;本网关按在线网关分摊承担 quota。 - if self.cache is not None: - self.cache[self._scale_desired_key(worker_type)] = count - quota = self.own_quota(worker_type, count) - logger.info( - "[scale] %s 全局目标=%d,本网关分摊 quota=%d", worker_type, count, quota - ) - # inline:优先走本进程内池(按分摊配额),直接且即时应答 + # 「count=全局总量」收归 worker 层:网关不再直写 count/quota 缓存、不再直调进程池。 + # 统一经 rescale 下发全局 target,由 worker 读取在线网关分摊、写缓存并扩缩本进程副本; + # inline/external 口径一致(inline 副本在本进程,经 Zenoh 直达其 __admin__)。 + inline = False try: from chongming_worker.runtime import get_process_pool - pool = get_process_pool(worker_type) - except Exception: # noqa: BLE001 - 运行时缺依赖时退回 Zenoh 下发 - pool = None - if pool is not None: - await pool.set_target(quota) - persist = "未持久化" - if self.cache is not None: - persist = f"count={count}(全局)已写 cache,本网关 quota={quota}" - else: - 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}, - } + inline = get_process_pool(worker_type) is not None + except Exception: # noqa: BLE001 - 运行时缺依赖视为非 inline + inline = False + candidates = [ + w for w in self.list_workers() + if w["worker_type"] == worker_type and w.get("alive") + ] + if not candidates: + raise RuntimeError(f"类型 '{worker_type}' 没有可用副本,无法调整数量") - # 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( ( w for w in self.list_workers() @@ -885,18 +1027,16 @@ class WorkerManager: None, ) if candidate is None: - raise RuntimeError(f"类型 '{worker_type}' 没有可用副本,无法调整数量") - + raise RuntimeError(f"类型 '{worker_type}' 没有在线副本,无法读取其配置") reply = await self._send_admin_cmd( - worker_type, candidate["worker_id"], "rescale", - timeout=timeout, data_extra={"scale": count}, + worker_type, candidate["worker_id"], "get_config", timeout=timeout ) return { "worker_type": worker_type, - "target": count, - "mode": "external", - "sent_to": candidate["worker_id"], - "reply": reply, + "worker_id": candidate["worker_id"], + "status": reply.get("status"), + "config": reply.get("config"), + "source": reply.get("source", "toml"), } async def update_worker_config( @@ -908,12 +1048,12 @@ class WorkerManager: """经 Zenoh 下发 config_set 指令,让目标类型的全部副本修改配置并自我重启。 worker 侧行为(见 chongming_worker.runtime): - - 仅 index0 副本把 updates 写入插件 config.toml 的 [workers.config](原子写回, - 路由等保留键会被拒绝); + - 仅 index0 副本把 updates 写入共享缓存的 [workers.config](key + worker:cm:configs:{type},全局唯一,路由/保留键会被拒绝);不再回写 config.toml。 - 所有副本合并 updates 后以新配置重启(新 worker_id)。 - 本方法只负责"下发 + 汇总",写操作由 worker 触发执行,故进程插件位于 - 不同路径/宿主机时也可正确写回各自的配置文件。 + 本方法只负责"下发 + 汇总",写操作由 worker 访问其自身缓存触发执行,故进程插件 + 位于不同路径/宿主机时也能把配置持久化到各自的共享缓存后端。 Args: worker_type: 插件类型 diff --git a/src/chongming/app/routes/worker_admin.py b/src/chongming/app/routes/worker_admin.py index 51ad069..6f80d26 100644 --- a/src/chongming/app/routes/worker_admin.py +++ b/src/chongming/app/routes/worker_admin.py @@ -166,6 +166,72 @@ async def scale_workers( 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): """配置更新请求体:写目标插件 config.toml 的 [workers.config] 段。""" updates: dict = Field(..., description="待写入 [workers.config] 的键值(路由/加载控制键除外)") @@ -179,11 +245,29 @@ async def read_worker_config( ): """读取某插件 config.toml 的 [workers.config] 现有值(供前端展示)。 - 直接从插件配置文件读取,反映最近一次落盘结果(含 worker 写回后的值)。 + 经 Zenoh 向该类任一存活副本下发 get_config,由其访问自身缓存(或回退 toml 种子) + 返回 —— 网关不直接接触 external worker 的缓存后端。无存活副本 / 旧版运行时 + 不支持 get_config 时,回退回直读 config/workers//config.toml 的 [workers].config。 路由配置(add.toml 等)不在此范围。 - 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" if not path.exists(): raise HTTPException(status_code=404, detail=f"插件 {worker_type} 的配置文件不存在: {path}") @@ -194,6 +278,7 @@ async def read_worker_config( return { "worker_type": worker_type, "config": cfg if isinstance(cfg, dict) else {}, + "source": "toml", "success": True, } diff --git a/test/native_fastapi.py b/test/native_fastapi.py new file mode 100644 index 0000000..10f07cd --- /dev/null +++ b/test/native_fastapi.py @@ -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()} \ No newline at end of file diff --git a/test/test_scale_instance_distribution.py b/test/test_scale_instance_distribution.py new file mode 100644 index 0000000..8e034ed --- /dev/null +++ b/test/test_scale_instance_distribution.py @@ -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()) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 8977cbd..83c5a3d 100644 --- a/uv.lock +++ b/uv.lock @@ -541,18 +541,18 @@ wheels = [ [[package]] name = "chongming-worker" -version = "0.4.0" +version = "0.4.1" source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } dependencies = [ + { name = "chongming-cache" }, { name = "eclipse-zenoh" }, { name = "minio" }, { name = "pydantic" }, { 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 = [ - { 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]] @@ -1098,9 +1098,9 @@ wheels = [ [[package]] name = "nuitka" -version = "4.1.3" +version = "4.2" 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]] name = "openai" @@ -1608,27 +1608,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.4" +version = "0.16.5" 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 = [ - { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] @@ -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" }, ] -[[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]] name = "transitions" version = "0.9.3" @@ -1905,7 +1896,7 @@ wheels = [ [[package]] name = "volcengine-python-sdk" -version = "5.0.46" +version = "5.0.47" source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } dependencies = [ { name = "certifi" }, @@ -1913,9 +1904,9 @@ dependencies = [ { name = "six" }, { 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 = [ - { 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]