已合并
fix(config):测试issue修复 #540
王明琦创建于 9月17日
fix(config):测试issue修复 #540
已合并
共 39 个文件变更+1509-296
| @@ -162,7 +162,8 @@ spec: | |||
| 162 | # 空 kubeconfig → Pod 内走 in-cluster ServiceAccount | 162 | # 空 kubeconfig → Pod 内走 in-cluster ServiceAccount |
| 163 | - name: AGENT_RUNTIME_KUBECONFIG | 163 | - name: AGENT_RUNTIME_KUBECONFIG |
| 164 | value: "" | 164 | value: "" |
| 165 | - # runtime 自身 ns(downward API):模板 namespace 为空时 AgentServer 落同名 ns | 165 | + # runtime 自身 ns(downward API):模板 namespace 为空时 AgentServer |
| 166 | + # 落同名 ns;代码另有 SA 文件兜底(config.own_namespace),注入仅为显式化 | ||
| 166 | - name: POD_NAMESPACE | 167 | - name: POD_NAMESPACE |
| 167 | valueFrom: | 168 | valueFrom: |
| 168 | fieldRef: | 169 | fieldRef: |
| @@ -126,7 +126,7 @@ flowchart TB | |||
| 126 | 126 | ||
| 127 | | 字段 | 类型 | 说明 | | 127 | | 字段 | 类型 | 说明 | |
| 128 | |---|---|---| | 128 | |---|---|---| |
| 129 | -| `template_id` / `template_name` / `description` / `enabled` / `data` | — | 元信息(`enabled=False` 的模板路由不解析) | | 129 | +| `template_id` / `template_name` / `description` / `data` | — | 元信息(`enabled` 已删 2026-09:模板生命周期=存在性,禁用=从载荷删除,见 `docs/feature/2026-09-drop-enabled-fields.md`) | |
| 130 | | `namespace` | str | K8s 命名空间(Pod 级;**空串 = 继承**——AgentServer 落 runtime 自身 ns,由 `POD_NAMESPACE`(downward API)解析,见 §deploy 兜底链与 docs/feature/2026-09-agentserver-ns-follow-runtime.md;省略仍落默认值 `"default"`,表达继承须显式下发 `""`) | | 130 | | `namespace` | str | K8s 命名空间(Pod 级;**空串 = 继承**——AgentServer 落 runtime 自身 ns,由 `POD_NAMESPACE`(downward API)解析,见 §deploy 兜底链与 docs/feature/2026-09-agentserver-ns-follow-runtime.md;省略仍落默认值 `"default"`,表达继承须显式下发 `""`) | |
| 131 | | `nodeName` | str? | 节点绑定(A 类;渲染为 `V1PodSpec.nodeName` 绕过调度器点名上机,deploy tool 镜像预载场景用。`None` = 不绑定;空串同 `None`。坏值 → Pod 永久 Pending 挂满 ready_timeout,入口按 hostname 形态 ≤253 校验) | | 131 | | `nodeName` | str? | 节点绑定(A 类;渲染为 `V1PodSpec.nodeName` 绕过调度器点名上机,deploy tool 镜像预载场景用。`None` = 不绑定;空串同 `None`。坏值 → Pod 永久 Pending 挂满 ready_timeout,入口按 hostname 形态 ≤253 校验) | |
| 132 | | `pod_name` | str | Pod 名前缀(pod_id = 前缀-随机后缀,默认 `agentserver`) | | 132 | | `pod_name` | str | Pod 名前缀(pod_id = 前缀-随机后缀,默认 `agentserver`) | |
| @@ -164,7 +164,7 @@ flowchart TB | |||
| 164 | 164 | ||
| 165 | > 内部实现注:水合后是**统一容器规范形**(2026-09 起,`containers.py`):`Template` 持模板级字段(namespace/node_name/pod_name/sse_path/ready_*/策略)+ `main_container`(canonical dict,13 键全填满:ports/env/env_from/resources/三类挂载/nfs/security_context/readiness_probe 等)+ `sidecars`(同款 canonical 列表,name 升序)——即快照与 RM `pod_spec` 契约,见 `docs/spec/session-manager.md` §models)——**同值必同 deploy_ver**(三段式水合 vs 手构 canonical 等值,承重断言固化;RM 侧 `normalize_pod_spec` 对缓存补缺省,未来加容器字段零伪日落)。`max_pods` 不在 template 里——它是派生值 `⌈scope_concurrency / pod_concurrency⌉`;`autoscale_interval` 是全局默认(0.5s)。 | 165 | > 内部实现注:水合后是**统一容器规范形**(2026-09 起,`containers.py`):`Template` 持模板级字段(namespace/node_name/pod_name/sse_path/ready_*/策略)+ `main_container`(canonical dict,13 键全填满:ports/env/env_from/resources/三类挂载/nfs/security_context/readiness_probe 等)+ `sidecars`(同款 canonical 列表,name 升序)——即快照与 RM `pod_spec` 契约,见 `docs/spec/session-manager.md` §models)——**同值必同 deploy_ver**(三段式水合 vs 手构 canonical 等值,承重断言固化;RM 侧 `normalize_pod_spec` 对缓存补缺省,未来加容器字段零伪日落)。`max_pods` 不在 template 里——它是派生值 `⌈scope_concurrency / pod_concurrency⌉`;`autoscale_interval` 是全局默认(0.5s)。 |
| 166 | 166 | ||
| 167 | -**`routing_scope`**(config_sync 下发,持久化到 DB 表 `routing_scope`):scope 定义 = `scope_id + index + template_id + routing_rules + enabled + expires_at`,scope↔模板多对一。 | 167 | +**`routing_scope`**(config_sync 下发,持久化到 DB 表 `routing_scope`):scope 定义 = `scope_id + index + template_id + routing_rules + expires_at`,scope↔模板多对一。生命周期 = **存在性 + expires_at**(`enabled` 已删 2026-09,禁用=从载荷删除,残留键防御性视为缺席)。 |
| 168 | 168 | ||
| 169 | | 字段 | 类型 | 说明 | | 169 | | 字段 | 类型 | 说明 | |
| 170 | |---|---|---| | 170 | |---|---|---| |
| @@ -172,8 +172,7 @@ flowchart TB | |||
| 172 | | `index` | int | 匹配序号;请求按 (index 升序, scope_id 升序) first-fit | | 172 | | `index` | int | 匹配序号;请求按 (index 升序, scope_id 升序) first-fit | |
| 173 | | `template_id` | str | 引用的 template(必须在本批模板列表内;多个 scope 可引用同一模板) | | 173 | | `template_id` | str | 引用的 template(必须在本批模板列表内;多个 scope 可引用同一模板) | |
| 174 | | `routing_rules` | str | **布尔表达式字符串**,条件经 `and`/`or` 与括号任意组合;**null/空串/纯空白 = 通配兜底 scope**(命中一切) | | 174 | | `routing_rules` | str | **布尔表达式字符串**,条件经 `and`/`or` 与括号任意组合;**null/空串/纯空白 = 通配兜底 scope**(命中一切) | |
| 175 | -| `enabled` | bool | 默认 `true`;`false` 时不参与路由匹配与 eager 预热(仍落库/进快照,与模板 `enabled` 同款语义) | | 175 | +| `expires_at` | datetime? | 可选过期时间(ISO-8601);到点后视为不生效(`null` = 永不过期)。route 热路径按墙钟判定,无需再 sync。**生命周期唯一状态字段**(`enabled` 已删 2026-09:禁用 = 从载荷删除该 scope,残留 `enabled` 键防御性视为缺席) | |
| 176 | -| `expires_at` | datetime? | 可选过期时间(ISO-8601);到点后视为不生效(`null` = 永不过期)。route 热路径按墙钟判定,无需再 sync | | ||
| 177 | 176 | ||
| 178 | 表达式语法: | 177 | 表达式语法: |
| 179 | 178 | ||
| @@ -188,7 +187,7 @@ field := user_id | group_id | bot_id(固定小写枚举) | |||
| 188 | 上限 := 长度 ≤ 8000,括号嵌套 ≤ 32 | 187 | 上限 := 长度 ≤ 8000,括号嵌套 ≤ 32 |
| 189 | ``` | 188 | ``` |
| 190 | 189 | ||
| 191 | -**匹配语义**(resolve 权威定义):请求属性 (user_id, group_id, bot_id) 对 scopes 按 `(index ASC, scope_id ASC)` 排序遍历,**首个命中的 scope 即止**(first-fit);scope 命中 = 空 routing_rules(通配)或表达式求值为真;条件求值 = 属性值(缺省 `""`)`in`/`not in` 值集合。引用模板缺失或 `enabled=False` 的 scope、以及 scope 自身 `enabled=False` / `expires_at` 已过期的,均视为不命中,继续落下一个。**下发方保证含一个空表达式的通配 scope 兜底**(通配判定只计生效中的 scope);服务端缺失时仅 WARNING 放行,运行时无匹配 → 503 `CONFIG_NOT_FOUND`。 | 190 | +**匹配语义**(resolve 权威定义):请求属性 (user_id, group_id, bot_id) 对 scopes 按 `(index ASC, scope_id ASC)` 排序遍历,**首个命中的 scope 即止**(first-fit);scope 命中 = 空 routing_rules(通配)或表达式求值为真;条件求值 = 属性值(缺省 `""`)`in`/`not in` 值集合。引用模板缺失(悬挂引用,防御)的 scope、以及 `expires_at` 已过期的,均视为不命中,继续落下一个(两级 `enabled` 已删 2026-09,生命周期 = 存在性 + expires_at)。**下发方保证含一个空表达式的通配 scope 兜底**(通配判定只计生效中的 scope);服务端缺失时仅 WARNING 放行,运行时无匹配 → 503 `CONFIG_NOT_FOUND`。 |
| 192 | 191 | ||
| 193 | **`config_sync` 入参完整 schema**(三段式全量快照,一次请求同时携带三个列表,**独占**;旧 `kind/op` 协议与无 `containers` 键的 legacy 内联载荷均 400 拒绝): | 192 | **`config_sync` 入参完整 schema**(三段式全量快照,一次请求同时携带三个列表,**独占**;旧 `kind/op` 协议与无 `containers` 键的 legacy 内联载荷均 400 拒绝): |
| 194 | 193 | ||
| @@ -211,13 +210,13 @@ field := user_id | group_id | bot_id(固定小写枚举) | |||
| 211 | "scope_concurrency": 3, "pod_concurrency": 2, "...": "..."} ], | 210 | "scope_concurrency": 3, "pod_concurrency": 2, "...": "..."} ], |
| 212 | "scopes": [ {"scope_id": "...", "index": 0, "template_id": "tpl-1", | 211 | "scopes": [ {"scope_id": "...", "index": 0, "template_id": "tpl-1", |
| 213 | "routing_rules": "user_id in ('admin') or group_id not in ('g1')", | 212 | "routing_rules": "user_id in ('admin') or group_id not in ('g1')", |
| 214 | - "enabled": true, "expires_at": null} ] | 213 | + "expires_at": null} ] |
| 215 | } | 214 | } |
| 216 | ``` | 215 | ``` |
| 217 | 216 | ||
| 218 | -- 语义:**以数组为准的全量替换**(upsert 全部 + 删除消失项;容器以本批为集 GC);幂等重放收敛(affected_scopes 为空)。 | 217 | +- 语义:**以数组为准的全量替换**(upsert 全部 + 删除消失项;容器以本批为集 GC);幂等重放收敛(affected_scopes 为空)。**残留防御(2026-09-drop-enabled-fields)**:两级 `enabled` 已删——载荷项带 `enabled:false` 视为缺席剔除 + WARNING(scope 缺席 = 删除走扩散③ 优雅排空;模板缺席被引用 → 400);`enabled:true` 残留键静默忽略。 |
| 219 | -- 校验(400 VALIDATION,锁外零副作用):缺 `containers` 键(legacy 内联载荷);`templates`/`scopes` 非 list;模板缺 `main_container_id`;**mixed**(引用键与 legacy 内联容器键并存);container_id 空/>100/同批重复/未被引用/双角色;容器逐项按角色校验(见 `container` 结构表;未知键/不可表示字段);模板引用不在本批 containers;sidecar 引用重复/>8;volumes(重复卷名/多源/无源/悬挂挂载/未挂载卷/`subPath` 非 configMap/NFS 逾界);模板级 int 严格/策略下界/`nodeName` hostname;scope_id 字符集/`index` 拒 bool/引用不在本批模板集/`routing_rules` 表达式语法/`enabled` 须 bool/`expires_at` ISO-8601 或 null/同批重复(语法细则见上文)。 | 218 | +- 校验(400 VALIDATION,锁外零副作用):缺 `containers` 键(legacy 内联载荷);`templates`/`scopes` 非 list;模板缺 `main_container_id`;**mixed**(引用键与 legacy 内联容器键并存);container_id 空/>100/同批重复/未被引用/双角色;容器逐项按角色校验(见 `container` 结构表;未知键/不可表示字段);模板引用不在本批 containers;sidecar 引用重复/>8;volumes(重复卷名/多源/无源/悬挂挂载/未挂载卷/`subPath` 非 configMap/NFS 逾界);模板级 int 严格/策略下界/`nodeName` hostname;scope_id 字符集/`index` 拒 bool/引用不在本批模板集/`routing_rules` 表达式语法/`expires_at` ISO-8601 或 null/同批重复(语法细则见上文)。 |
| 220 | -- 每次成功下发都会:重建路由快照(§5.1 `routing:snapshot`)、对每个**生效中** scope 推 RM 池参数 + pod_spec(**eager 预热**:autoscale 下一拍即预热 min_idle)、对禁用/过期 scope 与被删 scope 推 `min_idle=0`(自然排空)。 | 219 | +- 每次成功下发都会:重建路由快照(§5.1 `routing:snapshot`)、对每个**生效中** scope 推 RM 池参数 + pod_spec(**eager 预热**:autoscale 下一拍即预热 min_idle)、对过期 scope 与被删 scope 推 `min_idle=0`(停保温;被删 scope 先 bump 日落进排空窗口)。 |
| 221 | 220 | ||
| 222 | **curl 调用示例**(Envelope 包装:`type` 须为端点名、`metadata.request_id` 必填(兼幂等键)、三段式载荷在 `rawdata`;带 K8s pod 内探测的脚本版本见 `scripts/config_sync_seed.sh`。示例载荷要点:主容器探针恒 `httpGet` 且**无** `timeoutSeconds`/sidecar `tcpSocket` + 特权三件套/模板只持容器引用与 `volumes`/空 `routing_rules` = 通配兜底 scope): | 221 | **curl 调用示例**(Envelope 包装:`type` 须为端点名、`metadata.request_id` 必填(兼幂等键)、三段式载荷在 `rawdata`;带 K8s pod 内探测的脚本版本见 `scripts/config_sync_seed.sh`。示例载荷要点:主容器探针恒 `httpGet` 且**无** `timeoutSeconds`/sidecar `tcpSocket` + 特权三件套/模板只持容器引用与 `volumes`/空 `routing_rules` = 通配兜底 scope): |
| 223 | 222 | ||
| @@ -258,7 +258,7 @@ | |||
| 258 | > `{session_manager}:`,hash tag)拼出;调用侧把前缀同时声明为 `KEYS[1]` 作路由锚 | 258 | > `{session_manager}:`,hash tag)拼出;调用侧把前缀同时声明为 `KEYS[1]` 作路由锚 |
| 259 | > (集群客户端据此把 EVAL 路由到 tag 归属节点;单实例无影响)。下文伪码从简省略。 | 259 | > (集群客户端据此把 EVAL 路由到 tag 归属节点;单实例无影响)。下文伪码从简省略。 |
| 260 | 260 | ||
| 261 | -**`LUA_ROUTE_PLACE(session_id, scope_id, expiry_ts, session_ttl, scope_concurrency, pod_concurrency, max_pods, now)`** —— route 的原子核心:一次脚本内完成"亲和续期 / 惰性回收 / scope 闸门 / first-fit 选 Pod / 提交",中途无其它请求插入(无 race)。`session_ttl` 随亲和写入 session HASH,供 TOUCH 就地读取(不依赖 scope:config)。 | 261 | +**`LUA_ROUTE_PLACE(session_id, scope_id, expiry_ts, session_ttl, scope_concurrency, pod_concurrency, max_pods, now)`** —— route 的原子核心:一次脚本内完成"亲和续期 / rebind 重解 / scope 闸门 / first-fit 选 Pod / 提交",中途无其它请求插入(无 race)。`session_ttl` 随亲和写入 session HASH,供 TOUCH 就地读取(不依赖 scope:config)。**传入 scope 的语义(2026-09-scope-affinity-hold,#152)**:调用方要维持的绑定 scope(已有绑定的持有续期)或新会话的 first-fit 结果(全新放置)——规则变化不即时迁移存量会话,放置只发生在无绑定的调用里。 |
| 262 | ``` | 262 | ``` |
| 263 | # 1. 读现有亲和绑定(若该 session_id 之前 route 过) | 263 | # 1. 读现有亲和绑定(若该 session_id 之前 route 过) |
| 264 | 1. existing = HGETALL(session:{session_id}) | 264 | 1. existing = HGETALL(session:{session_id}) |
| @@ -270,19 +270,25 @@ | |||
| 270 | 1b. if existing 且 existing.scope_id/pod_id/expiry 任一为 nil: | 270 | 1b. if existing 且 existing.scope_id/pod_id/expiry 任一为 nil: |
| 271 | ZREM session_expiry session_id; DEL session:{session_id}; 落穿到 4 | 271 | ZREM session_expiry session_id; DEL session:{session_id}; 落穿到 4 |
| 272 | 272 | ||
| 273 | -# 2. 亲和命中且未过期 → 仅续期,返回原 Pod。 | 273 | +# 2. 亲和命中且未过期且 Pod 注册仍在 → 仅续期,返回原 Pod。 |
| 274 | # 不重新抢额度:chat_session 粒度模型下,额度在首次 route 已占用,持续到 TTL 老化; | 274 | # 不重新抢额度:chat_session 粒度模型下,额度在首次 route 已占用,持续到 TTL 老化; |
| 275 | # 同一 chat_session 的后续请求只刷新老化计时,不重复扣 scope/Pod 容量。 | 275 | # 同一 chat_session 的后续请求只刷新老化计时,不重复扣 scope/Pod 容量。 |
| 276 | -2. if existing.scope_id == scope_id and existing.expiry > now: | 276 | +# Pod 注册缺失(notify_pod_dead 清理窗口)判死绑定:继续续期会让会话对着已删 |
| 277 | +# 的 sse_url 无限自旋且 sweeper 永远收不走。 | ||
| 278 | +2. if existing.scope_id == scope_id and existing.expiry > now | ||
| 279 | + and EXISTS(pod:{scope_id}:{existing.pod_id}:info): | ||
| 277 | HSET session:{session_id} expiry expiry_ts session_ttl {session_ttl} # 续期 + 刷新 ttl(config 改了随 route 生效) | 280 | HSET session:{session_id} expiry expiry_ts session_ttl {session_ttl} # 续期 + 刷新 ttl(config 改了随 route 生效) |
| 278 | ZADD session_expiry expiry_ts session_id | 281 | ZADD session_expiry expiry_ts session_id |
| 279 | return {action:"refresh", pod_id: existing.pod_id} | 282 | return {action:"refresh", pod_id: existing.pod_id} |
| 280 | 283 | ||
| 281 | -# 3. 惰性兜底:绑定存在但已过期,或 group/bot 变了导致 scope_id 不同 → 先回收旧绑定。 | 284 | +# 3. 惰性兜底:绑定存在但已过期 / Pod 注册消失 / scope 不匹配(并发竞态)→ |
| 282 | -# "惰性"= 在访问当场发现 session 已死就立即清理,不等 sweeper 下一 tick。 | 285 | +# 回收旧绑定并返回 rebind,handler 换 first-fit 结果重试——**本调用内不落 |
| 283 | -# 空在此处不触发 idle_consider(统一交 sweeper 空 Pod pass,见 §5.4)。 | 286 | +# 放置**(曾见绑定的调用直接按传入 scope 放置,会把会话部署进已禁用/删除 |
| 287 | +# 的 scope)。"惰性"= 在访问当场发现 session 已死就立即清理,不等 sweeper | ||
| 288 | +# 下一 tick。空在此处不触发 idle_consider(统一交 sweeper 空 Pod pass,见 §5.4)。 | ||
| 284 | 3. if existing: | 289 | 3. if existing: |
| 285 | 内部走 EVICT 逻辑(用 existing.scope_id/pod_id) | 290 | 内部走 EVICT 逻辑(用 existing.scope_id/pod_id) |
| 291 | + return {action:"rebind"} | ||
| 286 | 292 | ||
| 287 | # 4. scope 闸门:scope_concurrency 限的是"活跃 chat_session 数",而非"在途请求数" | 293 | # 4. scope 闸门:scope_concurrency 限的是"活跃 chat_session 数",而非"在途请求数" |
| 288 | # (gateway 保证每 chat_session ≤1 在途,故活跃数 == 在途数上界)。SCARD 即活跃数。 | 294 | # (gateway 保证每 chat_session ≤1 在途,故活跃数 == 在途数上界)。SCARD 即活跃数。 |
| @@ -393,18 +399,30 @@ cached = ctx.idempotency.get(metadata.request_id); if cached: return cached | |||
| 393 | # resolve:热路径先查 scope:{scope_id}:config 缓存(命中不查库);miss 或被 config_sync 失效才读共享 DB。 | 399 | # resolve:热路径先查 scope:{scope_id}:config 缓存(命中不查库);miss 或被 config_sync 失效才读共享 DB。 |
| 394 | scope_config = resolve(scope_id, group_id, bot_id, user_id) | 400 | scope_config = resolve(scope_id, group_id, bot_id, user_id) |
| 395 | 401 | ||
| 402 | +# 亲和持有判定(2026-09-scope-affinity-hold,#152):已有绑定 → 仲裁目标改为绑定 | ||
| 403 | +# scope(session_ttl 取其当前模板;scope/模板已从快照消失则回退哈希留存的 | ||
| 404 | +# session_ttl)。规则变化不即时迁移存量会话;first-fit 只决定新会话(与 rebind | ||
| 405 | +# 后)的去处。 | ||
| 406 | +binding = HGETALL(session:{session_id}) | ||
| 407 | +target_scope = binding.scope_id if binding 有 scope_id+pod_id else scope_id | ||
| 408 | + | ||
| 396 | # 容量数学(见术语表):pod_concurrency = 单 Pod 满载容量(per-scope 独占);max_pods = 本 scope 的 Pod 数上限。 | 409 | # 容量数学(见术语表):pod_concurrency = 单 Pod 满载容量(per-scope 独占);max_pods = 本 scope 的 Pod 数上限。 |
| 397 | max_pods = ceil(scope_config.scope_concurrency / scope_config.pod_concurrency) | 410 | max_pods = ceil(scope_config.scope_concurrency / scope_config.pod_concurrency) |
| 398 | 411 | ||
| 399 | pod_spec = extract_pod_spec(scope_config.template) # acquire 时下发给 Resource Manager 的部署子集 | 412 | pod_spec = extract_pod_spec(scope_config.template) # acquire 时下发给 Resource Manager 的部署子集 |
| 400 | 413 | ||
| 401 | loop: | 414 | loop: |
| 402 | - # 原子核心:一次 Lua 完成 亲和续期/惰性回收/闸门/选 Pod/提交。 | 415 | + # 原子核心:一次 Lua 完成 亲和续期/rebind 重解/闸门/选 Pod/提交。 |
| 403 | # 传 expiry_ts = now + session_ttl,并把 session_ttl 传入(写入 session HASH 供 TOUCH 就地读)。 | 416 | # 传 expiry_ts = now + session_ttl,并把 session_ttl 传入(写入 session HASH 供 TOUCH 就地读)。 |
| 404 | - result = redis.eval(LUA_ROUTE_PLACE, session_id, scope_id, | 417 | + result = redis.eval(LUA_ROUTE_PLACE, session_id, target_scope, |
| 405 | - now + scope_config.session_ttl, scope_config.session_ttl, | 418 | + now + session_ttl, session_ttl, |
| 406 | scope_config.scope_concurrency, scope_config.pod_concurrency, max_pods, now) | 419 | scope_config.scope_concurrency, scope_config.pod_concurrency, max_pods, now) |
| 407 | 420 | ||
| 421 | + if result.action == "rebind": | ||
| 422 | + # 绑定已被惰性回收(过期/Pod 注册消失):换 first-fit 结果重试——曾见绑定 | ||
| 423 | + # 的调用内不落放置(防向已禁用/删除 scope 重部署)。 | ||
| 424 | + target_scope = scope_id; continue | ||
| 425 | + | ||
| 408 | if result.action in ("refresh","placed"): | 426 | if result.action in ("refresh","placed"): |
| 409 | # 成功:取 Pod 的 sse_url 返回 gateway;gateway 据此直连 Pod(数据面绕过 Session Manager)。 | 427 | # 成功:取 Pod 的 sse_url 返回 gateway;gateway 据此直连 Pod(数据面绕过 Session Manager)。 |
| 410 | pod_sse_url = HGET pod:{scope_id}:{result.pod_id}:info sse_url | 428 | pod_sse_url = HGET pod:{scope_id}:{result.pod_id}:info sse_url |
Mapplications/agent_runtime/docs/feature/2026-09-agentserver-ns-follow-runtime.md+21-0文件内容审核中,请稍后刷新重试
| @@ -17,8 +17,11 @@ | |||
| 17 | 17 | ||
| 18 | | 日期 | 文档 | 一句话 | | 18 | | 日期 | 文档 | 一句话 | |
| 19 | |---|---|---| | 19 | |---|---|---| |
| 20 | +| 2026-09 | [删除两级 enabled(禁用=删除,生命周期=存在性+expires_at)](2026-09-drop-enabled-fields.md) | #154 定案:template/scope 两级 enabled 半死态(准入看/暖侧不看→暖 Pod 死锁,refresh 重焐热同族)整字段删除;「关」=从载荷删除(删 scope 走扩散③ 优雅排空,删 template 被 parse 400 强制显式组合);expires_at 保留为唯一生命周期字段;残留 enabled:false 防御性视为缺席(防静默重开准入);refresh 对过期 scope 只日落不保温;DB 两表先发版后 DROP+前置检查;555 用例 | | ||
| 21 | +| 2026-09 | [scope 亲和保持(规则变更不迁移旧会话 + 路由性排除日落)](2026-09-scope-affinity-hold.md) | #152:route 每请求重算 first-fit 参与仲裁存量绑定=规则一改旧会话被即时迁移;改为亲和只认「绑定+Pod 存活」(重算只服务新放置,曾见绑定的调用不落放置,rebind 重试);禁用/失权/删 scope bump gen 走排空窗口(与 #151 同哲学,首因定窗);index 重排零打扰;corner 用例反转;554 用例 | | ||
| 22 | +| 2026-09 | [日落优雅排空窗口(drain=session_ttl + surge 余量 1)](2026-09-sunset-drain-window.md) | 会话中的 Pod 不再被日落秒杀:排空截止=首因下发+session_ttl(route 亲和/touch 只查 pod:info 天然连续),过窗即收;max_pods 排空期 +1(三闸门同步);闸门 409 等排空(窗 ≤ session_ttl);A 类 sync 统一进窗;取代 09-15 硬切决策;542 用例+cluster 23/23 | | ||
| 20 | | 2026-09 | [删除模板字段 message_timeout](2026-09-drop-message-timeout.md) | 全链路死参数硬删:runtime 只落库无出口(pool_config/route 响应/Redis 键均不含,单改连 affected_scopes 都不进;实测 10s/90s 配 40s 慢调用均不超时)——契约/DB/代码/文档同步删;载荷残留键静默忽略(同 max_pods)不 400;数据面 SSE 超时定性为 gateway 自身配置;存量库先发版后 DROP COLUMN | | 23 | | 2026-09 | [删除模板字段 message_timeout](2026-09-drop-message-timeout.md) | 全链路死参数硬删:runtime 只落库无出口(pool_config/route 响应/Redis 键均不含,单改连 affected_scopes 都不进;实测 10s/90s 配 40s 慢调用均不超时)——契约/DB/代码/文档同步删;载荷残留键静默忽略(同 max_pods)不 400;数据面 SSE 超时定性为 gateway 自身配置;存量库先发版后 DROP COLUMN | |
| 21 | -| 2026-09 | [AgentServer 默认 namespace 跟随 runtime 自身 ns](2026-09-agentserver-ns-follow-runtime.md) | 模板下发 namespace 空串=继承——AgentServer 落 runtime 自身 ns(downward API 注入 `POD_NAMESPACE`,`default_namespace = POD_NAMESPACE or "default"` 单点改 config.from_env;`AGENT_RUNTIME_DEFAULT_NAMESPACE` 变量移除);部署模板换注入源;DB/wire 契约/存量库零改动 | | 24 | +| 2026-09 | [AgentServer 默认 namespace 跟随 runtime 自身 ns](2026-09-agentserver-ns-follow-runtime.md) | 模板下发 namespace 空串=继承——AgentServer 落 runtime 自身 ns(`own_namespace()`:`POD_NAMESPACE` env > **SA 文件兜底** > `"default"`,镜像/模板任何升级顺序零配置自愈);`AGENT_RUNTIME_DEFAULT_NAMESPACE` 变量移除;DB/wire 契约/存量库零改动;09-17 cyz2 403 复盘见篇内 | |
| 22 | | 2026-09 | [日落排空语义修复(闸门基准=当前生效版本 + reclaim 落后免老化)](2026-09-sunset-drain-semantics-fix.md) | config_sync 闸门原比新载荷版本,当前代空闲 Pod 必然≠新版被误判遗留、又受 min_idle 底数保护永不回收=**永久 409**(cyz 实测 idle 33min≫pod_ttl);基准改当前生效版本(与 refresh 闸门比当前代次同构)+reclaim 对 ver/gen 落后 idle Pod 免老化即刻回收(当前版超额仍抗抖);会话硬切决策接受(②B 否决留档);528 用例 | | 25 | | 2026-09 | [日落排空语义修复(闸门基准=当前生效版本 + reclaim 落后免老化)](2026-09-sunset-drain-semantics-fix.md) | config_sync 闸门原比新载荷版本,当前代空闲 Pod 必然≠新版被误判遗留、又受 min_idle 底数保护永不回收=**永久 409**(cyz 实测 idle 33min≫pod_ttl);基准改当前生效版本(与 refresh 闸门比当前代次同构)+reclaim 对 ver/gen 落后 idle Pod 免老化即刻回收(当前版超额仍抗抖);会话硬切决策接受(②B 否决留档);528 用例 | |
| 23 | | 2026-09 | [data / created_at / updated_at 三元组对齐](2026-09-data-triad-alignment.md) | container/routing_scope/link_binding_state 补 data;config_sync 透传模板/资源 data;Gateway agent-resource 补时间戳;实例资源更新保留 created_at | | 26 | | 2026-09 | [data / created_at / updated_at 三元组对齐](2026-09-data-triad-alignment.md) | container/routing_scope/link_binding_state 补 data;config_sync 透传模板/资源 data;Gateway agent-resource 补时间戳;实例资源更新保留 created_at | |
| 24 | | 2026-09 | [删除 service_config_template legacy 内联容器列](2026-09-drop-legacy-inline-template-columns.md) | 硬删 24 个拆表前内联列;Manager 只认 data.config_sync.containers + refs;Runtime 保留 wire mixed 黑名单;存量库须 DROP COLUMN | | 27 | | 2026-09 | [删除 service_config_template legacy 内联容器列](2026-09-drop-legacy-inline-template-columns.md) | 硬删 24 个拆表前内联列;Manager 只认 data.config_sync.containers + refs;Runtime 保留 wire mixed 黑名单;存量库须 DROP COLUMN | |
| @@ -11,7 +11,7 @@ | |||
| 11 | | `facade.py` | `ResourceManagerFacade`(SM→RM 进程内入口,薄封装) | | 11 | | `facade.py` | `ResourceManagerFacade`(SM→RM 进程内入口,薄封装) | |
| 12 | | `orchestrator.py` | acquire(取暖/选主 deploy/follower 等待室)+ idle_consider + update_pool_config + cleanup + 幂等缓存 | | 12 | | `orchestrator.py` | acquire(取暖/选主 deploy/follower 等待室)+ idle_consider + update_pool_config + cleanup + 幂等缓存 | |
| 13 | | `state.py` | RM Redis 键 schema 唯一出口(`RMKeys`/`ResourceState`) | | 13 | | `state.py` | RM Redis 键 schema 唯一出口(`RMKeys`/`ResourceState`) | |
| 14 | -| `lua_scripts.py` | 6 个 Lua 全文 | | 14 | +| `lua_scripts.py` | 7 个 Lua 全文 | |
| 15 | | `k8s.py` | `K8sPodClient` 接口 + `RealK8sPodClient`(kubernetes_asyncio)+ `FakeK8sPodClient` | | 15 | | `k8s.py` | `K8sPodClient` 接口 + `RealK8sPodClient`(kubernetes_asyncio)+ `FakeK8sPodClient` | |
| 16 | | `sweeper.py` | 四个后台任务(各自带选主锁) | | 16 | | `sweeper.py` | 四个后台任务(各自带选主锁) | |
| 17 | | `models.py` | `PodInfo`/`PodDeployInfo`/判死枚举/label 常量 | | 17 | | `models.py` | `PodInfo`/`PodDeployInfo`/判死枚举/label 常量 | |
| @@ -22,8 +22,8 @@ | |||
| 22 | |---|---| | 22 | |---|---| |
| 23 | | `acquire(scope_id, pod_spec, pool_config, request_id)` | `{pod_id, pod_sse_url}`;失败抛 `MaxPodsReached`/`DeployFailed`(SM 映射 503 NO_POD_AVAILABLE) | | 23 | | `acquire(scope_id, pod_spec, pool_config, request_id)` | `{pod_id, pod_sse_url}`;失败抛 `MaxPodsReached`/`DeployFailed`(SM 映射 503 NO_POD_AVAILABLE) | |
| 24 | | `idle_consider(pod_id, scope_id)` | `{transitioned_to_idle}`,幂等 | | 24 | | `idle_consider(pod_id, scope_id)` | `{transitioned_to_idle}`,幂等 | |
| 25 | -| `update_pool_config(scope_id, pool_config, pod_spec?)` | `{updated}`(config_sync 触发;mapping **永不含 generation**) | | 25 | +| `update_pool_config(scope_id, pool_config, pod_spec?)` | `{updated}`(config_sync/refresh push 触发;mapping **永不含 generation**;含 session_ttl;检出落后 Pod 时戳记排空纪元 `scope:drain_until`,2026-09-17) | |
| 26 | -| `bump_generation(scope_id)` | `generation: int`(config_refresh 触发的代次日落,HINCRBY 原子自增,唯一写点) | | 26 | +| `bump_generation(scope_id)` | `generation: int`(代次日落:HINCRBY 原子自增,唯一写点;调用方=config_refresh 全量 + config_sync 路由性排除变更[2026-09-scope-affinity-hold]) | |
| 27 | | `sunset_pending_pods(scope_id)` | `list[str]`(config_refresh 前置闸门:scope 注册 Pod 中 `generation ≠ 当前配置代次` 者,忙排空/idle 待回收均计;非空 → SM 侧 409 CONFIG_SYNC_BUSY,见 session-manager spec ⓪ 步) | | 27 | | `sunset_pending_pods(scope_id)` | `list[str]`(config_refresh 前置闸门:scope 注册 Pod 中 `generation ≠ 当前配置代次` 者,忙排空/idle 待回收均计;非空 → SM 侧 409 CONFIG_SYNC_BUSY,见 session-manager spec ⓪ 步) | |
| 28 | | `cleanup(namespace?, label_selector?)` | `cleaned: int`(运维批删) | | 28 | | `cleanup(namespace?, label_selector?)` | `cleaned: int`(运维批删) | |
| 29 | | `known_scope_ids()` | RM 已知 scope 枚举(SCAN scope:config;config_sync 的被删 scope drain 收敛用——RM config 键是幻影预热的真源) | | 29 | | `known_scope_ids()` | RM 已知 scope 枚举(SCAN scope:config;config_sync 的被删 scope drain 收敛用——RM config 键是幻影预热的真源) | |
| @@ -49,13 +49,13 @@ | |||
| 49 | 49 | ||
| 50 | `_follow_leader`(M8,deploy 锁输家的等待室): | 50 | `_follow_leader`(M8,deploy 锁输家的等待室): |
| 51 | - 准入走 `LUA_DEPLOY_FOLLOWER_GATE` 原子闸门,上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);overflow 严格快失败 MaxPodsReached。 | 51 | - 准入走 `LUA_DEPLOY_FOLLOWER_GATE` 原子闸门,上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);overflow 严格快失败 MaxPodsReached。 |
| 52 | -- 等待有界:`ready_timeout + 10s` 余量;轮询 `resource:scope:{sid}:pods` 出现新 Pod 且 pod:info 有 sse_url → **直接复用返回**(与 reuse 分支同构,SM 侧重跑仲裁即可)。 | 52 | +- 等待有界:`ready_timeout + 10s` 余量;轮询 `resource:scope:{sid}:pods` 出现新 Pod 且 pod:info 有 sse_url → `LUA_POP_IDLE` 忙记账(SREM idle + DEL idle_since,与 reuse 分支对齐;幂等)→ **直接复用返回**(与 reuse 分支同构,SM 侧重跑仲裁即可)。不摘则 autoscale(leader=补位路径,idle_flag=True 注册)的热备被接管后仍占 warm 底数 → 恒 skip_warm 不补位,池卡 1 忙 Pod(2026-09-17 wangchang follower_reuse 泄漏实录)。 |
| 53 | - leader 失败判定:deploy 锁空闲且无新 Pod → `DeployFailed`(**follower 不接管**——同镜像同环境大概率也失败);deadline 到 → MaxPodsReached。 | 53 | - leader 失败判定:deploy 锁空闲且无新 Pod → `DeployFailed`(**follower 不接管**——同镜像同环境大概率也失败);deadline 到 → MaxPodsReached。 |
| 54 | - 等待期进度行:每 `FOLLOWER_PROGRESS_LOG_SEC`(5s)一条 INFO `follower still waiting: scope= follower= waited_s=`——ready_timeout 最长 300s,INFO 下不留日志空白(部署风暴期的观测窗口);复用成功另有一条 INFO `acquire follower reuses leader pod`。 | 54 | - 等待期进度行:每 `FOLLOWER_PROGRESS_LOG_SEC`(5s)一条 INFO `follower still waiting: scope= follower= waited_s=`——ready_timeout 最长 300s,INFO 下不留日志空白(部署风暴期的观测窗口);复用成功另有一条 INFO `acquire follower reuses leader pod`。 |
| 55 | - 错误路径双清:占位 + follower 成员都进 finally;崩溃遗留由闸门 `ZREMRANGEBYSCORE(deadline)` 兜底。 | 55 | - 错误路径双清:占位 + follower 成员都进 finally;崩溃遗留由闸门 `ZREMRANGEBYSCORE(deadline)` 兜底。 |
| 56 | 56 | ||
| 57 | `idle_consider`:`LUA_RELEASE` 转 idle 暖池(起 pod_ttl 计时)+ pod:info.phase=idle;幂等。 | 57 | `idle_consider`:`LUA_RELEASE` 转 idle 暖池(起 pod_ttl 计时)+ pod:info.phase=idle;幂等。 |
| 58 | -`update_pool_config`:HSET 覆盖池参数;A 类变更附带 pod_spec 时同时刷 deploy_ver/pod_spec_json(autoscale 补位用新 deploy 字段);**mapping 永不含 generation——代次只经 `bump_generation` 单调递增,推送永不重置**。 | 58 | +`update_pool_config`:HSET 覆盖池参数(含 `session_ttl`,排空 deadline 计算用);A 类变更附带 pod_spec 时同时刷 deploy_ver/pod_spec_json(autoscale 补位用新 deploy 字段);**mapping 永不含 generation——代次只经 `bump_generation` 单调递增,推送永不重置**。**日落排空纪元戳记(2026-09-17)**:推送后注册表存在版本(A 类)或代次(refresh / config_sync 路由性排除已先 bump)落后的 Pod 且纪元未活跃 → SET `scope:drain_until = now + session_ttl`(EX 同值 + DRAIN_SLACK_S=60 崩溃兜底);期间容量上限 +SURGE_MARGIN(见 LUA_ACQUIRE/autoscale),reclaim 对 stale 延后到截止后回收。**首因下发定窗**:纪元已活跃则不重戳(B 类纯池参数下发不延长);排空中的 refresh 本就被闸门 409 拦住(候选集内老代 Pod 放行的竞态形态除外,见 R8)。 |
| 59 | `bump_generation`:HINCRBY `scope:config.generation`;现有 Pod 代次全部落后 → LUA_ACQUIRE 过滤 / `_current_version_idle` 判 stale / autoscale 重建(场景 M-R,SM 的 config_refresh 触发)。"当前版本"判定 = **deploy_ver 相等 ∧ generation 相等**;两侧同缺(空串)视为一致——从未刷新过的 scope 零行为变化。 | 59 | `bump_generation`:HINCRBY `scope:config.generation`;现有 Pod 代次全部落后 → LUA_ACQUIRE 过滤 / `_current_version_idle` 判 stale / autoscale 重建(场景 M-R,SM 的 config_refresh 触发)。"当前版本"判定 = **deploy_ver 相等 ∧ generation 相等**;两侧同缺(空串)视为一致——从未刷新过的 scope 零行为变化。 |
| 60 | `cleanup`:K8s list+delete,**不操作 Redis 编排态**(被删 Pod 由 watch/reconcile 兜底发现);ns 404 容忍为 cleaned=0,**403 保持 fail-fast**(静默清零会掩盖部署配错);逐 Pod 一条 INFO `cleanup deleted pod: pod= namespace=`(批删中途中断时可见删到哪;k8s.delete 自身明细在 DEBUG)+ 结尾 WARNING 聚合。 | 60 | `cleanup`:K8s list+delete,**不操作 Redis 编排态**(被删 Pod 由 watch/reconcile 兜底发现);ns 404 容忍为 cleaned=0,**403 保持 fail-fast**(静默清零会掩盖部署配错);逐 Pod 一条 INFO `cleanup deleted pod: pod= namespace=`(批删中途中断时可见删到哪;k8s.delete 自身明细在 DEBUG)+ 结尾 WARNING 聚合。 |
| 61 | 61 | ||
| @@ -65,9 +65,10 @@ | |||
| 65 | |---|---|---| | 65 | |---|---|---| |
| 66 | | `resource:scope:{sid}:pods` | ZSET | 该 scope 全部 Pod(in_use ∪ idle);**ZCARD+deploying SCARD 参与 max_pods 判定** | | 66 | | `resource:scope:{sid}:pods` | ZSET | 该 scope 全部 Pod(in_use ∪ idle);**ZCARD+deploying SCARD 参与 max_pods 判定** | |
| 67 | | `resource:scope:{sid}:idle` | SET | idle 暖池;acquire 从此取暖 Pod | | 67 | | `resource:scope:{sid}:idle` | SET | idle 暖池;acquire 从此取暖 Pod | |
| 68 | -| `resource:scope:{sid}:config` | HASH | min_idle_pods/max_pods/pod_ttl/pod_concurrency/deploy_ver/pod_spec_json/**generation**(config_refresh 的代次日落标记,**唯一写点 = HINCRBY**,config_sync 推送永不重置)。**config_sync 对每个存活 scope 主动写入/刷新(带 pod_spec)——无请求 scope 的 min_idle 预热依赖它**;首 acquire 兜底写入;被删 scope 推 min_idle=0 停预热自然排空 | | 68 | +| `resource:scope:{sid}:config` | HASH | min_idle_pods/max_pods/pod_ttl/**session_ttl**(排空窗口用,2026-09-17)/pod_concurrency/deploy_ver/pod_spec_json/**generation**(config_refresh 的代次日落标记,**唯一写点 = HINCRBY**,config_sync 推送永不重置)。**config_sync 对每个存活 scope 主动写入/刷新(带 pod_spec)——无请求 scope 的 min_idle 预热依赖它**;首 acquire 兜底写入;被删 scope 推 min_idle=0 停预热自然排空 | |
| 69 | | `resource:scope:{sid}:deploying` | ZSET | deploy 占位 token→deadline 秒级 score(计入 max_pods,防并发超配;闸门/autoscale 按 deadline 原子清崩溃遗留——硬崩后进程内清理不存在,占位不得永久虚占容量) | | 69 | | `resource:scope:{sid}:deploying` | ZSET | deploy 占位 token→deadline 秒级 score(计入 max_pods,防并发超配;闸门/autoscale 按 deadline 原子清崩溃遗留——硬崩后进程内清理不存在,占位不得永久虚占容量) | |
| 70 | | `resource:scope:{sid}:deploy_followers` | ZSET | follower 等待室(request_id→deadline 秒级 score;闸门按 deadline 原子清过期) | | 70 | | `resource:scope:{sid}:deploy_followers` | ZSET | follower 等待室(request_id→deadline 秒级 score;闸门按 deadline 原子清过期) | |
| 71 | +| `resource:scope:{sid}:drain_until` | STR | 日落排空截止时间戳(秒);存在 ⟺ 排空纪元活跃 → 容量上限 +SURGE_MARGIN / stale 回收延后到截止。写点=update_pool_config(首因下发,不重戳);正常终止=reclaim 收完落后 Pod 主动 DEL;EX=window+60s 为崩溃兜底(2026-09-17) | | ||
| 71 | | `resource:pod:{pod}:info` | HASH | scope_id/pod_sse_url/pod_ip/namespace/phase/created_ts/deploy_ver/**sse_port/health_path**(Pod 自己烘焙的探测契约;A 类变更后 scope 当前配置已换代,watch 探测必须用 Pod 自己的参数,否则存量老 Pod 被探错路径误杀)/**generation**(注册时刻代次烙印,REGISTER 服务端读 scope:config——与 bump 原子排队,deploy 中途刷新不误伤晚注册的新 Pod) | | 72 | | `resource:pod:{pod}:info` | HASH | scope_id/pod_sse_url/pod_ip/namespace/phase/created_ts/deploy_ver/**sse_port/health_path**(Pod 自己烘焙的探测契约;A 类变更后 scope 当前配置已换代,watch 探测必须用 Pod 自己的参数,否则存量老 Pod 被探错路径误杀)/**generation**(注册时刻代次烙印,REGISTER 服务端读 scope:config——与 bump 原子排队,deploy 中途刷新不误伤晚注册的新 Pod) | |
| 72 | | `resource:pod:{pod}:idle_since` | STR | idle 起始(reclaim 计时);存在 ⟺ 在 idle 池 | | 73 | | `resource:pod:{pod}:idle_since` | STR | idle 起始(reclaim 计时);存在 ⟺ 在 idle 池 | |
| 73 | | `resource:pod:{pod}:health_fails` | STR | 健康探测连续失败次数(场景 N) | | 74 | | `resource:pod:{pod}:health_fails` | STR | 健康探测连续失败次数(场景 N) | |
| @@ -80,14 +81,15 @@ | |||
| 80 | 81 | ||
| 81 | `eval()` 统一出口带异常留痕(同 SM:空表异常 WARNING、>200ms 慢 eval WARNING、常规 DEBUG)。**诊断只读方法**:`health_fails(pod_id)`(/visualization/scope 用)。 | 82 | `eval()` 统一出口带异常留痕(同 SM:空表异常 WARNING、>200ms 慢 eval WARNING、常规 DEBUG)。**诊断只读方法**:`health_fails(pod_id)`(/visualization/scope 用)。 |
| 82 | 83 | ||
| 83 | -## lua_scripts.py —— 6 个 Lua | 84 | +## lua_scripts.py —— 7 个 Lua |
| 84 | 85 | ||
| 85 | | 脚本 | 一句话职责 | | 86 | | 脚本 | 一句话职责 | |
| 86 | |---|---| | 87 | |---|---| |
| 87 | -| `LUA_ACQUIRE` | 取暖 Pod 复用(**跳过 deploy_ver 或 generation 不匹配**——A 类变更后老版本、config_refresh 后老代次暖 Pod 不外发,由 reclaim 版本/代次感知回收)→ 无匹配判 max_pods(ZCARD pods + ZCARD deploying)→ 占位 ZADD deploying(score=deadline,先清过期) → need_deploy | | 88 | +| `LUA_ACQUIRE` | 取暖 Pod 复用(**跳过 deploy_ver 或 generation 不匹配**——A 类变更后老版本、config_refresh 后老代次暖 Pod 不外发,由 reclaim 版本/代次感知回收)→ 无匹配判容量(ZCARD pods + ZCARD deploying;**排空纪元活跃(EXISTS scope:drain_until)→ 上限 +SURGE_MARGIN=1**,2026-09-17 优雅排空)→ 占位 ZADD deploying(score=deadline,先清过期) → need_deploy | |
| 88 | -| `LUA_PLACEHOLDER` | autoscale 专用占位(判 max_pods + ZADD,**不碰 idle 池**——补位不该消耗暖 Pod;同款 deadline 自清) | | 89 | +| `LUA_PLACEHOLDER` | autoscale 专用占位(判容量 + ZADD,**不碰 idle 池**——补位不该消耗暖 Pod;同款 deadline 自清;同款排空 surge +1) | |
| 89 | | `LUA_REGISTER` | deploy 成功登记:pod:info(含 sse_port/health_path + **generation 服务端烙印**——读注册时刻 scope:config 当前代次)/ scope:pods / pods:all 同写,清占位;idle_flag=1(热备)入 idle 池 | | 90 | | `LUA_REGISTER` | deploy 成功登记:pod:info(含 sse_port/health_path + **generation 服务端烙印**——读注册时刻 scope:config 当前代次)/ scope:pods / pods:all 同写,清占位;idle_flag=1(热备)入 idle 池 | |
| 90 | | `LUA_RELEASE` | idle_consider:转 idle 暖池,**仅首次转入(SADD=1)起 pod_ttl 计时**;周期重放(reconcile stale/idle_consider 去重重发)不刷新计时——否则空闲 Pod 永不回收;acquire 弹出后再转 idle 重新计时;**已 PURGE 的 Pod(info 已清)no-op**(防 TOCTOU 幽灵成员) | | 91 | | `LUA_RELEASE` | idle_consider:转 idle 暖池,**仅首次转入(SADD=1)起 pod_ttl 计时**;周期重放(reconcile stale/idle_consider 去重重发)不刷新计时——否则空闲 Pod 永不回收;acquire 弹出后再转 idle 重新计时;**已 PURGE 的 Pod(info 已清)no-op**(防 TOCTOU 幽灵成员) | |
| 92 | +| `LUA_POP_IDLE` | 忙占用摘出 idle 暖池(SREM idle + DEL idle_since;幂等,非 idle 成员/请求驱动 leader 的 idle_flag=False 注册均 no-op)——`_follow_leader` follower 接管 leader 热备 Pod 的忙记账,与 LUA_ACQUIRE reuse 分支对齐(2026-09-17 修复) | | ||
| 91 | | `LUA_PURGE` | Pod 死亡/reclaim 后清全部 RM key(返回其 scope_id;幂等) | | 93 | | `LUA_PURGE` | Pod 死亡/reclaim 后清全部 RM key(返回其 scope_id;幂等) | |
| 92 | | `LUA_DEPLOY_FOLLOWER_GATE` | follower 等待室原子准入:先 `ZREMRANGEBYSCORE` 清过期 → ZADD 先行 → ZCARD 超限自退(纪律同 LUA_WAITER_GATE,禁止先查后加) | | 94 | | `LUA_DEPLOY_FOLLOWER_GATE` | follower 等待室原子准入:先 `ZREMRANGEBYSCORE` 清过期 → ZADD 先行 → ZCARD 超限自退(纪律同 LUA_WAITER_GATE,禁止先查后加) | |
| 93 | 95 | ||
| @@ -102,7 +104,7 @@ | |||
| 102 | - **单次调用超时(2026-09 健壮性加固)**:create/read/list/delete 一律传 `_request_timeout`(常量 `CREATE_TIMEOUT=30`/`READ_TIMEOUT=10`/`LIST_TIMEOUT=15`/`DELETE_TIMEOUT=60`)——kubernetes_asyncio 不传时 aiohttp ClientTimeout 全 None(连库默认都覆盖),API server/网络挂起会无限悬挂并逐级拖死 deploy 与上层 route。超时异常走既有 except 链归一 DeployFailed(契约不变)。 | 104 | - **单次调用超时(2026-09 健壮性加固)**:create/read/list/delete 一律传 `_request_timeout`(常量 `CREATE_TIMEOUT=30`/`READ_TIMEOUT=10`/`LIST_TIMEOUT=15`/`DELETE_TIMEOUT=60`)——kubernetes_asyncio 不传时 aiohttp ClientTimeout 全 None(连库默认都覆盖),API server/网络挂起会无限悬挂并逐级拖死 deploy 与上层 route。超时异常走既有 except 链归一 DeployFailed(契约不变)。 |
| 103 | - **生命周期并发(同批加固)**:`close()` 锁内只做引用摘除(快照+置空+复位 `_loaded`),网络收尾 `api_client.close()` 放锁外(持锁等网络会饿死 start);在飞调用持旧引用,各调用点(`deploy/get_pod/list_pods/delete/_read`)在 start 后快照 `self._core`,None 即 `DeployFailed("k8s client closed")`(不裸抛 AttributeError);close 后的惰性调用(`_loaded=False`)经 `start()` 自愈重建。 | 105 | - **生命周期并发(同批加固)**:`close()` 锁内只做引用摘除(快照+置空+复位 `_loaded`),网络收尾 `api_client.close()` 放锁外(持锁等网络会饿死 start);在飞调用持旧引用,各调用点(`deploy/get_pod/list_pods/delete/_read`)在 start 后快照 `self._core`,None 即 `DeployFailed("k8s client closed")`(不裸抛 AttributeError);close 后的惰性调用(`_loaded=False`)经 `start()` 自愈重建。 |
| 104 | - `deploy(pod_spec)`:pod_id = `{pod_name}-{随机10}-{随机5}`(**K8s 随机 Pod 名,严禁业务 id 当实例 id——历史死锁根因**);409 名字冲突重命名重试至多 3 次;`_wait_ready` 轮询至 Ready+有 podIP(每 30s 一条 INFO 进度行,终态/超时 WARNING 带 waited_s),终态(Failed/Succeeded)/消失/超时 → DeployFailed;**create 之后的任何失败/取消先 best-effort 删除该 Pod 再抛,DeployFailed 携带 pod_id/namespace 属性供上层兜底**(契约:失败路径不留孤儿物理 Pod——未 REGISTER 的 Pod 不在 pods:all,watch/reconcile 只做 Redis→K8s 单向对账,孤儿无人认领);`get_pod`/`list_pods`/`delete` 带 DEBUG 耗时;`probe_health` 异常原因 DEBUG 留痕(调用方 sweeper 同节奏 WARNING)。 | 106 | - `deploy(pod_spec)`:pod_id = `{pod_name}-{随机10}-{随机5}`(**K8s 随机 Pod 名,严禁业务 id 当实例 id——历史死锁根因**);409 名字冲突重命名重试至多 3 次;`_wait_ready` 轮询至 Ready+有 podIP(每 30s 一条 INFO 进度行,终态/超时 WARNING 带 waited_s),终态(Failed/Succeeded)/消失/超时 → DeployFailed;**create 之后的任何失败/取消先 best-effort 删除该 Pod 再抛,DeployFailed 携带 pod_id/namespace 属性供上层兜底**(契约:失败路径不留孤儿物理 Pod——未 REGISTER 的 Pod 不在 pods:all,watch/reconcile 只做 Redis→K8s 单向对账,孤儿无人认领);`get_pod`/`list_pods`/`delete` 带 DEBUG 耗时;`probe_health` 异常原因 DEBUG 留痕(调用方 sweeper 同节奏 WARNING)。 |
| 105 | -- **namespace 解析(2026-09 跟随自身 ns)**:`deploy`/`_build_pod_body` 取 `pod_spec.get("namespace") or self.default_namespace`——模板显式 ns 优先,**空串 = 继承语义**落 `default_namespace`(= `POD_NAMESPACE`(downward API 注入的自身 ns,deploy 模板注入),未注入时 `"default"`;Real/Fake 同款)。Pod 记录存的 namespace 是**解析后**值(register_pod),回收/删除跨 ns 无歧义。不带 ns 的 `cleanup` 兜底同链——集群内默认目标从 `"default"` 变为 runtime 自身 ns,"空目标必须无匹配 label_selector"红线继续适用。 | 107 | +- **namespace 解析(2026-09 跟随自身 ns)**:`deploy`/`_build_pod_body` 取 `pod_spec.get("namespace") or self.default_namespace`——模板显式 ns 优先,**空串 = 继承语义**落 `default_namespace`(= `config.own_namespace()`:`POD_NAMESPACE` env > SA namespace 文件 > `"default"`,Real/Fake 同款)。Pod 记录存的 namespace 是**解析后**值(register_pod),回收/删除跨 ns 无歧义。不带 ns 的 `cleanup` 兜底同链——集群内默认目标从 `"default"` 变为 runtime 自身 ns,"空目标必须无匹配 label_selector"红线继续适用。 |
| 106 | - `_build_pod_body`(2026-09 统一规范形):入口 `normalize_pod_spec`(补缺省键)→ shape 探测(缺 `main_container` = 旧扁平缓存 → **DeployFailed**,防渲染空镜像 Pod)→ `find_container_conflict` fail-fast(撞容器名/撞端口,防 agent 经 127.0.0.1 连错进程)→ **单一 `_build_container(c, cont, role, idx, pvc_seen, nfs_seen)` 渲染主/sidecar**,role 分支仅两处:ports 有名(sse/http)vs 无名声明、探针恒 httpGet 打 sse 端口无 timeout vs 可选 tcp/http 带 timeout。securityContext/command/args/挂载四族/env/envFrom/resources 主/sidecar 一致渲染(决策 B:securityContext 全量,apparmor 走 Pod annotation;全默认 → None 走镜像默认;command/args 缺省走镜像 ENTRYPOINT/CMD)。label `{jiuwenclaw-component: agentserver, app: pod_id}`;资源 requests/limits;**容器 env 注入**(真 AgentServer 需 `AGENT_HTTP_ENABLED/HOST/PORT` 开 HTTP 入口);**envFrom 引用 → `envFrom`(`_render_env_from`:secretRef/configMapRef/prefix/optional 逐字段透传;缺省 None 不设;脏缓存坏项跳过;值不落模板/快照,只传引用名——密钥不再明文)**;restart_policy=Always。`probe_health`(场景 N)与 readiness 同源取主容器探针 path(sweeper 从 scope:config 的 pod_spec_json 读,legacy 形回退旧扁平键)。 | 108 | - `_build_pod_body`(2026-09 统一规范形):入口 `normalize_pod_spec`(补缺省键)→ shape 探测(缺 `main_container` = 旧扁平缓存 → **DeployFailed**,防渲染空镜像 Pod)→ `find_container_conflict` fail-fast(撞容器名/撞端口,防 agent 经 127.0.0.1 连错进程)→ **单一 `_build_container(c, cont, role, idx, pvc_seen, nfs_seen)` 渲染主/sidecar**,role 分支仅两处:ports 有名(sse/http)vs 无名声明、探针恒 httpGet 打 sse 端口无 timeout vs 可选 tcp/http 带 timeout。securityContext/command/args/挂载四族/env/envFrom/resources 主/sidecar 一致渲染(决策 B:securityContext 全量,apparmor 走 Pod annotation;全默认 → None 走镜像默认;command/args 缺省走镜像 ENTRYPOINT/CMD)。label `{jiuwenclaw-component: agentserver, app: pod_id}`;资源 requests/limits;**容器 env 注入**(真 AgentServer 需 `AGENT_HTTP_ENABLED/HOST/PORT` 开 HTTP 入口);**envFrom 引用 → `envFrom`(`_render_env_from`:secretRef/configMapRef/prefix/optional 逐字段透传;缺省 None 不设;脏缓存坏项跳过;值不落模板/快照,只传引用名——密钥不再明文)**;restart_policy=Always。`probe_health`(场景 N)与 readiness 同源取主容器探针 path(sweeper 从 scope:config 的 pod_spec_json 读,legacy 形回退旧扁平键)。 |
| 107 | - **多容器(pod_spec.sidecars,canonical 见 containers.py)**:`normalize_pod_spec` 兜底(pod_spec 可能来自 Redis 缓存脏数据,坏项静默丢弃);sidecar 经同一 `_build_container` 渲染(端口纯声明性**无名**、独立 resources、security_context(privileged/caps/seccomp/run_as)、apparmor unconfined 落 **Pod annotation**、tcp/http readiness 探针);`containers=[主容器, *sidecars]`,无 sidecars 时 annotations=None、单容器——**与历史逐字节一致**。sidecar readiness 参与 Pod Ready → `_wait_ready` 天然等 sidecar 就绪(慢启动 sidecar 需调大模板 ready_timeout)。 | 109 | - **多容器(pod_spec.sidecars,canonical 见 containers.py)**:`normalize_pod_spec` 兜底(pod_spec 可能来自 Redis 缓存脏数据,坏项静默丢弃);sidecar 经同一 `_build_container` 渲染(端口纯声明性**无名**、独立 resources、security_context(privileged/caps/seccomp/run_as)、apparmor unconfined 落 **Pod annotation**、tcp/http readiness 探针);`containers=[主容器, *sidecars]`,无 sidecars 时 annotations=None、单容器——**与历史逐字节一致**。sidecar readiness 参与 Pod Ready → `_wait_ready` 天然等 sidecar 就绪(慢启动 sidecar 需调大模板 ready_timeout)。 |
| 108 | - **卷挂载渲染(`mounts.py` 规范形,主容器与 sidecar 共用 `_render_volume_mounts`)**:hostPath/ConfigMap/PVC/NFS 四种(挂载族在 canonical `*_mounts` 键,主/sidecar 一致);卷名 `_scoped_volume_name(prefix, 容器名净化, 容器idx, 挂载idx)`,前缀 `hp-`/`cm-`/`pvc-`/`nfs-`,≤63 防撞;**PVC 同 claim 跨容器去重**(`pvc_seen` 登记簿)与 **NFS 同共享(server+path)跨容器去重**(`nfs_seen` 同款)——同源只建**一个**共享卷(卷名取首现容器,主容器先渲染),后继容器的 volumeMount 复用该卷名(防 kubelet 挂第二个同源卷死锁/超时);卷级 `read_only` 取首现值(kubelet 语义:卷源 ro 压 mount 级 rw);ConfigMap 支持 `sub_path` 与 `items`(V1KeyToPath);RM 入口 `normalize_mounts` 兜底脏缓存;无挂载时零增量(与历史一致)。 | 110 | - **卷挂载渲染(`mounts.py` 规范形,主容器与 sidecar 共用 `_render_volume_mounts`)**:hostPath/ConfigMap/PVC/NFS 四种(挂载族在 canonical `*_mounts` 键,主/sidecar 一致);卷名 `_scoped_volume_name(prefix, 容器名净化, 容器idx, 挂载idx)`,前缀 `hp-`/`cm-`/`pvc-`/`nfs-`,≤63 防撞;**PVC 同 claim 跨容器去重**(`pvc_seen` 登记簿)与 **NFS 同共享(server+path)跨容器去重**(`nfs_seen` 同款)——同源只建**一个**共享卷(卷名取首现容器,主容器先渲染),后继容器的 volumeMount 复用该卷名(防 kubelet 挂第二个同源卷死锁/超时);卷级 `read_only` 取首现值(kubelet 语义:卷源 ro 压 mount 级 rw);ConfigMap 支持 `sub_path` 与 `items`(V1KeyToPath);RM 入口 `normalize_mounts` 兜底脏缓存;无挂载时零增量(与历史一致)。 |
| @@ -127,8 +129,8 @@ | |||
| 127 | 129 | ||
| 128 | | 任务 | 周期/锁 | 逻辑 | | 130 | | 任务 | 周期/锁 | 逻辑 | |
| 129 | |---|---|---| | 131 | |---|---|---| |
| 130 | -| `autoscale_once`(场景 H) | 1s / lock:rm:autoscale | 先 `reap_expired_deploying`(崩溃遗留占位自愈)再遍历 `known_scope_ids()`(SCAN scope:config):**当前版本+代次** idle < min_idle_pods(`_current_version_idle`:deploy_ver ∧ generation 均与 scope:config 一致——A 类变更后旧版、config_refresh 后老代次 idle Pod 永不可能被复用,不能拿来满足 min_idle,否则暖池被旧版钉死)且 pods+deploying < max_pods → `LUA_PLACEHOLDER` 占位 → 抢 deploy 锁 → `_deploy_and_register(idle_flag=True)` 热备入池;pod_spec 取 scope:config 缓存(A 类变更后为新值;刷新后同值仅换代)。**config_sync 会主动写每个存活 scope 的 config(带 pod_spec)→ 从未被请求过的 scope 也会被预热(eager,下发即预备热备)** | | 132 | +| `autoscale_once`(场景 H) | 1s / lock:rm:autoscale | 先 `reap_expired_deploying`(崩溃遗留占位自愈)再遍历 `known_scope_ids()`(SCAN scope:config):**当前版本+代次** idle < min_idle_pods(`_current_version_idle`:deploy_ver ∧ generation 均与 scope:config 一致——A 类变更后旧版、config_refresh 后老代次 idle Pod 永不可能被复用,不能拿来满足 min_idle,否则暖池被旧版钉死)且 pods+deploying < 容量上限(max_pods;**排空纪元活跃 +SURGE_MARGIN=1**,与 Lua 闸门同步,2026-09-17)→ `LUA_PLACEHOLDER` 占位 → 抢 deploy 锁 → `_deploy_and_register(idle_flag=True)` 热备入池;pod_spec 取 scope:config 缓存(A 类变更后为新值;刷新后同值仅换代)。**config_sync 会主动写每个存活 scope 的 config(带 pod_spec)→ 从未被请求过的 scope 也会被预热(eager,下发即预备热备)** | |
| 131 | -| `reclaim_once`(场景 K) | 1s / lock:rm:reclaim | excess = 旧版本/老代次 idle Pod(**恒为 excess**——acquire want_ver+generation 过滤后永不可复用,若受底数保护则暖池钉死旧版+蹲占 max_pods)+ 当前版本代次 idle 超出 min_idle 的部分(按转 idle 先后);**两级回收(2026-09-15 ③)**:stale(旧版本/老代次)免老化**即刻回收**——零复用价值,蹲满剩余 pod_ttl 只白占 max_pods 槽位并拖长 config_sync/refresh 日落闸门等待(含扩散后仍带会话的 Pod,会话硬切重放置,决策接受);当前版本超额须 `aged ≥ pod_ttl`(抗复用抖动,真镜像冷启动 p50≈12s)→ `_purge_and_notify`(K8s delete → LUA_PURGE → notify_pod_dead);底数只保护当前版本代次最早入 idle 的 min_idle 个(保底热备)。**scope 被删除时 config_sync 推 min_idle=0 → 本任务把其空闲 Pod 按 pod_ttl 自然排空(存量会话到期止,不强制驱逐)** | | 133 | +| `reclaim_once`(场景 K) | 1s / lock:rm:reclaim | excess = 旧版本/老代次 idle Pod(**恒为 excess**——acquire want_ver+generation 过滤后永不可复用,若受底数保护则暖池钉死旧版+蹲占 max_pods)+ 当前版本代次 idle 超出 min_idle 的部分(按转 idle 先后);**两级回收(2026-09-17 优雅排空,取代 2026-09-15 免老化即刻)**:stale(旧版本/老代次)在**排空窗口截止后回收**——`scope:drain_until` 存在且 `now < 截止` 则跳过(窗口 = 首因下发 + session_ttl;期间已绑定会话继续服务:route 亲和/touch 只查 pod:info 不查候选集);**键缺失(过期/残局)兜底立即回收**,闸门收敛保证不破坏;当前版本超额仍须 `aged ≥ pod_ttl`(抗复用抖动,真镜像冷启动 p50≈12s)→ `_purge_and_notify`(K8s delete → LUA_PURGE → notify_pod_dead);底数只保护当前版本代次最早入 idle 的 min_idle 个(保底热备)。**排空收尾**:本拍收过 stale 且注册表已无落后 Pod(`has_lagged_pods`)→ DEL drain_until 精确终止 surge。**scope 被删除/禁用/失权时 config_sync 先 bump 再推 min_idle=0(2026-09-scope-affinity-hold)→ 其 Pod 走同款日落排空:存量会话窗口内继续原 Pod,过窗回收后 rebind 落新规则;窗口内会话停止活跃即回收(与 refresh 日落同语义)** | |
| 132 | | `watch_once`(场景 J/N) | 10s / lock:rm:watch(TTL 15) | 遍历 pods:all:get_pod 为 None 或 phase∈DEAD → 清理;Running 但 `probe_health` **连续 2 次失败**(health_fails 阈值,防瞬时抖动误杀)→ 半死清理;成功清零计数。**探测参数优先取 Pod 自己 info 烘焙的 sse_port/health_path**(A 类变更后存量老 Pod 用旧契约探测;旧 Pod 无字段时回退 scope:config 的 pod_spec_json) | | 134 | | `watch_once`(场景 J/N) | 10s / lock:rm:watch(TTL 15) | 遍历 pods:all:get_pod 为 None 或 phase∈DEAD → 清理;Running 但 `probe_health` **连续 2 次失败**(health_fails 阈值,防瞬时抖动误杀)→ 半死清理;成功清零计数。**探测参数优先取 Pod 自己 info 烘焙的 sse_port/health_path**(A 类变更后存量老 Pod 用旧契约探测;旧 Pod 无字段时回退 scope:config 的 pod_spec_json) | |
| 133 | | `reconcile_once`(场景 L) | 30s / lock:rm:reconcile(TTL 60) | ① Redis 有 K8s 无 → PURGE+notify;② RM 持有但 SM 候选集已无的 stale Pod(经 `sm_facade.reconcile_pods`,Facade 单向)→ `LUA_RELEASE` 转 idle 按 pod_ttl 回收 | | 135 | | `reconcile_once`(场景 L) | 30s / lock:rm:reconcile(TTL 60) | ① Redis 有 K8s 无 → PURGE+notify;② RM 持有但 SM 候选集已无的 stale Pod(经 `sm_facade.reconcile_pods`,Facade 单向)→ `LUA_RELEASE` 转 idle 按 pod_ttl 回收 | |
| 134 | 136 | ||
| @@ -133,7 +133,7 @@ SM 侧 ctx,级联管理全部生命周期(框架 App 的 lifespan 只认一个 c | |||
| 133 | |---|---|---|---| | 133 | |---|---|---|---| |
| 134 | | mode | `AGENT_RUNTIME_MODE` | server | server\|local | | 134 | | mode | `AGENT_RUNTIME_MODE` | server | server\|local | |
| 135 | | kubeconfig | `AGENT_RUNTIME_KUBECONFIG` | None(集群内 SA) | | | 135 | | kubeconfig | `AGENT_RUNTIME_KUBECONFIG` | None(集群内 SA) | | |
| 136 | -| default_namespace | `POD_NAMESPACE` | default | downward API 注入的自身 ns(deploy 模板已注入);未注入/非 K8s 落字面 default。模板 namespace 为空串时 AgentServer 的落点(k8s 层 falsy 兜底) | | 136 | +| default_namespace | `POD_NAMESPACE` | default | 解析链(`config.own_namespace()`):`POD_NAMESPACE` env(显式覆盖/downward API)> in-cluster SA namespace 文件(Pod 必挂,**零部署配置**)> 字面 default。模板 namespace 为空串时 AgentServer 的落点(k8s 层 falsy 兜底);`AGENT_RUNTIME_DEFAULT_NAMESPACE` 已删,设了也忽略 | |
| 137 | | sweep_interval | `AGENT_RUNTIME_SWEEP_INTERVAL` | 1 | SM:到期+空 Pod pass | | 137 | | sweep_interval | `AGENT_RUNTIME_SWEEP_INTERVAL` | 1 | SM:到期+空 Pod pass | |
| 138 | | autoscale_interval | `AGENT_RUNTIME_AUTOSCALE_INTERVAL` | 1 | RM:min_idle 补位(**全局默认,无 per-scope 覆盖**) | | 138 | | autoscale_interval | `AGENT_RUNTIME_AUTOSCALE_INTERVAL` | 1 | RM:min_idle 补位(**全局默认,无 per-scope 覆盖**) | |
| 139 | | reclaim_interval | `AGENT_RUNTIME_RECLAIM_INTERVAL` | 1 | RM:idle 回收 | | 139 | | reclaim_interval | `AGENT_RUNTIME_RECLAIM_INTERVAL` | 1 | RM:idle 回收 | |
| @@ -49,6 +49,10 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。 | |||
| 49 | 四参非空校验(缺 → InvalidParams 400) | 49 | 四参非空校验(缺 → InvalidParams 400) |
| 50 | → resolve(user_id, group_id, bot_id)(config_store:读路由快照 first-fit 匹配) | 50 | → resolve(user_id, group_id, bot_id)(config_store:读路由快照 first-fit 匹配) |
| 51 | 返回 (scope_id, template);无匹配 → ConfigNotFound(503) | 51 | 返回 (scope_id, template);无匹配 → ConfigNotFound(503) |
| 52 | +→ **亲和持有判定(2026-09-scope-affinity-hold,#152)**:读 `session:{sid}` 哈希, | ||
| 53 | + 已有绑定(scope_id+pod_id 在)→ 本次仲裁目标改为**绑定 scope**(session_ttl 取 | ||
| 54 | + 该 scope 当前模板,scope/模板已从快照消失则回退哈希留存的 session_ttl)—— | ||
| 55 | + 规则变化不即时迁移存量会话;first-fit 只决定新会话(与 rebind 后)的去处 | ||
| 52 | → 循环 { **总预算校验:total_deadline = now + template.ready_timeout | 56 | → 循环 { **总预算校验:total_deadline = now + template.ready_timeout |
| 53 | + ROUTE_BUDGET_MARGIN_SEC(10s),每圈 monotonic 复核。 | 57 | + ROUTE_BUDGET_MARGIN_SEC(10s),每圈 monotonic 复核。 |
| 54 | 总预算封的是 need_acquire 的无上界循环(一轮可触发完整 deploy,ready_timeout | 58 | 总预算封的是 need_acquire 的无上界循环(一轮可触发完整 deploy,ready_timeout |
| @@ -59,6 +63,8 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。 | |||
| 59 | LUA_ROUTE_PLACE 原子仲裁 → (action, pod_id): | 63 | LUA_ROUTE_PLACE 原子仲裁 → (action, pod_id): |
| 60 | refresh/placed → 读 pod:info sse_url 返回(缺失=极端竞态被清,continue 重跑; | 64 | refresh/placed → 读 pod:info sse_url 返回(缺失=极端竞态被清,continue 重跑; |
| 61 | refresh 分支有 info 存活守卫,下一轮惰性回收死绑定重新放置,不构成自旋) | 65 | refresh 分支有 info 存活守卫,下一轮惰性回收死绑定重新放置,不构成自旋) |
| 66 | + rebind → 绑定已惰性回收(过期/Pod 注册消失)→ 目标切回 first-fit 结果 | ||
| 67 | + 重试(曾见绑定的调用内不落放置——防向已禁用/删除 scope 重部署) | ||
| 62 | scope_full → 立即 raise ScopeFull(503, retry_after=1)——场景 F 快失败 | 68 | scope_full → 立即 raise ScopeFull(503, retry_after=1)——场景 F 快失败 |
| 63 | (2026-09):不排队不订阅,Lua 闸门即唯一仲裁,被拒者毫秒级返回 | 69 | (2026-09):不排队不订阅,Lua 闸门即唯一仲裁,被拒者毫秒级返回 |
| 64 | need_acquire → rm_facade.acquire(扩+1)→ state.register_pod → 重跑(新 Pod 必被 first-fit 选中) } | 70 | need_acquire → rm_facade.acquire(扩+1)→ state.register_pod → 重跑(新 Pod 必被 first-fit 选中) } |
| @@ -95,7 +101,7 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。 | |||
| 95 | 101 | ||
| 96 | | 脚本 | 一句话职责 | | 102 | | 脚本 | 一句话职责 | |
| 97 | |---|---| | 103 | |---|---| |
| 98 | -| `LUA_ROUTE_PLACE` | route 原子核心:**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 的半成品哈希自清两处后落穿全新放置——nil 比较/nil 拼接是 Lua runtime error,该会话 route 永久 500)**→亲和续期(**前提 pod:info 存在**——注册已被清的绑定判死,惰性回收后走重新放置;否则 notify_pod_dead 窗口内新落的会话会无限自旋且每圈续期 expiry)→惰性回收旧绑定→scope 闸门(SCARD)→first-fit(接入序)→达 max_pods 则 scope_full / 否则 need_acquire→原子提交四处同写(复用时清 idle_notified) | | 104 | +| `LUA_ROUTE_PLACE` | route 原子核心:**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 的半成品哈希自清两处后落穿全新放置——nil 比较/nil 拼接是 Lua runtime error,该会话 route 永久 500)**→亲和续期(**前提 pod:info 存在**——注册已被清的绑定判死,惰性回收后走重新放置;否则 notify_pod_dead 窗口内新落的会话会无限自旋且每圈续期 expiry)→**其余(过期/Pod 注册消失/scope 不匹配竞态)惰性回收旧绑定后返回 `rebind`(2026-09-scope-affinity-hold:曾见绑定的调用内不落放置,handler 换 first-fit 结果重试;传入 scope 即要维持的绑定 scope 或新会话的 first-fit 结果——曾见绑定的调用直接按传入 scope 放置会把会话部署进已禁用/删除的 scope)**→scope 闸门(SCARD)→first-fit(接入序)→达 max_pods 则 scope_full / 否则 need_acquire→原子提交四处同写(复用时清 idle_notified) | |
| 99 | | `LUA_EVICT` | session 移除**唯一原语**(四处同删;返回 scope/pod/remaining;幂等 noop;**残骸自卫**:哈希缺 scope/pod(外部直改键半成品)→ 自清两处返回 rubble,调用侧 WARNING——单坏键不得使到期 pass 崩溃循环) | | 105 | | `LUA_EVICT` | session 移除**唯一原语**(四处同删;返回 scope/pod/remaining;幂等 noop;**残骸自卫**:哈希缺 scope/pod(外部直改键半成品)→ 自清两处返回 rubble,调用侧 WARNING——单坏键不得使到期 pass 崩溃循环) | |
| 100 | | `LUA_TOUCH` | 保活续期;**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 自清返回 False,不得 Lua runtime error)**;已过期当场惰性 evict;ttl 就地读 session HASH(不依赖 scope:config) | | 106 | | `LUA_TOUCH` | 保活续期;**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 自清返回 False,不得 Lua runtime error)**;已过期当场惰性 evict;ttl 就地读 session HASH(不依赖 scope:config) | |
| 101 | | `LUA_SWEEP_IDLE_NOTIFY` | 空 Pod 判定(SCARD==0)+ 60s NX 去重 + ZREM 退出候选(堵 reclaim 窗口内 route 直选的竞态 A) | | 107 | | `LUA_SWEEP_IDLE_NOTIFY` | 空 Pod 判定(SCARD==0)+ 60s NX 去重 + ZREM 退出候选(堵 reclaim 窗口内 route 直选的竞态 A) | |
| @@ -106,9 +112,9 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。 | |||
| 106 | 112 | ||
| 107 | ## config_store.py —— 配置层(scope 重构版) | 113 | ## config_store.py —— 配置层(scope 重构版) |
| 108 | 114 | ||
| 109 | -**DB 表**(策略四列 2026-09 起与 wire 术语同名,identity 映射在 `_COLUMN_OF`;曾用 EE 兼容名,见文末 RENAME 义务):`service_config_template`(模板级:`namespace`/`node_name`/`fs_group`/`pod_name`/`sse_path`/`kubeconfig`/`ready_*` + 策略四列 `min_idle_pods`/`pod_concurrency`/`pod_ttl`/`scope_concurrency` + 三段式引用 `main_container_id`(string 100)/`sidecar_container_ids`(JSON)/`volumes`(JSON);**2026-09 起已 DROP 拆表前内联容器列**,见 `docs/feature/2026-09-drop-legacy-inline-template-columns.md`)、**`service_config_container`**(容器规格表,15 列:`container_id` unique ≤100、`name`/`image`/`image_pull_policy` 标量 + `ports`/`env`/`env_from`/`resources`/`volume_mounts`/`security_context`/`readiness_probe` 七个内部规范形 JSON 段落列;框架 init_table 自动建,无需手工 DDL)、`routing_scope`(`scope_id` unique / `match_index`(避 SQL 保留字 index) / `template_id` / `routing_rules` JSON / `enabled` bool 默认 true / `expires_at` datetime 可空)。表结构常量 `*_TABLE_DEF` 由 main 传给框架建表。旧 `routing_rule` 表已废弃(不再读写,老库残留无害)。**模板表三段式三列为后期新增:存量库须先手工 ALTER 再发版**(`ALTER TABLE service_config_template ADD COLUMN main_container_id VARCHAR(100) NULL; ADD COLUMN sidecar_container_ids JSON NULL; ADD COLUMN volumes JSON NULL;`,框架建表只 create_all 不补列)。**routing_scope 的 `enabled`/`expires_at` 同为后期新增:存量库须** `ALTER TABLE routing_scope ADD COLUMN expires_at DATETIME NULL; ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;`(方言类型按 MySQL/PG 调整)。**模板表策略四列 2026-09 改名(wire 术语统一):存量库须先手工 RENAME 再发版**(`ALTER TABLE service_config_template RENAME COLUMN min_idle_services TO min_idle_pods; RENAME COLUMN service_concurrency TO pod_concurrency; RENAME COLUMN service_ttl TO pod_ttl; RENAME COLUMN session_concurrency TO scope_concurrency;`,MySQL 8+/PG 均支持;见 `docs/feature/2026-09-template-table-runtime-terms.md`)。**legacy 内联容器列 DROP:存量库须先按 feature 篇 DROP COLUMN 再发版**(框架不 DROP)。 | 115 | +**DB 表**(策略四列 2026-09 起与 wire 术语同名,identity 映射在 `_COLUMN_OF`;曾用 EE 兼容名,见文末 RENAME 义务):`service_config_template`(模板级:`namespace`/`node_name`/`fs_group`/`pod_name`/`sse_path`/`kubeconfig`/`ready_*` + 策略四列 `min_idle_pods`/`pod_concurrency`/`pod_ttl`/`scope_concurrency` + 三段式引用 `main_container_id`(string 100)/`sidecar_container_ids`(JSON)/`volumes`(JSON);**2026-09 起已 DROP 拆表前内联容器列**,见 `docs/feature/2026-09-drop-legacy-inline-template-columns.md`)、**`service_config_container`**(容器规格表,15 列:`container_id` unique ≤100、`name`/`image`/`image_pull_policy` 标量 + `ports`/`env`/`env_from`/`resources`/`volume_mounts`/`security_context`/`readiness_probe` 七个内部规范形 JSON 段落列;框架 init_table 自动建,无需手工 DDL)、`routing_scope`(`scope_id` unique / `match_index`(避 SQL 保留字 index) / `template_id` / `routing_rules` JSON / `expires_at` datetime 可空;`enabled` 列 2026-09 删除,生命周期=存在性+expires_at,存量库先发版后 DROP,见 `docs/feature/2026-09-drop-enabled-fields.md`)。表结构常量 `*_TABLE_DEF` 由 main 传给框架建表。旧 `routing_rule` 表已废弃(不再读写,老库残留无害)。**模板表三段式三列为后期新增:存量库须先手工 ALTER 再发版**(`ALTER TABLE service_config_template ADD COLUMN main_container_id VARCHAR(100) NULL; ADD COLUMN sidecar_container_ids JSON NULL; ADD COLUMN volumes JSON NULL;`,框架建表只 create_all 不补列)。**routing_scope 的 `expires_at` 为后期新增:存量库须** `ALTER TABLE routing_scope ADD COLUMN expires_at DATETIME NULL;`(方言类型按 MySQL/PG 调整);`enabled` 列 2026-09 起删除(先发版后 DROP,见 drop-enabled-fields)。**模板表策略四列 2026-09 改名(wire 术语统一):存量库须先手工 RENAME 再发版**(`ALTER TABLE service_config_template RENAME COLUMN min_idle_services TO min_idle_pods; RENAME COLUMN service_concurrency TO pod_concurrency; RENAME COLUMN service_ttl TO pod_ttl; RENAME COLUMN session_concurrency TO scope_concurrency;`,MySQL 8+/PG 均支持;见 `docs/feature/2026-09-template-table-runtime-terms.md`)。**legacy 内联容器列 DROP:存量库须先按 feature 篇 DROP COLUMN 再发版**(框架不 DROP)。 |
| 110 | 116 | ||
| 111 | -**`namespace` 空串语义(2026-09,跟随 runtime ns)**:显式下发 `"namespace": ""` = 继承——AgentServer 落 runtime 自身 ns(k8s 层 falsy 兜底到 `default_namespace`,= `POD_NAMESPACE`(downward API),见 spec/service-core.md / resource-manager.md)。列 NOT NULL 允许空串,**省略仍落默认值 `"default"`**——表达继承须显式空串。deploy_ver 以空串字面量进指纹(runtime 搬家不触发日落,老 Pod 按 idle 自然回收);库与 wire 契约零改动。 | 117 | +**`namespace` 空串语义(2026-09,跟随 runtime ns)**:显式下发 `"namespace": ""` = 继承——AgentServer 落 runtime 自身 ns(k8s 层 falsy 兜底到 `default_namespace`,= `config.own_namespace()`:`POD_NAMESPACE` env > SA 文件,见 spec/service-core.md / resource-manager.md)。列 NOT NULL 允许空串,**省略仍落默认值 `"default"`**——表达继承须显式空串。deploy_ver 以空串字面量进指纹(runtime 搬家不触发日落,老 Pod 按 idle 自然回收);库与 wire 契约零改动。 |
| 112 | 118 | ||
| 113 | **行形态单轨(2026-09 统一规范形起)**(`template_from_row(row, containers)`):模板级行列 + 容器行引用 + volumes join → 统一 canonical 水合(**任一引用容器行缺失/容器段落校验失败 → WARNING + 整模板跳过**,绝不静默丢单个 sidecar——那会隐形改 deploy_ver;引用它的 scope 视为不命中落兜底);**无 `main_container_id` 的行不再水合**(→ WARNING + None,fail-closed;wire 已三段式独占)。写行(`row_from_template_split`)只写模板级列 + 引用列 + volumes(不再写已 DROP 的内联列死值)。wire 层仍用 `_LEGACY_INLINE_CONTAINER_KEYS` 拒 mixed(template 带内联容器键 → 400)。 | 119 | **行形态单轨(2026-09 统一规范形起)**(`template_from_row(row, containers)`):模板级行列 + 容器行引用 + volumes join → 统一 canonical 水合(**任一引用容器行缺失/容器段落校验失败 → WARNING + 整模板跳过**,绝不静默丢单个 sidecar——那会隐形改 deploy_ver;引用它的 scope 视为不命中落兜底);**无 `main_container_id` 的行不再水合**(→ WARNING + None,fail-closed;wire 已三段式独占)。写行(`row_from_template_split`)只写模板级列 + 引用列 + volumes(不再写已 DROP 的内联列死值)。wire 层仍用 `_LEGACY_INLINE_CONTAINER_KEYS` 拒 mixed(template 带内联容器键 → 400)。 |
| 114 | 120 | ||
| @@ -126,7 +132,7 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。 | |||
| 126 | - **卷 join(K8s spec.volumes 同构)**:模板级 `volumes`(每卷恰一源:hostPath/configMap/persistentVolumeClaim/nfs;卷名 DNS-1123 唯一)+ 容器 `volumeMounts` 按名引用;`fuse_mounts` 重建内部 fused 挂载(mounts.py 规范形,指纹承重)。源类型规则:悬挂引用/未挂载卷 → 400;`subPath` 仅 configMap;`readOnly` 缺省按内部规范(cm→**true**、hp/pvc/nfs→false)。**NFS 与 PVC 同构**:卷源(server/path)是模板级(pod 级)卷,主容器与 sidecar 一律按名引用挂载,条数/挂载点不限(2026-09 起废除「NFS 仅主容器单挂载」的三元组特化)。 | 132 | - **卷 join(K8s spec.volumes 同构)**:模板级 `volumes`(每卷恰一源:hostPath/configMap/persistentVolumeClaim/nfs;卷名 DNS-1123 唯一)+ 容器 `volumeMounts` 按名引用;`fuse_mounts` 重建内部 fused 挂载(mounts.py 规范形,指纹承重)。源类型规则:悬挂引用/未挂载卷 → 400;`subPath` 仅 configMap;`readOnly` 缺省按内部规范(cm→**true**、hp/pvc/nfs→false)。**NFS 与 PVC 同构**:卷源(server/path)是模板级(pod 级)卷,主容器与 sidecar 一律按名引用挂载,条数/挂载点不限(2026-09 起废除「NFS 仅主容器单挂载」的三元组特化)。 |
| 127 | - 水合出口:`build_canonical`(内部规范形 + volumes join → containers.canonical 收口)×2 → `validate_pod_containers`——读路径(`_template_from_split_row`)与写路径(`template_from_split_payload`)共用 `_hydrate_containers` 单出口。**同值必同 deploy_ver**(`test_split_contract_deploy_ver_identical_to_inline`:三段式水合 vs 手构 canonical Template 逐字节相等,承重)。 | 133 | - 水合出口:`build_canonical`(内部规范形 + volumes join → containers.canonical 收口)×2 → `validate_pod_containers`——读路径(`_template_from_split_row`)与写路径(`template_from_split_payload`)共用 `_hydrate_containers` 单出口。**同值必同 deploy_ver**(`test_split_contract_deploy_ver_identical_to_inline`:三段式水合 vs 手构 canonical Template 逐字节相等,承重)。 |
| 128 | 134 | ||
| 129 | -**routing.py(纯函数)**:`routing_rules` 是**布尔表达式字符串**——条件 `field in|not in ('v1', 'v2')` 经 `and`/`or` 与括号任意组合;优先级 条件 > and > or;关键字大小写不敏感,字段名固定小写枚举(user_id/group_id/bot_id);值单引号串(`''` 加倍或 `\'`/`\\` 转义);空值列表 `()` → in 恒假、not_in 恒真;不支持一元 `not`;上限长度 8000、括号嵌套 32。**空 routing_rules(null/空串/纯空白)= 通配兜底**;遍历按 `(index ASC, scope_id ASC)` **first-fit**;引用模板缺失/禁用的 scope、以及 scope 自身 `enabled=False` / `expires_at` 已过期(墙钟判定,`null`=永不过期)的,跳过落下一个。通配告警只计生效中的空表达式 scope。解析器 = 词法(`_TOKEN_RE`)+ 递归下降(`_Parser`:or_expr → and_expr → primary),产物为表达式树(`MatchExpression` 叶 / `AndNode` / `OrNode`),存于 `RoutingScopeDef.rule`(与原始串 `expr` 成对,后者是 wire/DB/快照载体)。`SCOPE_ID_RE = ^[0-9A-Za-z._-]{1,128}$`(禁 `:`/`*`/空白——Redis 键与 `pods:registered` 切分依赖)。 | 135 | +**routing.py(纯函数)**:`routing_rules` 是**布尔表达式字符串**——条件 `field in|not in ('v1', 'v2')` 经 `and`/`or` 与括号任意组合;优先级 条件 > and > or;关键字大小写不敏感,字段名固定小写枚举(user_id/group_id/bot_id);值单引号串(`''` 加倍或 `\'`/`\\` 转义);空值列表 `()` → in 恒假、not_in 恒真;不支持一元 `not`;上限长度 8000、括号嵌套 32。**空 routing_rules(null/空串/纯空白)= 通配兜底**;遍历按 `(index ASC, scope_id ASC)` **first-fit**;引用模板缺失(悬挂引用)的 scope、以及 `expires_at` 已过期(墙钟判定,`null`=永不过期)的,跳过落下一个(两级 enabled 已删 2026-09,生命周期=存在性+expires_at;残留 `enabled:false` 载荷防御性视为缺席)。通配告警只计生效中的空表达式 scope。解析器 = 词法(`_TOKEN_RE`)+ 递归下降(`_Parser`:or_expr → and_expr → primary),产物为表达式树(`MatchExpression` 叶 / `AndNode` / `OrNode`),存于 `RoutingScopeDef.rule`(与原始串 `expr` 成对,后者是 wire/DB/快照载体)。`SCOPE_ID_RE = ^[0-9A-Za-z._-]{1,128}$`(禁 `:`/`*`/空白——Redis 键与 `pods:registered` 切分依赖)。 |
| 130 | 136 | ||
| 131 | **resolve(user_id, group_id, bot_id) → (scope_id, Template)**:读单键快照 `routing:snapshot`(1 GET;进程内按原文 memo 免重复解析)→ first-fit 匹配;快照缺失/损坏 → 从 DB 重建;无匹配 → `ConfigNotFound(503)`。 | 137 | **resolve(user_id, group_id, bot_id) → (scope_id, Template)**:读单键快照 `routing:snapshot`(1 GET;进程内按原文 memo 免重复解析)→ first-fit 匹配;快照缺失/损坏 → 从 DB 重建;无匹配 → `ConfigNotFound(503)`。 |
| 132 | 138 | ||
| @@ -148,7 +154,7 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。 | |||
| 148 | 坏值 Pod 永久 Pending 挂满 ready_timeout 才暴露;空串归一 None 同未设; | 154 | 坏值 Pod 永久 Pending 挂满 ready_timeout 才暴露;空串归一 None 同未设; |
| 149 | snake 双形态 node_name → 400,用 K8s 拼写 nodeName) | 155 | snake 双形态 node_name → 400,用 K8s 拼写 nodeName) |
| 150 | scope 段同前(scope_id 字符集/index 拒 bool/引用不在本批模板集/routing_rules | 156 | scope 段同前(scope_id 字符集/index 拒 bool/引用不在本批模板集/routing_rules |
| 151 | - 表达式串语法/enabled 须 bool/expires_at ISO-8601 或 null/重复);缺通配 scope | 157 | + 表达式串语法/expires_at ISO-8601 或 null/重复;残留 `enabled:false` 视为缺席);缺通配 scope |
| 152 | → 仅 WARNING 放行(响应 wildcard_present:false;通配只计生效中的空表达式) | 158 | → 仅 WARNING 放行(响应 wildcard_present:false;通配只计生效中的空表达式) |
| 153 | lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门狗续期**: | 159 | lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门狗续期**: |
| 154 | 周期 TTL//3 经 SessionState.refresh_lock(Lua compare-and-EXPIRE,同 unlock 守卫 | 160 | 周期 TTL//3 经 SessionState.refresh_lock(Lua compare-and-EXPIRE,同 unlock 守卫 |
| @@ -158,7 +164,9 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门 | |||
| 158 | 经 compare-and-del 误删他人锁);续期发现锁丢失 → ERROR 停止续期、本批跑完 | 164 | 经 compare-and-del 误删他人锁);续期发现锁丢失 → ERROR 停止续期、本批跑完 |
| 159 | (DB 单事务收敛);unlock 失败只记日志,不把成功变 500/不吞原异常) | 165 | (DB 单事务收敛);unlock 失败只记日志,不把成功变 500/不吞原异常) |
| 160 | → 读 DB 旧态(containers + templates(双形态水合) + scopes) | 166 | → 读 DB 旧态(containers + templates(双形态水合) + scopes) |
| 161 | -→ diff:模板 changed_ids(_diff_class 沿用)/ 引用切换 ref_switched → affected | 167 | +→ diff:模板 changed_ids(_diff_class 沿用)/ 引用切换 ref_switched → affected / |
| 168 | + 路由性排除 routing_excluded(expr 变化 / 生效→失效即 expires_at 缩到已过, | ||
| 169 | + 2026-09-scope-affinity-hold;enabled 已删,关=删除走扩散③) | ||
| 162 | → 日落中间态检查(★先于写库,拒绝时零副作用;★按版本判定,**基准=当前生效 | 170 | → 日落中间态检查(★先于写库,拒绝时零副作用;★按版本判定,**基准=当前生效 |
| 163 | 版本**(2026-09-15 修正,原比新载荷版本——版本变更时当前代空闲 Pod 必然 | 171 | 版本**(2026-09-15 修正,原比新载荷版本——版本变更时当前代空闲 Pod 必然 |
| 164 | ≠ 新版被误判遗留,而它受 min_idle 底数保护永不回收 → 永久 409,cyz 实测): | 172 | ≠ 新版被误判遗留,而它受 min_idle 底数保护永不回收 → 永久 409,cyz 实测): |
| @@ -175,16 +183,25 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门 | |||
| 175 | 生效参数 INFO `snapshot template: id= sc= pc= min_idle= session_ttl= pod_ttl= | 183 | 生效参数 INFO `snapshot template: id= sc= pc= min_idle= session_ttl= pod_ttl= |
| 176 | max_pods=`(2026-09-08 观测增强)——「配置页 vs 运行时实际生效值」对账只看 | 184 | max_pods=`(2026-09-08 观测增强)——「配置页 vs 运行时实际生效值」对账只看 |
| 177 | 这行,不必从 max_followers 等间接证据反推) | 185 | 这行,不必从 max_followers 等间接证据反推) |
| 186 | +→ **路由性排除日落(2026-09-scope-affinity-hold,#152)**:diff 出的 | ||
| 187 | + routing_excluded(expr 变化 / 生效→失效即 expires_at 缩到已过;**不含 index 重排与引用切换**—— | ||
| 188 | + 重排不排除命中,零打扰;引用切换走 ver 日落)逐 scope `_bump_or_warn`(gen | ||
| 189 | + bump)+ `_soft_remove_all_pods`(候选软摘,防 drain 期新会话 first-fit 落上 | ||
| 190 | + 老代 Pod);**时序红线:bump 先于下方 eager 预热 push**(RM update_pool_config | ||
| 191 | + 凭 gen lag 戳排空纪元,反序则永远不戳)。失败 warn 不 raise——DB 已提交, | ||
| 192 | + 丢 bump 的 scope 退化为软界(会话自然结束后 pod_ttl 回收) | ||
| 178 | → eager 预热:每个**生效中** scope 推 push(sid, pool_config, deploy_subset)——必须带 | 193 | → eager 预热:每个**生效中** scope 推 push(sid, pool_config, deploy_subset)——必须带 |
| 179 | pod_spec(RM 才落 pod_spec_json/deploy_ver;autoscale 无请求预热 min_idle 的依赖); | 194 | pod_spec(RM 才落 pod_spec_json/deploy_ver;autoscale 无请求预热 min_idle 的依赖); |
| 180 | - 禁用/过期 scope 推 min_idle=0 停预热(与被删同款) | 195 | + 过期 scope 推 min_idle=0 停预热(与被删同款;enabled 已删,「关」=从载荷删除) |
| 181 | → 候选集版本收敛(声明式,非 one-shot):对**每个生效中** scope 把 deploy_ver ≠ 当前版本的 | 196 | → 候选集版本收敛(声明式,非 one-shot):对**每个生效中** scope 把 deploy_ver ≠ 当前版本的 |
| 182 | Pod ZREM 出候选——不由 diff 驱动,写 DB 后中途失败/同载荷重试(diff==none)也每拍 | 197 | Pod ZREM 出候选——不由 diff 驱动,写 DB 后中途失败/同载荷重试(diff==none)也每拍 |
| 183 | 重算,旧版 Pod 不会无限期接新流量 | 198 | 重算,旧版 Pod 不会无限期接新流量 |
| 184 | → 删除处理:目标集 = **RM 已知 scope(known_rm_scopes 回调)∪ DB 旧 scope** − 本批; | 199 | → 删除处理:目标集 = **RM 已知 scope(known_rm_scopes 回调)∪ DB 旧 scope** − 本批; |
| 185 | - 推 push(sid, {**旧模板池参数, min_idle_pods:0}, None)——RM config 键是幻影预热的 | 200 | + **本批新删的 scope(s∈DB 旧集)先 `_bump_or_warn` 再推** push(sid, |
| 186 | - 真源,只看 DB(删行后失忆)的话一次推送失败后 min_idle=0 永远补不上 | 201 | + {**旧模板池参数, min_idle_pods:0}, None)——同款日落排空(仅首删一次,rm_known |
| 187 | -→ 响应 {ok, templates_synced/deleted, **containers_synced/deleted**, scopes_synced/deleted, affected_scopes, wildcard_present} | 202 | + 里的陈年旧删每拍只重推不重复 bump);RM config 键是幻影预热的真源,只看 DB(删行 |
| 203 | + 后失忆)的话一次推送失败后 min_idle=0 永远补不上 | ||
| 204 | +→ 响应 {ok, templates_synced/deleted, **containers_synced/deleted**, scopes_synced/deleted, affected_scopes, **routing_sunset(路由性排除日落集,2026-09)**, wildcard_present} | ||
| 188 | ``` | 205 | ``` |
| 189 | 206 | ||
| 190 | 幂等重放收敛(changed 空 → affected=[]);启动期 `main.start()` 调 `ensure_snapshot()` 无条件重建(消冷启动窗口);`Template.deploy_ver()` / RM `_deploy_ver()` 同一算法(`util.fingerprint` + `DEPLOY_VER_FIELDS`)——A 类过滤两端一致的前提。 | 207 | 幂等重放收敛(changed 空 → affected=[]);启动期 `main.start()` 调 `ensure_snapshot()` 无条件重建(消冷启动窗口);`Template.deploy_ver()` / RM `_deploy_ver()` 同一算法(`util.fingerprint` + `DEPLOY_VER_FIELDS`)——A 类过滤两端一致的前提。 |
| @@ -203,7 +220,10 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门 | |||
| 203 | 代次感知保证收敛,闸门不会永久 409;防连续 refresh 多代日落堆积蹲占 | 220 | 代次感知保证收敛,闸门不会永久 409;防连续 refresh 多代日落堆积蹲占 |
| 204 | max_pods——2026-09-11 wangchang 环境 9 分钟 4 连刷实录) | 221 | max_pods——2026-09-11 wangchang 环境 9 分钟 4 连刷实录) |
| 205 | ① bump_generation(sid)(rm_facade → HINCRBY scope:config generation;严格,失败上抛) | 222 | ① bump_generation(sid)(rm_facade → HINCRBY scope:config generation;严格,失败上抛) |
| 206 | - ② _push_or_warn(sid, pool_config, deploy_subset)(值未变,确保 RM 缓存就绪;失败仅告警——良性) | 223 | + ② _push_or_warn(sid, pool_config, deploy_subset)(值未变,确保 RM 缓存就绪;失败仅告警——良性)。 |
| 224 | + **过期 scope(is_active 纯过期判定,时间感知)只日落不保温**:推 | ||
| 225 | + {**pool, min_idle_pods:0} + 无 pod_spec——否则一次强制刷新就把已停池按 | ||
| 226 | + 模板 min_idle 重新焐热(#154 同族半死态,2026-09-drop-enabled-fields 修) | ||
| 207 | ③ _soft_remove_all_pods(sid)(候选集全量 ZREM,不按版本过滤;严格) | 227 | ③ _soft_remove_all_pods(sid)(候选集全量 ZREM,不按版本过滤;严格) |
| 208 | → 响应 {ok, scopes_refreshed, pods_sunset, generations} | 228 | → 响应 {ok, scopes_refreshed, pods_sunset, generations} |
| 209 | ``` | 229 | ``` |
| @@ -211,7 +231,8 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门 | |||
| 211 | - **顺序红线 bump → ZREM**:"ZREM 而未 bump"会造出"被摘却仍是当前代次 warm"的搁浅态(min_idle 底数保护 → 永久蹲占 max_pods 且不重建);bump 在前的任何中途失败都收敛于"老 Pod 暂时继续接新流量",重试即收敛。 | 231 | - **顺序红线 bump → ZREM**:"ZREM 而未 bump"会造出"被摘却仍是当前代次 warm"的搁浅态(min_idle 底数保护 → 永久蹲占 max_pods 且不重建);bump 在前的任何中途失败都收敛于"老 Pod 暂时继续接新流量",重试即收敛。 |
| 212 | - 不写 DB、不动路由快照;日落收敛/重建全复用既有后台任务(reclaim 代次感知回收 + autoscale 按缓存 pod_spec 重建,见 resource-manager spec)。 | 232 | - 不写 DB、不动路由快照;日落收敛/重建全复用既有后台任务(reclaim 代次感知回收 + autoscale 按缓存 pod_spec 重建,见 resource-manager spec)。 |
| 213 | - `pods_sunset` 只计 SM 候选集摘除量(未入候选的 RM 暖 Pod 不计但同样被代次日落)。 | 233 | - `pods_sunset` 只计 SM 候选集摘除量(未入候选的 RM 暖 Pod 不计但同样被代次日落)。 |
| 214 | -- **非幂等但收敛**(每次调用 = 一轮全量日落重建,成功后勿自动重试);config_sync 的日落中间态守卫基准=**当前生效版本**、不扩展看 generation——老代 Pod 版本与当前配置相等 → 对守卫不可见 → B 类/A 类下发均不因此 409(2026-09-15 修正;原「A 类照旧 409 到排空完成」在老代 Pod 受 min_idle 底数保护时永不放行=配置面永久 409)。A 类落库后由扩散②软摘 + reclaim 版本感知即刻回收老代 Pod。 | 234 | +- **非幂等但收敛**(每次调用 = 一轮全量日落重建,成功后勿自动重试);config_sync 的日落中间态守卫基准=**当前生效版本**、不扩展看 generation——老代 Pod 版本与当前配置相等 → 对守卫不可见 → B 类/A 类下发均不因此 409(2026-09-15 修正;原「A 类照旧 409 到排空完成」在老代 Pod 受 min_idle 底数保护时永不放行=配置面永久 409)。A 类落库后由扩散②软摘 + reclaim **在排空窗口后**回收老代 Pod(2026-09-17 优雅排空,与 refresh 统一:窗口 = 首因下发 + session_ttl,期间已绑定会话继续服务;见 docs/feature/2026-09-sunset-drain-window.md)。 |
| 235 | +- **排空窗口对闸门的时长影响(2026-09-17)**:⓪ 的等待窗从"~一拍 reconcile+reclaim"拉长到 ≤ session_ttl + tick(有界:窗口截止后 reclaim 必收 → 放行)。两闸门判据维度不同——refresh 闸门看**代次**(对 A 类排空 Pod 失明)、sync 守卫看**版本**(对 refresh 排空 Pod 失明)——各自入口在自家排空窗口内 409,异类入口可放行(R3)。运维节奏须适配:攒完编辑再刷新。 | ||
| 215 | - 构造注入:`ConfigStore(..., bump_generation=rm_facade.bump_generation)`(`main._bind_modules`)。 | 236 | - 构造注入:`ConfigStore(..., bump_generation=rm_facade.bump_generation)`(`main._bind_modules`)。 |
| 216 | 237 | ||
| 217 | ## sweeper.py —— 老化扫描 | 238 | ## sweeper.py —— 老化扫描 |
| @@ -20,6 +20,27 @@ SERVICE_PREFIX = "/api/session" # 唯一 App 的 prefix(端口 8091) | |||
| 20 | 20 | ||
| 21 | logger = logging.getLogger("agent_runtime.config") | 21 | logger = logging.getLogger("agent_runtime.config") |
| 22 | 22 | ||
| 23 | +_SA_NS_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def own_namespace() -> str: | ||
| 27 | + """default_namespace 解析链:POD_NAMESPACE env(显式覆盖/downward API) | ||
| 28 | + > in-cluster SA namespace 文件(Pod 必挂,自身 ns,零部署配置)> "default"。 | ||
| 29 | + | ||
| 30 | + 2026-09-17 cyz2 实录:镜像带"删 AGENT_RUNTIME_DEFAULT_NAMESPACE"新代码 + | ||
| 31 | + 旧部署模板无 POD_NAMESPACE 注入 → 空 ns 模板落字面 "default" → SA 越 | ||
| 32 | + ns 建 Pod 403;SA 文件兜底使存量部署零改动自愈(env 仍可显式覆盖)。 | ||
| 33 | + """ | ||
| 34 | + ns = (os.getenv("POD_NAMESPACE") or "").strip() | ||
| 35 | + if ns: | ||
| 36 | + return ns | ||
| 37 | + try: | ||
| 38 | + with open(_SA_NS_FILE, encoding="utf-8") as f: | ||
| 39 | + ns = f.read().strip() | ||
| 40 | + except OSError: | ||
| 41 | + return "default" | ||
| 42 | + return ns or "default" | ||
| 43 | + | ||
| 23 | 44 | ||
| 24 | def _env_float(name: str, default: float) -> float: | 45 | def _env_float(name: str, default: float) -> float: |
| 25 | """float 型 env(评估 LLM 超时用;场景 F 快失败时随 scope_full_timeout | 46 | """float 型 env(评估 LLM 超时用;场景 F 快失败时随 scope_full_timeout |
| @@ -89,8 +110,8 @@ class AgentRuntimeConfig: | |||
| 89 | return cls( | 110 | return cls( |
| 90 | mode=os.getenv("AGENT_RUNTIME_MODE", "server").strip().lower(), | 111 | mode=os.getenv("AGENT_RUNTIME_MODE", "server").strip().lower(), |
| 91 | kubeconfig=os.getenv("AGENT_RUNTIME_KUBECONFIG") or None, | 112 | kubeconfig=os.getenv("AGENT_RUNTIME_KUBECONFIG") or None, |
| 92 | - # 解析链:POD_NAMESPACE(downward API 注入的自身 ns)> "default" | 113 | + # 解析链见 own_namespace():POD_NAMESPACE env > SA 文件 > "default" |
| 93 | - default_namespace=(os.getenv("POD_NAMESPACE") or "default"), | 114 | + default_namespace=own_namespace(), |
| 94 | sweep_interval=_env_int("AGENT_RUNTIME_SWEEP_INTERVAL", 1), | 115 | sweep_interval=_env_int("AGENT_RUNTIME_SWEEP_INTERVAL", 1), |
| 95 | autoscale_interval=_env_int("AGENT_RUNTIME_AUTOSCALE_INTERVAL", 1), | 116 | autoscale_interval=_env_int("AGENT_RUNTIME_AUTOSCALE_INTERVAL", 1), |
| 96 | reclaim_interval=_env_int("AGENT_RUNTIME_RECLAIM_INTERVAL", 1), | 117 | reclaim_interval=_env_int("AGENT_RUNTIME_RECLAIM_INTERVAL", 1), |
| @@ -1,5 +1,5 @@ | |||
| 1 | # coding: utf-8 | 1 | # coding: utf-8 |
| 2 | -"""Resource Manager 的 6 个 Lua 脚本(所有编排态变更,原子)。 | 2 | +"""Resource Manager 的 7 个 Lua 脚本(所有编排态变更,原子)。 |
| 3 | 3 | ||
| 4 | 约定同 SM:``ARGV[1]`` 恒为键前缀(``{resource_manager}:``,hash tag 使 cluster | 4 | 约定同 SM:``ARGV[1]`` 恒为键前缀(``{resource_manager}:``,hash tag 使 cluster |
| 5 | 下全键域同槽);返回扁平字符串数组。 | 5 | 下全键域同槽);返回扁平字符串数组。 |
| @@ -9,6 +9,7 @@ | |||
| 9 | - LUA_REGISTER deploy 成功登记(info / scope:pods / pods:all,清占位;热备入 idle; | 9 | - LUA_REGISTER deploy 成功登记(info / scope:pods / pods:all,清占位;热备入 idle; |
| 10 | generation 服务端烙印注册时刻 scope 当前代次) | 10 | generation 服务端烙印注册时刻 scope 当前代次) |
| 11 | - LUA_RELEASE idle_consider:转 idle 暖池 + 起 pod_ttl 计时(幂等) | 11 | - LUA_RELEASE idle_consider:转 idle 暖池 + 起 pod_ttl 计时(幂等) |
| 12 | +- LUA_POP_IDLE 忙占用摘出 idle 暖池(follower 接管 leader 热备 Pod;幂等) | ||
| 12 | - LUA_PURGE Pod 死亡 / reclaim 后清全部 RM key(幂等) | 13 | - LUA_PURGE Pod 死亡 / reclaim 后清全部 RM key(幂等) |
| 13 | - LUA_DEPLOY_FOLLOWER_GATE deploy 锁输家的等待室原子准入(ZSET+deadline, | 14 | - LUA_DEPLOY_FOLLOWER_GATE deploy 锁输家的等待室原子准入(ZSET+deadline, |
| 14 | 上限 pod_concurrency-1;先清过期成员再 ZADD 先行+超限自退——原子闸门 | 15 | 上限 pod_concurrency-1;先清过期成员再 ZADD 先行+超限自退——原子闸门 |
| @@ -60,7 +61,14 @@ end | |||
| 60 | -- 2. 无匹配暖 Pod:判 max_pods(含 deploying 占位,防并发超配) | 61 | -- 2. 无匹配暖 Pod:判 max_pods(含 deploying 占位,防并发超配) |
| 61 | local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods') | 62 | local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods') |
| 62 | + redis.call('ZCARD', dep_key) | 63 | + redis.call('ZCARD', dep_key) |
| 63 | -if total >= max_pods then | 64 | +-- 排空纪元活跃(sunset drain)→ 容量上限 +SURGE_MARGIN(暂写死 1,与 |
| 65 | +-- sweeper.DRAIN_SURGE_MARGIN 对齐):日落老 Pod 在 drain_until 前占槽不清, | ||
| 66 | +-- 无余量则新代补位 Pod 无处部署(2026-09-17 优雅排空配套头寸) | ||
| 67 | +local cap = max_pods | ||
| 68 | +if redis.call('EXISTS', pfx .. 'resource:scope:' .. scope .. ':drain_until') == 1 then | ||
| 69 | + cap = cap + 1 | ||
| 70 | +end | ||
| 71 | +if total >= cap then | ||
| 64 | return {'max_reached', '', ''} | 72 | return {'max_reached', '', ''} |
| 65 | end | 73 | end |
| 66 | 74 | ||
| @@ -86,7 +94,12 @@ local dep_key = pfx .. 'resource:scope:' .. scope .. ':deploying' | |||
| 86 | redis.call('ZREMRANGEBYSCORE', dep_key, '-inf', now) | 94 | redis.call('ZREMRANGEBYSCORE', dep_key, '-inf', now) |
| 87 | local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods') | 95 | local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods') |
| 88 | + redis.call('ZCARD', dep_key) | 96 | + redis.call('ZCARD', dep_key) |
| 89 | -if total >= max_pods then | 97 | +-- 排空纪元活跃 → 上限 +SURGE_MARGIN(1),同 LUA_ACQUIRE(2026-09-17 优雅排空) |
| 98 | +local cap = max_pods | ||
| 99 | +if redis.call('EXISTS', pfx .. 'resource:scope:' .. scope .. ':drain_until') == 1 then | ||
| 100 | + cap = cap + 1 | ||
| 101 | +end | ||
| 102 | +if total >= cap then | ||
| 90 | return {'max_reached'} | 103 | return {'max_reached'} |
| 91 | end | 104 | end |
| 92 | redis.call('ZADD', dep_key, deadline, token) | 105 | redis.call('ZADD', dep_key, deadline, token) |
| @@ -159,6 +172,22 @@ end | |||
| 159 | return {'true'} | 172 | return {'true'} |
| 160 | """ | 173 | """ |
| 161 | 174 | ||
| 175 | +# Argv: prefix, pod_id, scope_id | ||
| 176 | +LUA_POP_IDLE = r""" | ||
| 177 | +local pfx = ARGV[1] | ||
| 178 | +local pod = ARGV[2] | ||
| 179 | +local scope = ARGV[3] | ||
| 180 | + | ||
| 181 | +-- follower 接管 leader 新 Pod 的忙记账(与 LUA_ACQUIRE reuse 分支同款): | ||
| 182 | +-- 摘出 idle 池 + 清 idle_since。幂等:非 idle 成员(请求驱动 leader 的 | ||
| 183 | +-- idle_flag=False 注册 / 已被并发 acquire 弹出)两指令均为 no-op。不摘则 | ||
| 184 | +-- autoscale 的 warm 底数把忙 Pod 数成热备,min_idle 永不重建(2026-09-17 | ||
| 185 | +-- wangchang follower_reuse 泄漏实录:池卡 1 忙 Pod 无补位)。 | ||
| 186 | +redis.call('SREM', pfx .. 'resource:scope:' .. scope .. ':idle', pod) | ||
| 187 | +redis.call('DEL', pfx .. 'resource:pod:' .. pod .. ':idle_since') | ||
| 188 | +return {'ok'} | ||
| 189 | +""" | ||
| 190 | + | ||
| 162 | # Argv: prefix, pod_id | 191 | # Argv: prefix, pod_id |
| 163 | LUA_PURGE = r""" | 192 | LUA_PURGE = r""" |
| 164 | local pfx = ARGV[1] | 193 | local pfx = ARGV[1] |
| @@ -35,6 +35,9 @@ FOLLOWER_WAIT_MARGIN = 10 # follower 等待上界 = ready_timeout + 此余 | |||
| 35 | NO_CONFIG_MAX_LOOPS = 5 # acquire 内 no_config 重跑上限(真异常态有界,防热自旋) | 35 | NO_CONFIG_MAX_LOOPS = 5 # acquire 内 no_config 重跑上限(真异常态有界,防热自旋) |
| 36 | NO_CONFIG_BACKOFF_SEC = 0.2 # no_config 重跑退避(正常首见建配置后 1 次即过) | 36 | NO_CONFIG_BACKOFF_SEC = 0.2 # no_config 重跑退避(正常首见建配置后 1 次即过) |
| 37 | FOLLOWER_PROGRESS_LOG_SEC = 5 # follower 轮询进度 INFO 行间隔(限频) | 37 | FOLLOWER_PROGRESS_LOG_SEC = 5 # follower 轮询进度 INFO 行间隔(限频) |
| 38 | +DRAIN_SLACK_S = 60 # 排空纪元键 TTL 的崩溃兜底余量(盖住 reconcile 30s | ||
| 39 | + # + reclaim 1s tick + 选主抖动;正常终止是 reclaim | ||
| 40 | + # 收完主动 DEL,TTL 只防进程死透后 surge 悬挂) | ||
| 38 | 41 | ||
| 39 | 42 | ||
| 40 | def _deploy_ver(pod_spec: dict[str, Any]) -> str: | 43 | def _deploy_ver(pod_spec: dict[str, Any]) -> str: |
| @@ -94,6 +97,7 @@ class ResourceOrchestrator: | |||
| 94 | "min_idle_pods": int(pool_config.get("min_idle_pods", 0)), | 97 | "min_idle_pods": int(pool_config.get("min_idle_pods", 0)), |
| 95 | "max_pods": int(pool_config.get("max_pods", 1)), | 98 | "max_pods": int(pool_config.get("max_pods", 1)), |
| 96 | "pod_ttl": int(pool_config.get("pod_ttl", 300)), | 99 | "pod_ttl": int(pool_config.get("pod_ttl", 300)), |
| 100 | + "session_ttl": int(pool_config.get("session_ttl", 600)), | ||
| 97 | "pod_concurrency": int(pool_config.get("pod_concurrency", 1)), | 101 | "pod_concurrency": int(pool_config.get("pod_concurrency", 1)), |
| 98 | "deploy_ver": deploy_ver, | 102 | "deploy_ver": deploy_ver, |
| 99 | "pod_spec_json": json.dumps(pod_spec), | 103 | "pod_spec_json": json.dumps(pod_spec), |
| @@ -241,6 +245,13 @@ class ResourceOrchestrator: | |||
| 241 | for pod in new_pods: | 245 | for pod in new_pods: |
| 242 | info = await self.state.pod_info(pod) | 246 | info = await self.state.pod_info(pod) |
| 243 | if info.get("pod_sse_url"): | 247 | if info.get("pod_sse_url"): |
| 248 | + # 忙记账对齐 LUA_ACQUIRE 的 reuse 分支:leader 以 | ||
| 249 | + # idle_flag=True 注册的热备 Pod 被接管即转忙,须摘出 | ||
| 250 | + # idle 池——否则 autoscale 的 warm 底数把忙 Pod 数成 | ||
| 251 | + # 热备,min_idle 永不重建(2026-09-17 wangchang 实录: | ||
| 252 | + # follower_reuse 泄漏 → 池卡 1 忙 Pod,久无补位)。 | ||
| 253 | + # 幂等:请求驱动 leader(idle_flag=False)注册时为 no-op。 | ||
| 254 | + await self.state.pop_idle(pod, scope_id) | ||
| 244 | logger.info( | 255 | logger.info( |
| 245 | "acquire follower reuses leader pod: scope=%s " | 256 | "acquire follower reuses leader pod: scope=%s " |
| 246 | "pod=%s follower=%s", scope_id, pod, request_id) | 257 | "pod=%s follower=%s", scope_id, pod, request_id) |
| @@ -382,21 +393,43 @@ class ResourceOrchestrator: | |||
| 382 | pool_config: dict[str, Any], | 393 | pool_config: dict[str, Any], |
| 383 | pod_spec: dict[str, Any] | None = None, | 394 | pod_spec: dict[str, Any] | None = None, |
| 384 | ) -> dict[str, bool]: | 395 | ) -> dict[str, bool]: |
| 385 | - """config_sync 主动刷新(场景 M):HSET 覆盖(幂等),立即生效。 | 396 | + """config_sync/refresh 主动刷新(场景 M):HSET 覆盖(幂等),立即生效。 |
| 386 | 397 | ||
| 387 | A 类变更附带 pod_spec:同时刷新 deploy_ver / pod_spec_json → | 398 | A 类变更附带 pod_spec:同时刷新 deploy_ver / pod_spec_json → |
| 388 | autoscale 补位的新暖 Pod 用新 deploy 字段。mapping 永不含 generation | 399 | autoscale 补位的新暖 Pod 用新 deploy 字段。mapping 永不含 generation |
| 389 | ——代次只经 bump_generation 单调递增,config_sync 推送不重置。 | 400 | ——代次只经 bump_generation 单调递增,config_sync 推送不重置。 |
| 401 | + session_ttl 进 config 供排空 deadline 计算与后续读点。 | ||
| 390 | """ | 402 | """ |
| 391 | mapping: dict[str, Any] = { | 403 | mapping: dict[str, Any] = { |
| 392 | "min_idle_pods": int(pool_config.get("min_idle_pods", 0)), | 404 | "min_idle_pods": int(pool_config.get("min_idle_pods", 0)), |
| 393 | "max_pods": int(pool_config.get("max_pods", 1)), | 405 | "max_pods": int(pool_config.get("max_pods", 1)), |
| 394 | "pod_ttl": int(pool_config.get("pod_ttl", 300)), | 406 | "pod_ttl": int(pool_config.get("pod_ttl", 300)), |
| 407 | + "session_ttl": int(pool_config.get("session_ttl", 600)), | ||
| 395 | } | 408 | } |
| 396 | if pod_spec is not None: | 409 | if pod_spec is not None: |
| 397 | mapping["deploy_ver"] = _deploy_ver(pod_spec) | 410 | mapping["deploy_ver"] = _deploy_ver(pod_spec) |
| 398 | mapping["pod_spec_json"] = json.dumps(pod_spec) | 411 | mapping["pod_spec_json"] = json.dumps(pod_spec) |
| 399 | await self.state.save_scope_config(scope_id, mapping) | 412 | await self.state.save_scope_config(scope_id, mapping) |
| 413 | + # ---- 日落排空纪元戳记(2026-09-17 优雅排空): | ||
| 414 | + # 本次推送后仍有版本(A 类 sync)或代次(refresh / 路由性排除已先 bump) | ||
| 415 | + # 落后的注册 Pod | ||
| 416 | + # → 它们已被/将被软摘出候选集,戳 drain_until = now + session_ttl; | ||
| 417 | + # 期间容量上限 +SURGE_MARGIN(reclaim 对应延后回收)。**纪元已活跃则不 | ||
| 418 | + # 重戳**——首因下发(refresh 的 bump / A 类的换版)定窗口,后续不改变 | ||
| 419 | + # ver/gen 的下发(B 类纯池参数)不延长;排空中的 refresh 本就被闸门 409 | ||
| 420 | + # 拦住到不了这里。无落后 Pod 不戳(空 scope/B 类不得激活 surge)。 | ||
| 421 | + # 检出读注册表不读候选集,与软摘除的先后顺序无关。 | ||
| 422 | + if (await self.state.drain_until(scope_id) is None | ||
| 423 | + and await self.state.has_lagged_pods(scope_id)): | ||
| 424 | + ttl = int(pool_config.get("session_ttl", 600)) | ||
| 425 | + deadline = now_ts() + ttl | ||
| 426 | + await self.state.set_drain_until( | ||
| 427 | + scope_id, deadline, ttl + DRAIN_SLACK_S) | ||
| 428 | + logger.info( | ||
| 429 | + "sunset drain epoch: scope=%s drain_until=%d window=%ds " | ||
| 430 | + "(lagged pods keep serving sessions until deadline)", | ||
| 431 | + scope_id, deadline, ttl, | ||
| 432 | + ) | ||
| 400 | logger.info("update_pool_config: scope=%s fields=%s", scope_id, sorted(mapping)) | 433 | logger.info("update_pool_config: scope=%s fields=%s", scope_id, sorted(mapping)) |
| 401 | return {"updated": True} | 434 | return {"updated": True} |
| 402 | 435 | ||
| @@ -404,7 +437,8 @@ class ResourceOrchestrator: | |||
| 404 | """config_refresh 的代次日落(facade 出口):scope 代次 +1,返回新代次。 | 437 | """config_refresh 的代次日落(facade 出口):scope 代次 +1,返回新代次。 |
| 405 | 438 | ||
| 406 | 效果:现有 Pod 的 generation 全部落后于 config → LUA_ACQUIRE 不再复用、 | 439 | 效果:现有 Pod 的 generation 全部落后于 config → LUA_ACQUIRE 不再复用、 |
| 407 | - _current_version_idle 判 stale(reclaim 按 pod_ttl 回收、autoscale 重建)。 | 440 | + _current_version_idle 判 stale(reclaim 按排空窗口延后回收、autoscale |
| 441 | + 重建;排空纪元由随后的 update_pool_config 推送戳记)。 | ||
| 408 | """ | 442 | """ |
| 409 | generation = await self.state.bump_generation(scope_id) | 443 | generation = await self.state.bump_generation(scope_id) |
| 410 | logger.info("bump_generation: scope=%s generation=%d", scope_id, generation) | 444 | logger.info("bump_generation: scope=%s generation=%d", scope_id, generation) |
| @@ -76,6 +76,17 @@ class RMKeys: | |||
| 76 | """ | 76 | """ |
| 77 | return f"{self.prefix}:resource:scope:{scope_id}:deploy_followers" | 77 | return f"{self.prefix}:resource:scope:{scope_id}:deploy_followers" |
| 78 | 78 | ||
| 79 | + def scope_drain_until(self, scope_id: str) -> str: | ||
| 80 | + """STRING: 日落排空截止时间戳(秒)。存在 ⟺ 排空纪元活跃。 | ||
| 81 | + | ||
| 82 | + update_pool_config 检出版本/代次落后 Pod 时戳记(值 = now + | ||
| 83 | + session_ttl,EX 同值 + DRAIN_SLACK_S 崩溃兜底);期间 LUA_ACQUIRE / | ||
| 84 | + LUA_PLACEHOLDER 的容量上限 +SURGE_MARGIN、reclaim 对 stale Pod 延后 | ||
| 85 | + 到截止后才回收;全部 stale Pod 收完后由 reclaim 主动 DEL(TTL 只是 | ||
| 86 | + 崩溃兜底,非正常终止路径)。 | ||
| 87 | + """ | ||
| 88 | + return f"{self.prefix}:resource:scope:{scope_id}:drain_until" | ||
| 89 | + | ||
| 79 | # ---- Pod 级 | 90 | # ---- Pod 级 |
| 80 | def pod_info(self, pod_id: str) -> str: | 91 | def pod_info(self, pod_id: str) -> str: |
| 81 | """HASH: scope_id / pod_sse_url / pod_ip / namespace / phase / created_ts / | 92 | """HASH: scope_id / pod_sse_url / pod_ip / namespace / phase / created_ts / |
| @@ -204,6 +215,14 @@ class ResourceState: | |||
| 204 | ret = await self.eval(lua.LUA_RELEASE, pod_id, scope_id, now) | 215 | ret = await self.eval(lua.LUA_RELEASE, pod_id, scope_id, now) |
| 205 | return bool(ret and ret[0] == "true") | 216 | return bool(ret and ret[0] == "true") |
| 206 | 217 | ||
| 218 | + async def pop_idle(self, pod_id: str, scope_id: str) -> None: | ||
| 219 | + """LUA_POP_IDLE:忙占用摘出 idle 暖池 + 清 idle_since。幂等。 | ||
| 220 | + | ||
| 221 | + follower 接管 leader 热备 Pod 时用——与 LUA_ACQUIRE 的 reuse 分支 | ||
| 222 | + 记账对齐(release 的逆操作,不起计时而是清计时)。 | ||
| 223 | + """ | ||
| 224 | + await self.eval(lua.LUA_POP_IDLE, pod_id, scope_id) | ||
| 225 | + | ||
| 207 | async def purge(self, pod_id: str) -> str: | 226 | async def purge(self, pod_id: str) -> str: |
| 208 | """LUA_PURGE:清该 Pod 全部 RM key。返回其 scope_id(空串=本就不存在)。""" | 227 | """LUA_PURGE:清该 Pod 全部 RM key。返回其 scope_id(空串=本就不存在)。""" |
| 209 | ret = await self.eval(lua.LUA_PURGE, pod_id) | 228 | ret = await self.eval(lua.LUA_PURGE, pod_id) |
| @@ -269,6 +288,36 @@ class ResourceState: | |||
| 269 | self.k.scope_config(scope_id), "generation", 1 | 288 | self.k.scope_config(scope_id), "generation", 1 |
| 270 | )) | 289 | )) |
| 271 | 290 | ||
| 291 | + async def drain_until(self, scope_id: str) -> int | None: | ||
| 292 | + """排空截止时间戳;None = 无活跃排空纪元(或已过期,兜底语义 = 立即回收)。""" | ||
| 293 | + raw = await self.redis.get(self.k.scope_drain_until(scope_id)) | ||
| 294 | + return to_int(raw) if raw is not None else None | ||
| 295 | + | ||
| 296 | + async def set_drain_until(self, scope_id: str, deadline: int, ttl: int) -> None: | ||
| 297 | + """戳记排空纪元(EX=ttl 崩溃兜底;正常终止走 clear_drain_until)。""" | ||
| 298 | + await self.redis.set(self.k.scope_drain_until(scope_id), deadline, ex=ttl) | ||
| 299 | + | ||
| 300 | + async def clear_drain_until(self, scope_id: str) -> None: | ||
| 301 | + await self.redis.delete(self.k.scope_drain_until(scope_id)) | ||
| 302 | + | ||
| 303 | + async def has_lagged_pods(self, scope_id: str) -> bool: | ||
| 304 | + """注册表中是否存在版本或代次落后于 scope 当前配置的 Pod。 | ||
| 305 | + | ||
| 306 | + 排空纪元的统一判据(update_pool_config 戳记 / reclaim 收尾终止共用): | ||
| 307 | + 读注册表(scope:pods + pod:info),不读 SM 候选集——与软摘除的先后 | ||
| 308 | + 顺序无关。幽灵成员(info 已清)不构成落后。 | ||
| 309 | + """ | ||
| 310 | + cfg = await self.load_scope_config(scope_id) | ||
| 311 | + ver = cfg.get("deploy_ver") or "" | ||
| 312 | + gen = cfg.get("generation") or "" | ||
| 313 | + for pod_id in await self.pod_ids(scope_id): | ||
| 314 | + info = await self.pod_info(pod_id) | ||
| 315 | + if info and ( | ||
| 316 | + (info.get("deploy_ver") or "") != ver | ||
| 317 | + or (info.get("generation") or "") != gen): | ||
| 318 | + return True | ||
| 319 | + return False | ||
| 320 | + | ||
| 272 | async def has_scope_config(self, scope_id: str) -> bool: | 321 | async def has_scope_config(self, scope_id: str) -> bool: |
| 273 | return to_int( | 322 | return to_int( |
| 274 | await self.redis.hlen(self.k.scope_config(scope_id)) | 323 | await self.redis.hlen(self.k.scope_config(scope_id)) |
| @@ -34,6 +34,9 @@ logger = logging.getLogger("agent_runtime.resource_manager") | |||
| 34 | HEALTH_FAIL_THRESHOLD = 2 # 连续失败判半死(防瞬时抖动误杀,场景 N) | 34 | HEALTH_FAIL_THRESHOLD = 2 # 连续失败判半死(防瞬时抖动误杀,场景 N) |
| 35 | WATCH_LOCK_TTL = 15 # 10s tick + 余量 | 35 | WATCH_LOCK_TTL = 15 # 10s tick + 余量 |
| 36 | RECONCILE_LOCK_TTL = 60 # 30s tick + 余量 | 36 | RECONCILE_LOCK_TTL = 60 # 30s tick + 余量 |
| 37 | +DRAIN_SURGE_MARGIN = 1 # 排空纪元活跃时的容量上限加成(暂写死,2026-09-17 | ||
| 38 | + # 优雅排空决策;与 lua_scripts 两闸门内的 +1 对齐—— | ||
| 39 | + # 改动须三处同步) | ||
| 37 | 40 | ||
| 38 | 41 | ||
| 39 | class ResourceSweeper: | 42 | class ResourceSweeper: |
| @@ -132,9 +135,15 @@ class ResourceSweeper: | |||
| 132 | f"warm={len(warm)} min_idle={min_idle} idle_total={len(idle)}" | 135 | f"warm={len(warm)} min_idle={min_idle} idle_total={len(idle)}" |
| 133 | ) | 136 | ) |
| 134 | total = await self.state.pod_count(scope_id) + await self.state.deploying_count(scope_id) | 137 | total = await self.state.pod_count(scope_id) + await self.state.deploying_count(scope_id) |
| 135 | - if total >= max_pods: | 138 | + # 排空纪元活跃 → 容量上限 +SURGE_MARGIN:日落老 Pod 在 drain_until 前 |
| 139 | + # 占槽不清,无余量则新代补位 Pod 无处部署(与 Lua 两闸门同步加成) | ||
| 140 | + cap = max_pods | ||
| 141 | + if await self.state.drain_until(scope_id) is not None: | ||
| 142 | + cap += DRAIN_SURGE_MARGIN | ||
| 143 | + if total >= cap: | ||
| 136 | return "skip_max", ( | 144 | return "skip_max", ( |
| 137 | f"warm={len(warm)} min_idle={min_idle} total={total} max_pods={max_pods}" | 145 | f"warm={len(warm)} min_idle={min_idle} total={total} max_pods={max_pods}" |
| 146 | + + (f" cap={cap}(drain)" if cap != max_pods else "") | ||
| 138 | ) | 147 | ) |
| 139 | # 热备 deploy 用缓存的 pod_spec(config_sync A 类变更后为新值)。 | 148 | # 热备 deploy 用缓存的 pod_spec(config_sync A 类变更后为新值)。 |
| 140 | # shape 探测:缺 main_container = 统一规范形前的旧扁平缓存(渲染会 | 149 | # shape 探测:缺 main_container = 统一规范形前的旧扁平缓存(渲染会 |
| @@ -237,36 +246,62 @@ class ResourceSweeper: | |||
| 237 | aged = {p: await self.state.idle_since(p) for p in idle} | 246 | aged = {p: await self.state.idle_since(p) for p in idle} |
| 238 | ranked_warm = sorted(warm, key=lambda p: aged[p]) | 247 | ranked_warm = sorted(warm, key=lambda p: aged[p]) |
| 239 | excess = stale + ranked_warm[min_idle:] | 248 | excess = stale + ranked_warm[min_idle:] |
| 249 | + # 排空纪元(优雅排空,2026-09-17):日落 Pod(ver/gen 落后, | ||
| 250 | + # 已被软摘出候选集)在 drain_until 前不回收——期间已绑定 | ||
| 251 | + # 会话继续在其上服务(route 亲和/touch 只查 pod:info 不查 | ||
| 252 | + # 候选集),窗口给到 session_ttl 让停活跃的会话自然结束; | ||
| 253 | + # 键缺失(过期/旧版本遗留)兜底为立即可收,闸门收敛不破坏 | ||
| 254 | + drain = await self.state.drain_until(scope_id) | ||
| 240 | # 待回收留痕:excess 成员变化才 INFO(30s tick,稳定成员 | 255 | # 待回收留痕:excess 成员变化才 INFO(30s tick,稳定成员 |
| 241 | # 不重复);到龄即回收的走 _reclaim_pod 自有日志 | 256 | # 不重复);到龄即回收的走 _reclaim_pod 自有日志 |
| 242 | sig = tuple(excess) | 257 | sig = tuple(excess) |
| 243 | if self._reclaim_state.get(scope_id) != sig: | 258 | if self._reclaim_state.get(scope_id) != sig: |
| 244 | ages = [now - aged[p] for p in excess if aged.get(p)] | 259 | ages = [now - aged[p] for p in excess if aged.get(p)] |
| 245 | overflow = ranked_warm[min_idle:] | 260 | overflow = ranked_warm[min_idle:] |
| 246 | - due = len(stale) + sum( | 261 | + due = sum( |
| 262 | + 1 for p in stale | ||
| 263 | + if drain is None or now >= drain | ||
| 264 | + ) + sum( | ||
| 247 | 1 for p in overflow | 265 | 1 for p in overflow |
| 248 | if aged.get(p) and now - aged[p] >= pod_ttl | 266 | if aged.get(p) and now - aged[p] >= pod_ttl |
| 249 | ) | 267 | ) |
| 268 | + drain_ctx = ( | ||
| 269 | + f" drain_wait={max(0, drain - now)}s" | ||
| 270 | + if drain is not None and any( | ||
| 271 | + p in stale_set for p in excess) else "") | ||
| 250 | logger.info( | 272 | logger.info( |
| 251 | "reclaim pending: scope=%s excess=%d pending=%d " | 273 | "reclaim pending: scope=%s excess=%d pending=%d " |
| 252 | - "oldest_age=%ds pod_ttl=%ds pods=%s", | 274 | + "oldest_age=%ds pod_ttl=%ds%s pods=%s", |
| 253 | scope_id, len(excess), len(excess) - due, | 275 | scope_id, len(excess), len(excess) - due, |
| 254 | max(ages) if ages else 0, | 276 | max(ages) if ages else 0, |
| 255 | - pod_ttl, ",".join(excess), | 277 | + pod_ttl, drain_ctx, ",".join(excess), |
| 256 | ) | 278 | ) |
| 257 | self._reclaim_state[scope_id] = sig | 279 | self._reclaim_state[scope_id] = sig |
| 258 | - # 两级回收:stale(ver/gen 落后)免老化即刻回收——acquire 的 | 280 | + # 两级回收:stale(ver/gen 落后)排空到龄回收(drain 缺失 |
| 259 | - # want_ver+generation 过滤已判死刑,零复用价值,蹲满剩余 | 281 | + # 兜底立即——acquire 的 want_ver+generation 过滤已判死刑, |
| 260 | - # pod_ttl 只白占 max_pods 槽位并拖长 config_sync/refresh 日落 | 282 | + # 零复用价值,蹲着只白占 max_pods 槽位并拖长 config_sync/ |
| 261 | - # 闸门的等待(含扩散后仍带会话的 Pod:会话被硬切重放置, | 283 | + # refresh 日落闸门的等待);当前版本超额保留 pod_ttl 老化, |
| 262 | - # 2026-09-15 决策接受);当前版本超额保留 pod_ttl 老化,抗 | 284 | + # 抗「马上会被复用」的抖动(真镜像冷启动 p50≈12s)。 |
| 263 | - # 「马上会被复用」的抖动(真镜像冷启动 p50≈12s)。 | 285 | + reclaimed_stale = 0 |
| 264 | for pod_id in excess: | 286 | for pod_id in excess: |
| 265 | - if pod_id in stale_set or ( | 287 | + if (pod_id in stale_set and ( |
| 288 | + drain is None or now >= drain)) or ( | ||
| 289 | + pod_id not in stale_set and | ||
| 266 | aged.get(pod_id) | 290 | aged.get(pod_id) |
| 267 | and now - aged[pod_id] >= pod_ttl): | 291 | and now - aged[pod_id] >= pod_ttl): |
| 268 | await self._reclaim_pod(pod_id, scope_id) | 292 | await self._reclaim_pod(pod_id, scope_id) |
| 269 | reclaimed += 1 | 293 | reclaimed += 1 |
| 294 | + if pod_id in stale_set: | ||
| 295 | + reclaimed_stale += 1 | ||
| 296 | + # 排空收尾:本拍收过 stale 且注册表已无任何落后 Pod → | ||
| 297 | + # 纪元结束,DEL drain_until 精确终止 surge(不留 TTL 悬挂 | ||
| 298 | + # 的超额窗口);仍有落后 Pod(含未释放的带会话者)则续期等待 | ||
| 299 | + if reclaimed_stale and not await self.state.has_lagged_pods( | ||
| 300 | + scope_id): | ||
| 301 | + await self.state.clear_drain_until(scope_id) | ||
| 302 | + logger.info( | ||
| 303 | + "sunset drain epoch done: scope=%s (lagged pods all " | ||
| 304 | + "reclaimed, surge released)", scope_id) | ||
| 270 | except Exception: # noqa: BLE001 - per-scope 隔离,下拍重试 | 305 | except Exception: # noqa: BLE001 - per-scope 隔离,下拍重试 |
| 271 | logger.exception("reclaim scope failed: scope=%s", scope_id) | 306 | logger.exception("reclaim scope failed: scope=%s", scope_id) |
| 272 | finally: | 307 | finally: |
| @@ -110,7 +110,8 @@ SERVICE_CONFIG_TEMPLATE_TABLE_DEF = TableDefinition( | |||
| 110 | ColumnDefinition("pod_ttl", "integer", nullable=False, default=300), | 110 | ColumnDefinition("pod_ttl", "integer", nullable=False, default=300), |
| 111 | ColumnDefinition("scope_concurrency", "integer", nullable=False, default=3), | 111 | ColumnDefinition("scope_concurrency", "integer", nullable=False, default=3), |
| 112 | ColumnDefinition("session_ttl", "integer", nullable=False, default=60), | 112 | ColumnDefinition("session_ttl", "integer", nullable=False, default=60), |
| 113 | - ColumnDefinition("enabled", "boolean", nullable=False, default=True), | 113 | + # enabled 列 2026-09 删除(生命周期=存在性;存量库先发版后 DROP, |
| 114 | + # 见 docs/feature/2026-09-drop-enabled-fields.md) | ||
| 114 | ColumnDefinition("data", "json", nullable=True), | 115 | ColumnDefinition("data", "json", nullable=True), |
| 115 | ColumnDefinition("created_at", "datetime", nullable=False), | 116 | ColumnDefinition("created_at", "datetime", nullable=False), |
| 116 | ColumnDefinition("updated_at", "datetime", nullable=False), | 117 | ColumnDefinition("updated_at", "datetime", nullable=False), |
| @@ -129,7 +130,8 @@ ROUTING_SCOPE_TABLE_DEF = TableDefinition( | |||
| 129 | # 结构化规则原样落库(wire 格式;空列表/NULL = 通配 scope) | 130 | # 结构化规则原样落库(wire 格式;空列表/NULL = 通配 scope) |
| 130 | ColumnDefinition("routing_rules", "json", nullable=True), | 131 | ColumnDefinition("routing_rules", "json", nullable=True), |
| 131 | ColumnDefinition("expires_at", "datetime", nullable=True), | 132 | ColumnDefinition("expires_at", "datetime", nullable=True), |
| 132 | - ColumnDefinition("enabled", "boolean", nullable=False, default=True), | 133 | + # enabled 列 2026-09 删除(生命周期=存在性+expires_at;存量库先发版 |
| 134 | + # 后 DROP,见 docs/feature/2026-09-drop-enabled-fields.md) | ||
| 133 | ColumnDefinition("data", "json", nullable=True), | 135 | ColumnDefinition("data", "json", nullable=True), |
| 134 | ColumnDefinition("created_at", "datetime", nullable=False), | 136 | ColumnDefinition("created_at", "datetime", nullable=False), |
| 135 | ColumnDefinition("updated_at", "datetime", nullable=False), | 137 | ColumnDefinition("updated_at", "datetime", nullable=False), |
| @@ -156,7 +158,6 @@ _COLUMN_OF: dict[str, str] = { | |||
| 156 | "pod_ttl": "pod_ttl", | 158 | "pod_ttl": "pod_ttl", |
| 157 | "scope_concurrency": "scope_concurrency", | 159 | "scope_concurrency": "scope_concurrency", |
| 158 | "session_ttl": "session_ttl", | 160 | "session_ttl": "session_ttl", |
| 159 | - "enabled": "enabled", | ||
| 160 | "data": "data", | 161 | "data": "data", |
| 161 | } | 162 | } |
| 162 | 163 | ||
| @@ -182,9 +183,10 @@ _LEGACY_INLINE_CONTAINER_KEYS = frozenset({ | |||
| 182 | # 模板级字段(留在模板表;容器级由容器表水合为统一 canonical,见 | 183 | # 模板级字段(留在模板表;容器级由容器表水合为统一 canonical,见 |
| 183 | # container_spec.build_canonical)。三段式契约的 template dict 只认这些键 + | 184 | # container_spec.build_canonical)。三段式契约的 template dict 只认这些键 + |
| 184 | # main_container_id/sidecar_container_ids/volumes;与 legacy 内联容器键 | 185 | # main_container_id/sidecar_container_ids/volumes;与 legacy 内联容器键 |
| 185 | -# 并存 = mixed 形态 → 400。 | 186 | +# 并存 = mixed 形态 → 400。enabled 已删(2026-09-drop-enabled-fields):残留 |
| 187 | +# 键由 _parse_payload 防御性剔除(真值 false 视为缺席)或静默忽略(真值 true)。 | ||
| 186 | TEMPLATE_LEVEL_FIELDS: tuple[str, ...] = ( | 188 | TEMPLATE_LEVEL_FIELDS: tuple[str, ...] = ( |
| 187 | - "template_id", "template_name", "description", "enabled", "data", | 189 | + "template_id", "template_name", "description", "data", |
| 188 | "namespace", "node_name", "fs_group", "pod_name", "sse_path", | 190 | "namespace", "node_name", "fs_group", "pod_name", "sse_path", |
| 189 | "ready_timeout", "ready_poll_interval", "kubeconfig", | 191 | "ready_timeout", "ready_poll_interval", "kubeconfig", |
| 190 | "scope_concurrency", "pod_concurrency", "session_ttl", "pod_ttl", | 192 | "scope_concurrency", "pod_concurrency", "session_ttl", "pod_ttl", |
| @@ -199,8 +201,8 @@ _TEMPLATE_WIRE_ALIASES = {"node_name": "nodeName", | |||
| 199 | 201 | ||
| 200 | 202 | ||
| 201 | def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]: | 203 | def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]: |
| 202 | - """scope → DB 行(时间戳由 _upsert_row_tx 统一处理;enabled/expires_at | 204 | + """scope → DB 行(时间戳由 _upsert_row_tx 统一处理;expires_at 为 |
| 203 | - 为 2026-09 routing-scope 扩展字段,随三段式载荷透传)。""" | 205 | + routing-scope 扩展字段随三段式载荷透传;enabled 已删,生命周期=存在性)。""" |
| 204 | data = getattr(scope, "data", None) | 206 | data = getattr(scope, "data", None) |
| 205 | return { | 207 | return { |
| 206 | "jiuwenclaw_id": TENANT_ID, | 208 | "jiuwenclaw_id": TENANT_ID, |
| @@ -209,7 +211,6 @@ def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]: | |||
| 209 | "template_id": scope.template_id, | 211 | "template_id": scope.template_id, |
| 210 | # 原始表达式串(空 = 通配);JSON 列存标量字符串 | 212 | # 原始表达式串(空 = 通配);JSON 列存标量字符串 |
| 211 | "routing_rules": scope.expr, | 213 | "routing_rules": scope.expr, |
| 212 | - "enabled": bool(scope.enabled), | ||
| 213 | "expires_at": scope.expires_at, | 214 | "expires_at": scope.expires_at, |
| 214 | "data": data if isinstance(data, dict) else None, | 215 | "data": data if isinstance(data, dict) else None, |
| 215 | } | 216 | } |
| @@ -236,7 +237,7 @@ def _hydrate_containers( | |||
| 236 | def template_from_row(row: Any, | 237 | def template_from_row(row: Any, |
| 237 | containers: dict[str, dict[str, Any]] | None = None, | 238 | containers: dict[str, dict[str, Any]] | None = None, |
| 238 | ) -> Template | None: | 239 | ) -> Template | None: |
| 239 | - """DB 行 → Template 业务对象(未命中 enabled=False 的模板仍返回,调用方判定)。 | 240 | + """DB 行 → Template 业务对象。 |
| 240 | 241 | ||
| 241 | 单轨水合(2026-09 起):模板级行列 + 容器引用 + volumes join → 统一 | 242 | 单轨水合(2026-09 起):模板级行列 + 容器引用 + volumes join → 统一 |
| 242 | canonical。任一引用容器行缺失/水合校验失败 → WARNING + None(fail-closed, | 243 | canonical。任一引用容器行缺失/水合校验失败 → WARNING + None(fail-closed, |
| @@ -430,8 +431,6 @@ def _scope_from_row(row: Any) -> RoutingScopeDef | None: | |||
| 430 | f"scope_id must not contain '{{' or '}}': " | 431 | f"scope_id must not contain '{{' or '}}': " |
| 431 | f"{getattr(row, 'scope_id', None)!r}" | 432 | f"{getattr(row, 'scope_id', None)!r}" |
| 432 | ) | 433 | ) |
| 433 | - enabled_raw = getattr(row, "enabled", True) | ||
| 434 | - enabled = True if enabled_raw is None else bool(enabled_raw) | ||
| 435 | data_raw = getattr(row, "data", None) | 434 | data_raw = getattr(row, "data", None) |
| 436 | return RoutingScopeDef( | 435 | return RoutingScopeDef( |
| 437 | scope_id=s(getattr(row, "scope_id")), | 436 | scope_id=s(getattr(row, "scope_id")), |
| @@ -439,7 +438,6 @@ def _scope_from_row(row: Any) -> RoutingScopeDef | None: | |||
| 439 | template_id=s(getattr(row, "template_id")), | 438 | template_id=s(getattr(row, "template_id")), |
| 440 | expr=expr_raw, | 439 | expr=expr_raw, |
| 441 | rule=parse_routing_expr(expr_raw) if expr_raw.strip() else None, | 440 | rule=parse_routing_expr(expr_raw) if expr_raw.strip() else None, |
| 442 | - enabled=enabled, | ||
| 443 | expires_at=parse_datetime(getattr(row, "expires_at", None)), | 441 | expires_at=parse_datetime(getattr(row, "expires_at", None)), |
| 444 | data=data_raw if isinstance(data_raw, dict) else None, | 442 | data=data_raw if isinstance(data_raw, dict) else None, |
| 445 | ) | 443 | ) |
| @@ -579,7 +577,6 @@ class ConfigStore: | |||
| 579 | continue | 577 | continue |
| 580 | out.append({ | 578 | out.append({ |
| 581 | "template_id": t.template_id, | 579 | "template_id": t.template_id, |
| 582 | - "enabled": bool(t.enabled), | ||
| 583 | "agent_image": t.agent_image, | 580 | "agent_image": t.agent_image, |
| 584 | "namespace": t.namespace, | 581 | "namespace": t.namespace, |
| 585 | "scope_concurrency": t.scope_concurrency, | 582 | "scope_concurrency": t.scope_concurrency, |
| @@ -626,6 +623,22 @@ class ConfigStore: | |||
| 626 | ) | 623 | ) |
| 627 | return scope.scope_id, snapshot.templates[scope.template_id] | 624 | return scope.scope_id, snapshot.templates[scope.template_id] |
| 628 | 625 | ||
| 626 | + async def scope_template( | ||
| 627 | + self, scope_id: str | ||
| 628 | + ) -> tuple[RoutingScopeDef | None, Template | None]: | ||
| 629 | + """快照查指定 scope:(scope 定义, 其模板)。 | ||
| 630 | + | ||
| 631 | + scope 亲和保持(2026-09-scope-affinity-hold)专供 route 持有分支取 | ||
| 632 | + 绑定 scope 的 session_ttl;scope 已删 / 模板缺失时对应项为 None, | ||
| 633 | + 调用方回退绑定哈希里留存的 session_ttl。非匹配语义:不判 | ||
| 634 | + expires_at(持有不重算规则,日落界由 RM 排空窗口管)。 | ||
| 635 | + """ | ||
| 636 | + snapshot = await self._load_snapshot() | ||
| 637 | + for scope in snapshot.scopes: | ||
| 638 | + if scope.scope_id == scope_id: | ||
| 639 | + return scope, snapshot.templates.get(scope.template_id) | ||
| 640 | + return None, None | ||
| 641 | + | ||
| 629 | # -------------------------------------------------------------- 路由快照 | 642 | # -------------------------------------------------------------- 路由快照 |
| 630 | 643 | ||
| 631 | async def _load_snapshot(self) -> RoutingSnapshot: | 644 | async def _load_snapshot(self) -> RoutingSnapshot: |
| @@ -788,6 +801,12 @@ class ConfigStore: | |||
| 788 | containers_raw = payload.get("containers") | 801 | containers_raw = payload.get("containers") |
| 789 | 802 | ||
| 790 | # ---- 阶段 1:浅扫模板(收引用),不构造 | 803 | # ---- 阶段 1:浅扫模板(收引用),不构造 |
| 804 | + # 残留防御(2026-09-drop-enabled-fields):enabled 真值为 false 的模板 | ||
| 805 | + # 视为缺席——「关」的正确表达是从载荷删除,静默忽略会让存量禁用配置 | ||
| 806 | + # 升级后重开准入。引用照收(其容器不因剔除变成未引用),仅阶段 3 跳过 | ||
| 807 | + # 构造:引用它的 scope 得到直指问题的 400「引用不在本批模板集」。 | ||
| 808 | + # 真值 true 的残留键在构造层随未知键忽略。 | ||
| 809 | + dropped_templates: set[str] = set() | ||
| 791 | main_refs: dict[str, str] = {} # tid → main_container_id | 810 | main_refs: dict[str, str] = {} # tid → main_container_id |
| 792 | sidecar_refs: dict[str, list[str]] = {} | 811 | sidecar_refs: dict[str, list[str]] = {} |
| 793 | for item in templates_raw: | 812 | for item in templates_raw: |
| @@ -796,6 +815,8 @@ class ConfigStore: | |||
| 796 | tid = str(item.get("template_id") or "") | 815 | tid = str(item.get("template_id") or "") |
| 797 | if not tid: | 816 | if not tid: |
| 798 | raise InvalidParams("template item missing template_id") | 817 | raise InvalidParams("template item missing template_id") |
| 818 | + if item.get("enabled") is False: | ||
| 819 | + dropped_templates.add(tid) | ||
| 799 | main_cid = item.get("main_container_id") | 820 | main_cid = item.get("main_container_id") |
| 800 | if not isinstance(main_cid, str) or not main_cid.strip(): | 821 | if not isinstance(main_cid, str) or not main_cid.strip(): |
| 801 | raise InvalidParams( | 822 | raise InvalidParams( |
| @@ -804,6 +825,12 @@ class ConfigStore: | |||
| 804 | ) | 825 | ) |
| 805 | main_refs[tid] = main_cid | 826 | main_refs[tid] = main_cid |
| 806 | sidecar_refs[tid] = list(item.get("sidecar_container_ids") or []) | 827 | sidecar_refs[tid] = list(item.get("sidecar_container_ids") or []) |
| 828 | + if dropped_templates: | ||
| 829 | + logger.warning( | ||
| 830 | + "config_sync: templates with enabled=false treated as absent " | ||
| 831 | + "(field removed 2026-09, disable = remove from payload): %s", | ||
| 832 | + sorted(dropped_templates), | ||
| 833 | + ) | ||
| 807 | 834 | ||
| 808 | # ---- 阶段 2:容器逐项按角色校验(角色 = 引用位置;未引用/双角色 → 400) | 835 | # ---- 阶段 2:容器逐项按角色校验(角色 = 引用位置;未引用/双角色 → 400) |
| 809 | main_ids, sidecar_ids = set(main_refs.values()), set().union( | 836 | main_ids, sidecar_ids = set(main_refs.values()), set().union( |
| @@ -841,10 +868,12 @@ class ConfigStore: | |||
| 841 | f"{missing}" | 868 | f"{missing}" |
| 842 | ) | 869 | ) |
| 843 | 870 | ||
| 844 | - # ---- 阶段 3:构造模板 | 871 | + # ---- 阶段 3:构造模板(enabled=false 剔除项不构造 → 引用它的 scope 400) |
| 845 | templates_in: dict[str, TemplateSync] = {} | 872 | templates_in: dict[str, TemplateSync] = {} |
| 846 | for item in templates_raw: | 873 | for item in templates_raw: |
| 847 | tid = str(item.get("template_id") or "") | 874 | tid = str(item.get("template_id") or "") |
| 875 | + if tid in dropped_templates: | ||
| 876 | + continue | ||
| 848 | if tid in templates_in: | 877 | if tid in templates_in: |
| 849 | raise InvalidParams(f"duplicate template_id {tid!r}") | 878 | raise InvalidParams(f"duplicate template_id {tid!r}") |
| 850 | template, volumes = template_from_split_payload( | 879 | template, volumes = template_from_split_payload( |
| @@ -857,11 +886,23 @@ class ConfigStore: | |||
| 857 | ) | 886 | ) |
| 858 | 887 | ||
| 859 | scopes_in: dict[str, RoutingScopeDef] = {} | 888 | scopes_in: dict[str, RoutingScopeDef] = {} |
| 889 | + dropped_scopes: list[str] = [] | ||
| 860 | for item in scopes_raw: | 890 | for item in scopes_raw: |
| 861 | scope = parse_scope(item, set(templates_in)) | 891 | scope = parse_scope(item, set(templates_in)) |
| 892 | + if scope is None: | ||
| 893 | + # 残留防御(2026-09-drop-enabled-fields):enabled=false 的 scope | ||
| 894 | + # 视为缺席剔除 = 发送方删除该 scope → 扩散③ 优雅日落 | ||
| 895 | + dropped_scopes.append(str(item.get("scope_id") or "?")) | ||
| 896 | + continue | ||
| 862 | if scope.scope_id in scopes_in: | 897 | if scope.scope_id in scopes_in: |
| 863 | raise InvalidParams(f"duplicate scope_id {scope.scope_id!r}") | 898 | raise InvalidParams(f"duplicate scope_id {scope.scope_id!r}") |
| 864 | scopes_in[scope.scope_id] = scope | 899 | scopes_in[scope.scope_id] = scope |
| 900 | + if dropped_scopes: | ||
| 901 | + logger.warning( | ||
| 902 | + "config_sync: scopes with enabled=false treated as absent " | ||
| 903 | + "(field removed 2026-09, disable = remove from payload): %s", | ||
| 904 | + dropped_scopes, | ||
| 905 | + ) | ||
| 865 | 906 | ||
| 866 | wildcard = has_wildcard_scope(scopes_in.values()) | 907 | wildcard = has_wildcard_scope(scopes_in.values()) |
| 867 | if not wildcard: | 908 | if not wildcard: |
| @@ -888,7 +929,7 @@ class ConfigStore: | |||
| 888 | } | 929 | } |
| 889 | old_scopes = {scope.scope_id: scope for scope in await self.list_scopes()} | 930 | old_scopes = {scope.scope_id: scope for scope in await self.list_scopes()} |
| 890 | 931 | ||
| 891 | - # ---- diff:模板变更集 / 引用切换 | 932 | + # ---- diff:模板变更集 / 引用切换 / 路由性排除 |
| 892 | changed_ids = { | 933 | changed_ids = { |
| 893 | tid for tid, new in templates_in.items() | 934 | tid for tid, new in templates_in.items() |
| 894 | if tid not in old_templates | 935 | if tid not in old_templates |
| @@ -898,6 +939,17 @@ class ConfigStore: | |||
| 898 | sid for sid, scope in scopes_in.items() | 939 | sid for sid, scope in scopes_in.items() |
| 899 | if sid in old_scopes and old_scopes[sid].template_id != scope.template_id | 940 | if sid in old_scopes and old_scopes[sid].template_id != scope.template_id |
| 900 | } | 941 | } |
| 942 | + # 路由性排除(scope 亲和保持,#152):expr 变化(失权)或 生效→失效 | ||
| 943 | + # (expires_at 缩到已过;enabled 已删,关 = 从载荷删除走扩散③)。 | ||
| 944 | + # **不含** index 重排——重排不排除命中,存量会话零打扰;也不含模板 | ||
| 945 | + # 引用切换——走既有 ver 日落。 | ||
| 946 | + routing_excluded = { | ||
| 947 | + sid for sid, scope in scopes_in.items() | ||
| 948 | + if sid in old_scopes and ( | ||
| 949 | + old_scopes[sid].expr != scope.expr | ||
| 950 | + or (old_scopes[sid].is_active() and not scope.is_active()) | ||
| 951 | + ) | ||
| 952 | + } | ||
| 901 | affected = sorted( | 953 | affected = sorted( |
| 902 | {sid for sid, scope in scopes_in.items() if scope.template_id in changed_ids} | 954 | {sid for sid, scope in scopes_in.items() if scope.template_id in changed_ids} |
| 903 | | ref_switched | 955 | | ref_switched |
| @@ -966,6 +1018,23 @@ class ConfigStore: | |||
| 966 | # ---- 重建快照(DB 读回最终态 → 原子 SET;B 类立即生效由此完成) | 1018 | # ---- 重建快照(DB 读回最终态 → 原子 SET;B 类立即生效由此完成) |
| 967 | await self.rebuild_snapshot() | 1019 | await self.rebuild_snapshot() |
| 968 | 1020 | ||
| 1021 | + # ---- 路由性排除日落(scope 亲和保持,#152):expr 变化 / 生效→失效的 | ||
| 1022 | + # scope 先 bump gen——随后的扩散① push 凭 gen lag 截排空纪元 | ||
| 1023 | + # (日落 + session_ttl,与 #151 排空窗口同哲学):存量会话窗口内 | ||
| 1024 | + # 继续原池(route 亲和保持),Pod 过窗回收后旧会话 rebind 落新 | ||
| 1025 | + # 规则;新会话立即走新规则。bump 后顺带软摘候选集,防 drain 期 | ||
| 1026 | + # 新会话 first-fit 落上老代 Pod。**时序红线:bump 必须先于扩散① | ||
| 1027 | + # push**(纪元戳记依赖 gen lag 已形成)。失败 warn 不 raise——DB | ||
| 1028 | + # 已提交,丢 bump 的 scope 退化为软界(会话自然结束后 pod_ttl | ||
| 1029 | + # 回收),同扩散① push_or_warn 先例。 | ||
| 1030 | + for sid in sorted(routing_excluded): | ||
| 1031 | + await self._bump_or_warn(sid) | ||
| 1032 | + try: | ||
| 1033 | + await self._soft_remove_all_pods(sid) | ||
| 1034 | + except Exception: # noqa: BLE001 - 软摘失败不阻断扩散,下拍可补 | ||
| 1035 | + logger.exception( | ||
| 1036 | + "routing sunset soft-remove failed: scope=%s", sid) | ||
| 1037 | + | ||
| 969 | # ---- 扩散①:eager 预热——每个生效中的 scope 推池参数 + pod_spec; | 1038 | # ---- 扩散①:eager 预热——每个生效中的 scope 推池参数 + pod_spec; |
| 970 | # 禁用/过期 scope 推 min_idle=0 停预热(与被删 scope 同款自然排空)。 | 1039 | # 禁用/过期 scope 推 min_idle=0 停预热(与被删 scope 同款自然排空)。 |
| 971 | # 带 pod_spec 时 RM 才会落 pod_spec_json/deploy_ver,autoscale 才能 | 1040 | # 带 pod_spec 时 RM 才会落 pod_spec_json/deploy_ver,autoscale 才能 |
| @@ -994,7 +1063,8 @@ class ConfigStore: | |||
| 994 | "new_ver=%s", sid, removed, new_ver, | 1063 | "new_ver=%s", sid, removed, new_ver, |
| 995 | ) | 1064 | ) |
| 996 | 1065 | ||
| 997 | - # ---- 扩散③:被删 scope → 推 min_idle=0(停预热自然排空;存量会话到期止)。 | 1066 | + # ---- 扩散③:被删 scope → bump 日落 + 推 min_idle=0(停预热;存量会话 |
| 1067 | + # 排空窗口内继续原池,过窗回收后 rebind 落新规则)。 | ||
| 998 | # 目标集 = RM 已知 scope ∪ DB 旧 scope − 本批 payload:RM config 键 | 1068 | # 目标集 = RM 已知 scope ∪ DB 旧 scope − 本批 payload:RM config 键 |
| 999 | # 是幻影预热的真源,DB old_scopes 删行后即失忆——只看 DB 的话, | 1069 | # 是幻影预热的真源,DB old_scopes 删行后即失忆——只看 DB 的话, |
| 1000 | # 一次推送失败(滚动重启中断)后该 scope 的 min_idle=0 永远补不上。 | 1070 | # 一次推送失败(滚动重启中断)后该 scope 的 min_idle=0 永远补不上。 |
| @@ -1005,11 +1075,18 @@ class ConfigStore: | |||
| 1005 | except Exception: # noqa: BLE001 - 枚举失败退回 DB 视图 | 1075 | except Exception: # noqa: BLE001 - 枚举失败退回 DB 视图 |
| 1006 | logger.exception("known_rm_scopes failed, falling back to db diff") | 1076 | logger.exception("known_rm_scopes failed, falling back to db diff") |
| 1007 | for sid in sorted((rm_known | set(old_scopes)) - set(scopes_in)): | 1077 | for sid in sorted((rm_known | set(old_scopes)) - set(scopes_in)): |
| 1078 | + # 本批新删的 scope = 最强排除:bump 日落后下方 push 凭 gen lag 戳 | ||
| 1079 | + # 排空纪元(仅首删一次;rm_known 里的陈年旧删每拍只重推 | ||
| 1080 | + # min_idle=0,不重复 bump)。存量会话窗口内继续原池(亲和保持), | ||
| 1081 | + # 过窗回收后 rebind 落新规则。 | ||
| 1082 | + if sid in old_scopes: | ||
| 1083 | + await self._bump_or_warn(sid) | ||
| 1008 | old_tpl = old_templates.get(old_scopes[sid].template_id) if sid in old_scopes else None | 1084 | old_tpl = old_templates.get(old_scopes[sid].template_id) if sid in old_scopes else None |
| 1009 | pool = ( | 1085 | pool = ( |
| 1010 | {**old_tpl.pool_config(), "min_idle_pods": 0} | 1086 | {**old_tpl.pool_config(), "min_idle_pods": 0} |
| 1011 | if old_tpl is not None | 1087 | if old_tpl is not None |
| 1012 | - else {"min_idle_pods": 0, "max_pods": 1, "pod_ttl": 300} | 1088 | + else {"min_idle_pods": 0, "max_pods": 1, "pod_ttl": 300, |
| 1089 | + "session_ttl": 60} | ||
| 1013 | ) | 1090 | ) |
| 1014 | await self._push_or_warn(sid, pool, None) | 1091 | await self._push_or_warn(sid, pool, None) |
| 1015 | 1092 | ||
| @@ -1022,6 +1099,10 @@ class ConfigStore: | |||
| 1022 | "scopes_synced": len(scopes_in), | 1099 | "scopes_synced": len(scopes_in), |
| 1023 | "scopes_deleted": len(set(old_scopes) - set(scopes_in)), | 1100 | "scopes_deleted": len(set(old_scopes) - set(scopes_in)), |
| 1024 | "affected_scopes": affected, | 1101 | "affected_scopes": affected, |
| 1102 | + # 路由性排除日落集(expr 变化 / 生效→失效 / 本批删除):已 bump gen, | ||
| 1103 | + # 存量会话经 RM 排空窗口有界回收(scope 亲和保持,#152) | ||
| 1104 | + "routing_sunset": sorted( | ||
| 1105 | + routing_excluded | (set(old_scopes) - set(scopes_in))), | ||
| 1025 | "wildcard_present": parsed.wildcard, | 1106 | "wildcard_present": parsed.wildcard, |
| 1026 | } | 1107 | } |
| 1027 | 1108 | ||
| @@ -1043,6 +1124,22 @@ class ConfigStore: | |||
| 1043 | scope_id, sorted(pool), | 1124 | scope_id, sorted(pool), |
| 1044 | ) | 1125 | ) |
| 1045 | 1126 | ||
| 1127 | + async def _bump_or_warn(self, scope_id: str) -> None: | ||
| 1128 | + """路由性排除变更的 gen bump(diff 驱动);失败仅告警不中止。 | ||
| 1129 | + | ||
| 1130 | + DB 已提交,raise 只会留下更大的半同步面;丢 bump 的 scope 退化为软界 | ||
| 1131 | + ——存量会话自然结束后 Pod 转 idle、按 pod_ttl 回收(同扩散③自然排空)。 | ||
| 1132 | + """ | ||
| 1133 | + if self._bump_generation is None: | ||
| 1134 | + return | ||
| 1135 | + try: | ||
| 1136 | + await self._bump_generation(scope_id) | ||
| 1137 | + except Exception: # noqa: BLE001 | ||
| 1138 | + logger.exception( | ||
| 1139 | + "routing sunset bump failed (scope=%s) -- old sessions " | ||
| 1140 | + "degrade to natural-drain bound", scope_id, | ||
| 1141 | + ) | ||
| 1142 | + | ||
| 1046 | async def _config_refresh_locked(self) -> dict[str, Any]: | 1143 | async def _config_refresh_locked(self) -> dict[str, Any]: |
| 1047 | """锁内的强制刷新编排:前置日落中间态检查 → 逐 scope bump → push → 全量软摘除。 | 1144 | """锁内的强制刷新编排:前置日落中间态检查 → 逐 scope bump → push → 全量软摘除。 |
| 1048 | 1145 | ||
| @@ -1077,8 +1174,10 @@ class ConfigStore: | |||
| 1077 | # 回收(在集=合法服务中),闸门等它=等会话生命周期(600s 会话 | 1174 | # 回收(在集=合法服务中),闸门等它=等会话生命周期(600s 会话 |
| 1078 | # 15s 连刷 409 连坐 2min+ 实录)。放行语义与 config_sync 闸门跳过 | 1175 | # 15s 连刷 409 连坐 2min+ 实录)。放行语义与 config_sync 闸门跳过 |
| 1079 | # 在集 Pod 一致;且自愈闭环:本次 refresh 的全量软摘除把它 ZREM | 1176 | # 在集 Pod 一致;且自愈闭环:本次 refresh 的全量软摘除把它 ZREM |
| 1080 | - # 出集 → reconcile ≤30s release 入 idle → reclaim stale 免老化 | 1177 | + # 出集 → reconcile ≤30s release 入 idle → reclaim 在排空窗口 |
| 1081 | - # 即刻回收(会话硬切重放置,2026-09-15 决策接受)。 | 1178 | + # (drain_until = 日落 + session_ttl,2026-09-17 优雅排空,此前为 |
| 1179 | + # 免老化即刻硬切)后回收,闸门等待窗相应从 ~分钟内拉长到 | ||
| 1180 | + # ≤ session_ttl + tick;见 docs/feature/2026-09-sunset-drain-window.md。 | ||
| 1082 | if self._sunset_pending is not None: | 1181 | if self._sunset_pending is not None: |
| 1083 | for scope in scopes: | 1182 | for scope in scopes: |
| 1084 | pending = await self._sunset_pending(scope.scope_id) | 1183 | pending = await self._sunset_pending(scope.scope_id) |
| @@ -1097,11 +1196,20 @@ class ConfigStore: | |||
| 1097 | # ① 代次日落(严格:失败上抛中止——该 scope 尚未摘除,重试收敛) | 1196 | # ① 代次日落(严格:失败上抛中止——该 scope 尚未摘除,重试收敛) |
| 1098 | generations[scope.scope_id] = await self._bump_generation(scope.scope_id) | 1197 | generations[scope.scope_id] = await self._bump_generation(scope.scope_id) |
| 1099 | # ② 重推池参数 + pod_spec(值未变,确保 RM 缓存/预热就绪;失败仅告警: | 1198 | # ② 重推池参数 + pod_spec(值未变,确保 RM 缓存/预热就绪;失败仅告警: |
| 1100 | - # 刷新不改配置,scope:config 旧值与欲写值相同,良性) | 1199 | + # 刷新不改配置,scope:config 旧值与欲写值相同,良性)。 |
| 1200 | + # **过期 scope 只日落不保温**(is_active 纯过期判定,时间感知): | ||
| 1201 | + # 推 min_idle=0 + 无 pod_spec——否则一次强制刷新就把已停池按 | ||
| 1202 | + # 模板 min_idle 重新焐热(#154 同族半死态) | ||
| 1101 | template = templates[scope.template_id] | 1203 | template = templates[scope.template_id] |
| 1102 | - await self._push_or_warn( | 1204 | + if not scope.is_active(): |
| 1103 | - scope.scope_id, template.pool_config(), template.deploy_subset() | 1205 | + await self._push_or_warn( |
| 1104 | - ) | 1206 | + scope.scope_id, |
| 1207 | + {**template.pool_config(), "min_idle_pods": 0}, None, | ||
| 1208 | + ) | ||
| 1209 | + else: | ||
| 1210 | + await self._push_or_warn( | ||
| 1211 | + scope.scope_id, template.pool_config(), template.deploy_subset() | ||
| 1212 | + ) | ||
| 1105 | # ③ 候选集全量软摘除(严格:失败上抛;从此刻起老 Pod 不接新会话) | 1213 | # ③ 候选集全量软摘除(严格:失败上抛;从此刻起老 Pod 不接新会话) |
| 1106 | removed = await self._soft_remove_all_pods(scope.scope_id) | 1214 | removed = await self._soft_remove_all_pods(scope.scope_id) |
| 1107 | pods_sunset += len(removed) | 1215 | pods_sunset += len(removed) |
| @@ -61,10 +61,10 @@ class Template: | |||
| 61 | sidecars: list[dict[str, Any]] | None = None | 61 | sidecars: list[dict[str, Any]] | None = None |
| 62 | # deploy 凭证(B 类例外:只影响新 deploy,不日落) | 62 | # deploy 凭证(B 类例外:只影响新 deploy,不日落) |
| 63 | kubeconfig: str | None = None | 63 | kubeconfig: str | None = None |
| 64 | - # 元信息 | 64 | + # 元信息(enabled 已删:模板生命周期 = 存在性,禁用 = 从载荷删除, |
| 65 | + # 见 2026-09-drop-enabled-fields;残留载荷键在解析层防御性剔除) | ||
| 65 | template_name: str = "" | 66 | template_name: str = "" |
| 66 | description: str = "" | 67 | description: str = "" |
| 67 | - enabled: bool = True | ||
| 68 | data: dict[str, Any] = field(default_factory=dict) | 68 | data: dict[str, Any] = field(default_factory=dict) |
| 69 | 69 | ||
| 70 | def __post_init__(self) -> None: | 70 | def __post_init__(self) -> None: |
| @@ -124,10 +124,14 @@ class Template: | |||
| 124 | 124 | ||
| 125 | pod_concurrency 供 RM 的 deploy follower 等待室推导上限(pc-1)—— | 125 | pod_concurrency 供 RM 的 deploy follower 等待室推导上限(pc-1)—— |
| 126 | 不参与 max_pods 判定(per-Pod 容量闸门仍在 SM 侧,红线不变)。 | 126 | 不参与 max_pods 判定(per-Pod 容量闸门仍在 SM 侧,红线不变)。 |
| 127 | + session_ttl 供 RM 日落排空窗口计 deadline(= 日落时刻 + session_ttl, | ||
| 128 | + 2026-09-17 优雅排空)——SM 侧 session 过期仍走自己的 session_expiry | ||
| 129 | + ZSET,两处用途互不依赖。 | ||
| 127 | """ | 130 | """ |
| 128 | return { | 131 | return { |
| 129 | "min_idle_pods": self.min_idle_pods, | 132 | "min_idle_pods": self.min_idle_pods, |
| 130 | "max_pods": self.max_pods, | 133 | "max_pods": self.max_pods, |
| 131 | "pod_ttl": self.pod_ttl, | 134 | "pod_ttl": self.pod_ttl, |
| 132 | "pod_concurrency": self.pod_concurrency, | 135 | "pod_concurrency": self.pod_concurrency, |
| 136 | + "session_ttl": self.session_ttl, | ||
| 133 | } | 137 | } |
| @@ -10,8 +10,9 @@ | |||
| 10 | ``in`` → 值 ∈ values;``not_in`` → 值 ∉ values;空 values ():in 恒假、not_in 恒真; | 10 | ``in`` → 值 ∈ values;``not_in`` → 值 ∉ values;空 values ():in 恒假、not_in 恒真; |
| 11 | - scope 命中 = 空 routing_rules(null/空串/纯空白 → 通配兜底)或表达式为真; | 11 | - scope 命中 = 空 routing_rules(null/空串/纯空白 → 通配兜底)或表达式为真; |
| 12 | - 遍历:scopes 按 (index ASC, scope_id ASC) 排序,first-fit 首个命中即止; | 12 | - 遍历:scopes 按 (index ASC, scope_id ASC) 排序,first-fit 首个命中即止; |
| 13 | -- 引用模板缺失/禁用的 scope 视为不命中,继续落下一个(防御,正常不该发生); | 13 | +- 引用模板缺失的 scope 视为不命中,继续落下一个(防御,正常不该发生); |
| 14 | -- scope 自身 ``enabled=False`` 或 ``expires_at`` 已过期视为不命中,继续落下一个; | 14 | +- scope 自身 ``expires_at`` 已过期视为不命中,继续落下一个 |
| 15 | + (生命周期 = 存在性 + expires_at,布尔开关已删,见 2026-09-drop-enabled-fields); | ||
| 15 | - 无匹配 → ConfigNotFound(503)。 | 16 | - 无匹配 → ConfigNotFound(503)。 |
| 16 | 17 | ||
| 17 | 解析失败(未知字段/裸 `not`/悬空括号/未引号值……)在 config_sync 下发校验时 | 18 | 解析失败(未知字段/裸 `not`/悬空括号/未引号值……)在 config_sync 下发校验时 |
| @@ -90,7 +91,9 @@ class RoutingScopeDef: | |||
| 90 | 91 | ||
| 91 | ``expr`` 是 wire/DB/快照的存储载体(原始字符串,空 = 通配); | 92 | ``expr`` 是 wire/DB/快照的存储载体(原始字符串,空 = 通配); |
| 92 | ``rule`` 是它的解析产物(通配时 None)——二者由构造方(解析入口)保证一致。 | 93 | ``rule`` 是它的解析产物(通配时 None)——二者由构造方(解析入口)保证一致。 |
| 93 | - ``enabled`` / ``expires_at`` 控制生效:禁用或过期的 scope 不参与匹配与预热。 | 94 | + ``expires_at`` 控制生效:过期的 scope 不参与匹配与预热。生命周期 = |
| 95 | + **存在性 + expires_at**(2026-09-drop-enabled-fields):「关」的自然表达 | ||
| 96 | + 是从载荷删除该 scope(扩散③ 优雅排空),布尔开关已删。 | ||
| 94 | """ | 97 | """ |
| 95 | 98 | ||
| 96 | scope_id: str | 99 | scope_id: str |
| @@ -98,14 +101,11 @@ class RoutingScopeDef: | |||
| 98 | template_id: str | 101 | template_id: str |
| 99 | expr: str | 102 | expr: str |
| 100 | rule: BoolNode | None | 103 | rule: BoolNode | None |
| 101 | - enabled: bool = True | ||
| 102 | expires_at: datetime | None = None | 104 | expires_at: datetime | None = None |
| 103 | data: dict[str, Any] | None = None | 105 | data: dict[str, Any] | None = None |
| 104 | 106 | ||
| 105 | def is_active(self, now: datetime | None = None) -> bool: | 107 | def is_active(self, now: datetime | None = None) -> bool: |
| 106 | - """enabled 且未过期才生效(expires_at=None = 永不过期)。""" | 108 | + """未过期才生效(expires_at=None = 永不过期)。""" |
| 107 | - if not self.enabled: | ||
| 108 | - return False | ||
| 109 | if self.expires_at is None: | 109 | if self.expires_at is None: |
| 110 | return True | 110 | return True |
| 111 | current = as_utc_naive(now) if now is not None else utc_now() | 111 | current = as_utc_naive(now) if now is not None else utc_now() |
| @@ -129,7 +129,6 @@ class RoutingScopeDef: | |||
| 129 | "index": self.index, | 129 | "index": self.index, |
| 130 | "template_id": self.template_id, | 130 | "template_id": self.template_id, |
| 131 | "routing_rules": self.expr, | 131 | "routing_rules": self.expr, |
| 132 | - "enabled": self.enabled, | ||
| 133 | "expires_at": ( | 132 | "expires_at": ( |
| 134 | self.expires_at.isoformat() if self.expires_at is not None else None | 133 | self.expires_at.isoformat() if self.expires_at is not None else None |
| 135 | ), | 134 | ), |
| @@ -332,8 +331,14 @@ def parse_routing_expr(text: str) -> BoolNode: | |||
| 332 | return _Parser(text).parse() | 331 | return _Parser(text).parse() |
| 333 | 332 | ||
| 334 | 333 | ||
| 335 | -def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef: | 334 | +def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef | None: |
| 336 | - """wire scope → RoutingScopeDef;template 引用必须在本批模板集内。""" | 335 | + """wire scope → RoutingScopeDef;template 引用必须在本批模板集内。 |
| 336 | + | ||
| 337 | + 残留防御(2026-09-drop-enabled-fields):载荷带 ``enabled`` 真值为 false | ||
| 338 | + → 视为缺席返回 None(调用方剔除 + 告警)——等价于发送方删除该 scope, | ||
| 339 | + 不静默忽略(那会让存量禁用配置升级后重开准入)。``enabled=true`` 或 | ||
| 340 | + 其他真值 → 原样忽略该键。字段本体已删,禁用 = 从载荷删除。 | ||
| 341 | + """ | ||
| 337 | if not isinstance(payload, dict): | 342 | if not isinstance(payload, dict): |
| 338 | raise InvalidParams(f"scope item must be an object, got {payload!r}") | 343 | raise InvalidParams(f"scope item must be an object, got {payload!r}") |
| 339 | scope_id = str(payload.get("scope_id") or "") | 344 | scope_id = str(payload.get("scope_id") or "") |
| @@ -367,11 +372,8 @@ def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef: | |||
| 367 | rule = parse_routing_expr(expr_raw) if expr_raw.strip() else None | 372 | rule = parse_routing_expr(expr_raw) if expr_raw.strip() else None |
| 368 | except InvalidParams as exc: | 373 | except InvalidParams as exc: |
| 369 | raise InvalidParams(f"scope {scope_id!r}: {exc}") from exc | 374 | raise InvalidParams(f"scope {scope_id!r}: {exc}") from exc |
| 370 | - enabled = payload.get("enabled", True) | 375 | + if payload.get("enabled") is False: |
| 371 | - if not isinstance(enabled, bool): | 376 | + return None |
| 372 | - raise InvalidParams( | ||
| 373 | - f"scope {scope_id!r} enabled must be a boolean, got {enabled!r}" | ||
| 374 | - ) | ||
| 375 | try: | 377 | try: |
| 376 | expires_at = parse_datetime( | 378 | expires_at = parse_datetime( |
| 377 | payload.get("expires_at"), field=f"scope {scope_id!r}.expires_at" | 379 | payload.get("expires_at"), field=f"scope {scope_id!r}.expires_at" |
| @@ -390,7 +392,6 @@ def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef: | |||
| 390 | template_id=template_id, | 392 | template_id=template_id, |
| 391 | expr=expr_raw, | 393 | expr=expr_raw, |
| 392 | rule=rule, | 394 | rule=rule, |
| 393 | - enabled=enabled, | ||
| 394 | expires_at=expires_at, | 395 | expires_at=expires_at, |
| 395 | data=data_raw if isinstance(data_raw, dict) else None, | 396 | data=data_raw if isinstance(data_raw, dict) else None, |
| 396 | ) | 397 | ) |
| @@ -411,12 +412,12 @@ def match_scope( | |||
| 411 | *, | 412 | *, |
| 412 | now: datetime | None = None, | 413 | now: datetime | None = None, |
| 413 | ) -> RoutingScopeDef | None: | 414 | ) -> RoutingScopeDef | None: |
| 414 | - """按 (index, scope_id) 序 first-fit;禁用/过期 scope 与模板缺失/禁用跳过。""" | 415 | + """按 (index, scope_id) 序 first-fit;过期 scope 与模板缺失(悬挂引用)跳过。""" |
| 415 | for scope in snapshot.scopes: | 416 | for scope in snapshot.scopes: |
| 416 | if not scope.is_active(now): | 417 | if not scope.is_active(now): |
| 417 | continue | 418 | continue |
| 418 | template = snapshot.templates.get(scope.template_id) | 419 | template = snapshot.templates.get(scope.template_id) |
| 419 | - if template is None or not template.enabled: | 420 | + if template is None: |
| 420 | continue | 421 | continue |
| 421 | if scope.matches(user_id, group_id, bot_id): | 422 | if scope.matches(user_id, group_id, bot_id): |
| 422 | return scope | 423 | return scope |
| @@ -495,9 +496,6 @@ def snapshot_from_json(text: str) -> RoutingSnapshot: | |||
| 495 | expr = "" | 496 | expr = "" |
| 496 | if not isinstance(expr, str): | 497 | if not isinstance(expr, str): |
| 497 | raise ValueError(f"routing_rules must be a string, got {type(expr).__name__}") | 498 | raise ValueError(f"routing_rules must be a string, got {type(expr).__name__}") |
| 498 | - enabled = item.get("enabled", True) | ||
| 499 | - if not isinstance(enabled, bool): | ||
| 500 | - raise ValueError(f"enabled must be a boolean, got {type(enabled).__name__}") | ||
| 501 | expires_at = parse_datetime(item.get("expires_at")) | 499 | expires_at = parse_datetime(item.get("expires_at")) |
| 502 | data_raw = item.get("data") | 500 | data_raw = item.get("data") |
| 503 | if data_raw is not None and not isinstance(data_raw, dict): | 501 | if data_raw is not None and not isinstance(data_raw, dict): |
| @@ -510,7 +508,6 @@ def snapshot_from_json(text: str) -> RoutingSnapshot: | |||
| 510 | template_id=str(item["template_id"]), | 508 | template_id=str(item["template_id"]), |
| 511 | expr=expr, | 509 | expr=expr, |
| 512 | rule=parse_routing_expr(expr) if expr.strip() else None, | 510 | rule=parse_routing_expr(expr) if expr.strip() else None, |
| 513 | - enabled=enabled, | ||
| 514 | expires_at=expires_at, | 511 | expires_at=expires_at, |
| 515 | data=data_raw if isinstance(data_raw, dict) else None, | 512 | data=data_raw if isinstance(data_raw, dict) else None, |
| 516 | ) | 513 | ) |
| @@ -220,6 +220,52 @@ async def test_follower_progress_and_reuse_logged(runtime, monkeypatch, caplog): | |||
| 220 | assert any("acquire follower reuses leader pod" in m for m in messages) | 220 | assert any("acquire follower reuses leader pod" in m for m in messages) |
| 221 | 221 | ||
| 222 | 222 | ||
| 223 | +@requires_lua | ||
| 224 | +async def test_follower_reuse_pops_idle_warm_pod(runtime, monkeypatch): | ||
| 225 | + """follower 接管 leader 热备 Pod 必须摘出 idle 池(2026-09-17 回归)。 | ||
| 226 | + | ||
| 227 | + wangchang 实录:config_refresh 日落 → autoscale 抢锁部署热备 | ||
| 228 | + (idle_flag=True 注册即入 idle)→ 并发请求 acquire 落 follower 接管, | ||
| 229 | + 旧实现直接 return 不摘 idle → 忙 Pod 永占 warm 底数 → autoscale 恒 | ||
| 230 | + skip_warm 不补位(池卡 1 忙 Pod,min_idle 热备永不重建),且 | ||
| 231 | + idle_since 卡在部署时刻(LUA_RELEASE 幂等不刷新)致 pod_ttl 误计。 | ||
| 232 | + """ | ||
| 233 | + await runtime.seed_template(min_idle_pods=1) # sc=3/pc=2 → max_pods=2 | ||
| 234 | + monkeypatch.setattr(rm_orch, "DEPLOY_WAIT_ON_BUSY", 0.01) | ||
| 235 | + cfg = await runtime.rm_state.load_scope_config(SCOPE) | ||
| 236 | + | ||
| 237 | + # 生产时序:leader(=autoscale)先持 deploy 锁;chat 的 acquire 后到落等待室 | ||
| 238 | + lock_key = runtime.rm_state.k.lock_deploy(SCOPE) | ||
| 239 | + assert await runtime.rm_state.try_lock(lock_key, 60, "leader-token") | ||
| 240 | + task = asyncio.create_task(runtime.rm_orchestrator.acquire( | ||
| 241 | + SCOPE, {"ready_timeout": 5}, {"pod_concurrency": 2}, "req-pop", | ||
| 242 | + )) | ||
| 243 | + await asyncio.sleep(0.05) | ||
| 244 | + | ||
| 245 | + # leader 注册热备 Pod(同 autoscale._deploy_and_register 的 idle_flag=True) | ||
| 246 | + await runtime.rm_state.register_pod( | ||
| 247 | + pod_id="leader-pod", scope_id=SCOPE, | ||
| 248 | + pod_sse_url="http://1.2.3.4:8080/sse", pod_ip="1.2.3.4", | ||
| 249 | + namespace="default", deploy_ver=cfg.get("deploy_ver", ""), | ||
| 250 | + deploy_token="tok", idle_flag=True, now=now_ts(), | ||
| 251 | + sse_port=8080, health_path="/health", | ||
| 252 | + ) | ||
| 253 | + result = await asyncio.wait_for(task, timeout=5) | ||
| 254 | + assert result["pod_id"] == "leader-pod" | ||
| 255 | + | ||
| 256 | + # ★ 忙记账:接管即摘出 idle 池 + 清 idle_since(对齐 reuse 分支) | ||
| 257 | + assert await runtime.rm_state.idle_pods(SCOPE) == [] | ||
| 258 | + assert await runtime.rm_state.redis.exists( | ||
| 259 | + runtime.rm_state.k.pod_idle_since("leader-pod")) == 0 | ||
| 260 | + | ||
| 261 | + await runtime.rm_state.unlock(lock_key, "leader-token") # leader 完成 | ||
| 262 | + | ||
| 263 | + # ★ 下游闭环:忙 Pod 不占 warm 底数 → autoscale 补回 1 个新热备 | ||
| 264 | + await runtime.rm_sweeper.autoscale_once() | ||
| 265 | + idle = await runtime.rm_state.idle_pods(SCOPE) | ||
| 266 | + assert len(idle) == 1 and idle[0] != "leader-pod" | ||
| 267 | + | ||
| 268 | + | ||
| 223 | async def test_cleanup_namespace_missing_returns_zero(runtime): | 269 | async def test_cleanup_namespace_missing_returns_zero(runtime): |
| 224 | """cleanup 目标 namespace 不存在(404)→ 容忍为 cleaned=0。 | 270 | """cleanup 目标 namespace 不存在(404)→ 容忍为 cleaned=0。 |
| 225 | 271 | ||
| @@ -4,6 +4,7 @@ eager 预热推送 / A-B 类扩散 / 409 / 红线(场景 M)。""" | |||
| 4 | 4 | ||
| 5 | from __future__ import annotations | 5 | from __future__ import annotations |
| 6 | 6 | ||
| 7 | +import asyncio | ||
| 7 | from datetime import datetime, timedelta, timezone | 8 | from datetime import datetime, timedelta, timezone |
| 8 | 9 | ||
| 9 | import pytest | 10 | import pytest |
| @@ -148,8 +149,13 @@ async def test_config_sync_full_replace_and_validation(runtime): | |||
| 148 | 149 | ||
| 149 | 150 | ||
| 150 | @requires_lua | 151 | @requires_lua |
| 151 | -async def test_config_sync_persists_scope_enabled_expires_and_skips_on_resolve(runtime): | 152 | +async def test_config_sync_scope_lifecycle_presence_and_expiry(runtime): |
| 152 | - """routing_scope.enabled / expires_at 落库;禁用与过期不参与 resolve。""" | 153 | + """生命周期 = 存在性 + expires_at(2026-09-drop-enabled-fields): |
| 154 | + | ||
| 155 | + - enabled=false 的 scope 视为缺席(不落库,resolve 落下一个); | ||
| 156 | + - 过期 scope 落库但不参与 resolve,推 min_idle=0 不预热; | ||
| 157 | + - 生效 scope 正常全量推送。 | ||
| 158 | + """ | ||
| 153 | store = runtime.config_store | 159 | store = runtime.config_store |
| 154 | now = datetime.now(timezone.utc) | 160 | now = datetime.now(timezone.utc) |
| 155 | past = (now - timedelta(hours=1)).isoformat().replace("+00:00", "Z") | 161 | past = (now - timedelta(hours=1)).isoformat().replace("+00:00", "Z") |
| @@ -165,20 +171,21 @@ async def test_config_sync_persists_scope_enabled_expires_and_skips_on_resolve(r | |||
| 165 | ], | 171 | ], |
| 166 | )) | 172 | )) |
| 167 | by_id = {s.scope_id: s for s in await store.list_scopes()} | 173 | by_id = {s.scope_id: s for s in await store.list_scopes()} |
| 168 | - assert by_id["s-off"].enabled is False | 174 | + # enabled=false → 缺席:不落库 |
| 175 | + assert "s-off" not in by_id | ||
| 169 | assert by_id["s-exp"].expires_at is not None | 176 | assert by_id["s-exp"].expires_at is not None |
| 170 | - assert by_id["s-ok"].enabled is True and by_id["s-ok"].expires_at is not None | 177 | + assert by_id["s-ok"].expires_at is not None |
| 171 | 178 | ||
| 172 | scope_id, _ = await store.resolve("u-ok", "g", "b") | 179 | scope_id, _ = await store.resolve("u-ok", "g", "b") |
| 173 | assert scope_id == "s-ok" | 180 | assert scope_id == "s-ok" |
| 174 | scope_id, _ = await store.resolve("other", "g", "b") | 181 | scope_id, _ = await store.resolve("other", "g", "b") |
| 175 | assert scope_id == "fallback" | 182 | assert scope_id == "fallback" |
| 176 | 183 | ||
| 177 | - # 禁用/过期 scope 推 min_idle=0,不带 pod_spec(不预热) | 184 | + # 过期 scope 推 min_idle=0,不带 pod_spec(不预热);生效 scope 带全量 |
| 178 | pushed = {sid: (pool, spec) for sid, pool, spec in runtime.pool_pushes} | 185 | pushed = {sid: (pool, spec) for sid, pool, spec in runtime.pool_pushes} |
| 179 | - assert pushed["s-off"][0]["min_idle_pods"] == 0 | 186 | + assert "s-off" not in pushed # 缺席 = 无推送面 |
| 180 | - assert pushed["s-off"][1] is None | ||
| 181 | assert pushed["s-exp"][0]["min_idle_pods"] == 0 | 187 | assert pushed["s-exp"][0]["min_idle_pods"] == 0 |
| 188 | + assert pushed["s-exp"][1] is None | ||
| 182 | assert pushed["s-ok"][1] is not None | 189 | assert pushed["s-ok"][1] is not None |
| 183 | 190 | ||
| 184 | 191 | ||
| @@ -366,8 +373,178 @@ async def test_config_sync_scope_template_switch_sunsets(runtime): | |||
| 366 | [_scope(SCOPE, "tpl-b")], | 373 | [_scope(SCOPE, "tpl-b")], |
| 367 | )) | 374 | )) |
| 368 | assert await runtime.sm_state.scope_pod_ids(SCOPE) == [] | 375 | assert await runtime.sm_state.scope_pod_ids(SCOPE) == [] |
| 369 | - _, template = await runtime.config_store.resolve("u", "grp", "bot") | 376 | + |
| 370 | - assert template.template_id == "tpl-b" | 377 | + |
| 378 | +# -------------------------------------------------------------- 路由性排除日落(亲和保持,#152) | ||
| 379 | + | ||
| 380 | +@requires_lua | ||
| 381 | +async def test_config_sync_index_reorder_no_bump_no_migrate(runtime): | ||
| 382 | + """index 重排(不排除命中)→ 不 bump 不日落,存量会话零打扰留原池。 | ||
| 383 | + | ||
| 384 | + 路径 A(#152):vip(10)/team(20) 双命中身份旧会话落 vip;team.index 改 5 | ||
| 385 | + 后新会话进 team,旧会话继续原 Pod。 | ||
| 386 | + """ | ||
| 387 | + await runtime.config_store.config_sync(_payload( | ||
| 388 | + [_tpl("tpl-vip"), _tpl("tpl-team")], | ||
| 389 | + [_scope("vip", "tpl-vip", index=10), | ||
| 390 | + _scope("team", "tpl-team", index=20)], | ||
| 391 | + )) | ||
| 392 | + first = await runtime.route("sess_1") | ||
| 393 | + runtime.gen_bumps.clear() | ||
| 394 | + | ||
| 395 | + await runtime.config_store.config_sync(_payload( | ||
| 396 | + [_tpl("tpl-vip"), _tpl("tpl-team")], | ||
| 397 | + [_scope("vip", "tpl-vip", index=10), | ||
| 398 | + _scope("team", "tpl-team", index=5)], | ||
| 399 | + )) | ||
| 400 | + # 重排不触发日落:vip/team 均无 bump、无排空纪元 | ||
| 401 | + assert runtime.gen_bumps == [] | ||
| 402 | + assert await runtime.rm_state.drain_until("vip") is None | ||
| 403 | + assert await runtime.rm_state.drain_until("team") is None | ||
| 404 | + # 旧会话亲和保持:同 Pod 同 scope;新会话(first-fit)进 team | ||
| 405 | + again = await runtime.route("sess_1") | ||
| 406 | + assert again["pod_id"] == first["pod_id"] | ||
| 407 | + binding = await runtime.sm_state.redis.hgetall( | ||
| 408 | + runtime.sm_state.k.session("sess_1")) | ||
| 409 | + assert binding[b"scope_id"] == b"vip" | ||
| 410 | + fresh = await runtime.route("sess_2") | ||
| 411 | + binding2 = await runtime.sm_state.redis.hgetall( | ||
| 412 | + runtime.sm_state.k.session("sess_2")) | ||
| 413 | + assert binding2[b"scope_id"] == b"team" | ||
| 414 | + assert fresh["pod_id"] != first["pod_id"] | ||
| 415 | + | ||
| 416 | + | ||
| 417 | +@requires_lua | ||
| 418 | +async def test_config_sync_routing_rules_change_bumps_with_drain(runtime): | ||
| 419 | + """expr 变化(失权)→ bump gen + 软摘候选集 + 排空纪元生效(时序自证)。 | ||
| 420 | + | ||
| 421 | + drain_until 非空本身证明 bump 先于扩散① push——纪元戳记要求 push 时刻 | ||
| 422 | + gen lag 已形成;反序则永远不戳。 | ||
| 423 | + """ | ||
| 424 | + await runtime.seed_template() | ||
| 425 | + await runtime.route("sess_1") | ||
| 426 | + assert await runtime.sm_state.scope_pod_ids(SCOPE) | ||
| 427 | + runtime.gen_bumps.clear() | ||
| 428 | + | ||
| 429 | + await runtime.config_store.config_sync(_payload( | ||
| 430 | + [_tpl("tpl-1")], | ||
| 431 | + [_scope(SCOPE, "tpl-1", expr="user_id in ('someone-else')")], | ||
| 432 | + )) | ||
| 433 | + assert runtime.gen_bumps == [SCOPE] | ||
| 434 | + # 老代 Pod 被软摘出候选集(新会话不再 first-fit 落上) | ||
| 435 | + assert await runtime.sm_state.scope_pod_ids(SCOPE) == [] | ||
| 436 | + # 排空纪元已戳(bump→push 时序正确的唯一可观测证据) | ||
| 437 | + assert await runtime.rm_state.drain_until(SCOPE) is not None | ||
| 438 | + # 存量会话绑定仍在(窗口内继续原池) | ||
| 439 | + assert await runtime.sm_state.redis.exists( | ||
| 440 | + runtime.sm_state.k.session("sess_1")) | ||
| 441 | + # 同载荷重放(幂等)不重复 bump,纪元仍活跃(首因定窗,不重戳) | ||
| 442 | + runtime.gen_bumps.clear() | ||
| 443 | + await runtime.config_store.config_sync(_payload( | ||
| 444 | + [_tpl("tpl-1")], | ||
| 445 | + [_scope(SCOPE, "tpl-1", expr="user_id in ('someone-else')")], | ||
| 446 | + )) | ||
| 447 | + assert runtime.gen_bumps == [] | ||
| 448 | + assert await runtime.rm_state.drain_until(SCOPE) is not None | ||
| 449 | + | ||
| 450 | + | ||
| 451 | +@requires_lua | ||
| 452 | +async def test_config_sync_disable_scope_bumps_with_drain(runtime): | ||
| 453 | + """残留 enabled=false → 视为缺席 = 删除路径(2026-09-drop-enabled-fields): | ||
| 454 | + bump + 排空纪元;新会话立即兜底。「禁用」的正确表达即从载荷删除。""" | ||
| 455 | + await runtime.config_store.config_sync(_payload( | ||
| 456 | + [_tpl("tpl-1"), _tpl("tpl-fb")], | ||
| 457 | + [_scope(SCOPE, "tpl-1", index=0, expr="group_id in ('grp')"), | ||
| 458 | + _scope("scope-fb", "tpl-fb", index=100)], | ||
| 459 | + )) | ||
| 460 | + old = await runtime.route("sess_1") | ||
| 461 | + runtime.gen_bumps.clear() | ||
| 462 | + | ||
| 463 | + await runtime.config_store.config_sync(_payload( | ||
| 464 | + [_tpl("tpl-1"), _tpl("tpl-fb")], | ||
| 465 | + [_scope(SCOPE, "tpl-1", index=0, expr="group_id in ('grp')", | ||
| 466 | + enabled=False), | ||
| 467 | + _scope("scope-fb", "tpl-fb", index=100)], | ||
| 468 | + )) | ||
| 469 | + assert runtime.gen_bumps == [SCOPE] | ||
| 470 | + assert await runtime.rm_state.drain_until(SCOPE) is not None | ||
| 471 | + # 禁用 scope 推 min_idle=0 停预热(无 pod_spec) | ||
| 472 | + disable_pushes = [p for p in runtime.pool_pushes if p[0] == SCOPE] | ||
| 473 | + assert disable_pushes and disable_pushes[-1][2] is None | ||
| 474 | + assert int(disable_pushes[-1][1]["min_idle_pods"]) == 0 | ||
| 475 | + # 窗口内旧会话继续原 Pod;新会话落兜底 | ||
| 476 | + again = await runtime.route("sess_1") | ||
| 477 | + assert again["pod_id"] == old["pod_id"] | ||
| 478 | + fresh = await runtime.route("sess_2") | ||
| 479 | + binding = await runtime.sm_state.redis.hgetall( | ||
| 480 | + runtime.sm_state.k.session("sess_2")) | ||
| 481 | + assert binding[b"scope_id"] == b"scope-fb" | ||
| 482 | + | ||
| 483 | + | ||
| 484 | +@requires_lua | ||
| 485 | +async def test_config_sync_expires_at_shortened_bumps_with_drain(runtime): | ||
| 486 | + """expires_at 缩短到已过(生效→失效的唯一保留翻转)→ routing_excluded | ||
| 487 | + 仍触发:bump + 排空纪元 + 候选软摘(enabled 删除后的生命周期自证)。""" | ||
| 488 | + now = datetime.now(timezone.utc) | ||
| 489 | + future = (now + timedelta(days=1)).isoformat().replace("+00:00", "Z") | ||
| 490 | + past = (now - timedelta(hours=1)).isoformat().replace("+00:00", "Z") | ||
| 491 | + await runtime.config_store.config_sync(_payload( | ||
| 492 | + [_tpl("tpl-1")], | ||
| 493 | + [_scope(SCOPE, "tpl-1", index=0, expires_at=future)], | ||
| 494 | + )) | ||
| 495 | + await runtime.route("sess_1") | ||
| 496 | + assert await runtime.sm_state.scope_pod_ids(SCOPE) | ||
| 497 | + runtime.gen_bumps.clear() | ||
| 498 | + | ||
| 499 | + await runtime.config_store.config_sync(_payload( | ||
| 500 | + [_tpl("tpl-1")], | ||
| 501 | + [_scope(SCOPE, "tpl-1", index=0, expires_at=past)], | ||
| 502 | + )) | ||
| 503 | + assert runtime.gen_bumps == [SCOPE] | ||
| 504 | + assert await runtime.sm_state.scope_pod_ids(SCOPE) == [] | ||
| 505 | + assert await runtime.rm_state.drain_until(SCOPE) is not None | ||
| 506 | + | ||
| 507 | + | ||
| 508 | +@requires_lua | ||
| 509 | +async def test_config_sync_deleted_scope_bumps_once(runtime): | ||
| 510 | + """删除 scope → bump + 推 min_idle=0(带 session_ttl);重放不重复 bump。""" | ||
| 511 | + await runtime.config_store.config_sync(_payload( | ||
| 512 | + [_tpl("tpl-1"), _tpl("tpl-keep")], | ||
| 513 | + [_scope("s-drop", "tpl-1"), _scope("s-keep", "tpl-keep")], | ||
| 514 | + )) | ||
| 515 | + runtime.gen_bumps.clear() | ||
| 516 | + await runtime.config_store.config_sync(_payload( | ||
| 517 | + [_tpl("tpl-keep")], | ||
| 518 | + [_scope("s-keep", "tpl-keep")], | ||
| 519 | + )) | ||
| 520 | + assert runtime.gen_bumps == ["s-drop"] | ||
| 521 | + drop_pushes = [p for p in runtime.pool_pushes if p[0] == "s-drop"] | ||
| 522 | + assert drop_pushes and drop_pushes[-1][2] is None | ||
| 523 | + assert int(drop_pushes[-1][1]["min_idle_pods"]) == 0 | ||
| 524 | + assert "session_ttl" in drop_pushes[-1][1] | ||
| 525 | + | ||
| 526 | + # 同载荷重放:rm_known 仍含 s-drop(每拍重推 min_idle=0),但不再 bump | ||
| 527 | + runtime.gen_bumps.clear() | ||
| 528 | + runtime.pool_pushes.clear() | ||
| 529 | + await runtime.config_store.config_sync(_payload( | ||
| 530 | + [_tpl("tpl-keep")], | ||
| 531 | + [_scope("s-keep", "tpl-keep")], | ||
| 532 | + )) | ||
| 533 | + assert runtime.gen_bumps == [] | ||
| 534 | + assert any(p[0] == "s-drop" for p in runtime.pool_pushes) | ||
| 535 | + | ||
| 536 | + | ||
| 537 | +@requires_lua | ||
| 538 | +async def test_config_sync_b_class_params_no_bump(runtime): | ||
| 539 | + """B 类纯池参数(session_ttl 等)→ 不 bump(scope/模板定义未动)。""" | ||
| 540 | + await runtime.seed_template() | ||
| 541 | + runtime.gen_bumps.clear() | ||
| 542 | + await runtime.config_store.config_sync(_payload( | ||
| 543 | + [_tpl("tpl-1", session_ttl=99, pod_ttl=120)], | ||
| 544 | + [_scope(SCOPE, "tpl-1")], | ||
| 545 | + )) | ||
| 546 | + assert runtime.gen_bumps == [] | ||
| 547 | + assert await runtime.rm_state.drain_until(SCOPE) is None | ||
| 371 | 548 | ||
| 372 | 549 | ||
| 373 | # -------------------------------------------------------------- 锁 / 409 / 红线 | 550 | # -------------------------------------------------------------- 锁 / 409 / 红线 |
| @@ -417,22 +594,29 @@ async def test_config_sync_a_class_passes_with_current_version_idle_pod(runtime) | |||
| 417 | 2026-09-15 cyz 实测病理:原判据比**新载荷**版本——版本变更时当前代空闲 | 594 | 2026-09-15 cyz 实测病理:原判据比**新载荷**版本——版本变更时当前代空闲 |
| 418 | Pod 必然 ≠ 新版,被误判日落遗留;而它 ver==cfg 受 min_idle 底数保护永不 | 595 | Pod 必然 ≠ 新版,被误判日落遗留;而它 ver==cfg 受 min_idle 底数保护永不 |
| 419 | 回收,等它 = 配置面永久 409(暖 Pod idle 33min ≫ pod_ttl=180s 仍 409)。 | 596 | 回收,等它 = 配置面永久 409(暖 Pod idle 33min ≫ pod_ttl=180s 仍 409)。 |
| 420 | - 修正后基准=当前生效版本 → 放行;老 Pod 落库后被扩散②软摘,reconcile | 597 | + 修正后基准=当前生效版本 → 放行;老 Pod 落库后被扩散②软摘,reclaim 在 |
| 421 | - 转入 idle 池,reclaim 免老化即刻回收(带会话硬切重放置,决策接受)。""" | 598 | + 排空窗口(A 类统一进排空,2026-09-17;session_ttl=1)截止后回收—— |
| 422 | - await runtime.seed_template(min_idle_pods=1) | 599 | + 窗口内不收(带会话 Pod 优雅排空),过窗即收(零复用价值不蹲 pod_ttl)。""" |
| 600 | + await runtime.seed_template(min_idle_pods=1, session_ttl=1) | ||
| 423 | await runtime.route("sess_1") | 601 | await runtime.route("sess_1") |
| 424 | pod_id = (await runtime.sm_state.registered_pods())[0].split(":", 1)[1] | 602 | pod_id = (await runtime.sm_state.registered_pods())[0].split(":", 1)[1] |
| 425 | # 合法中间态:软摘出候选集;版本保持当前值(对照上例,不伪造旧版本号) | 603 | # 合法中间态:软摘出候选集;版本保持当前值(对照上例,不伪造旧版本号) |
| 426 | await runtime.sm_state.redis.zrem(runtime.sm_state.k.scope_pods(SCOPE), pod_id) | 604 | await runtime.sm_state.redis.zrem(runtime.sm_state.k.scope_pods(SCOPE), pod_id) |
| 427 | result = await runtime.config_store.config_sync(_payload( | 605 | result = await runtime.config_store.config_sync(_payload( |
| 428 | - [_tpl("tpl-1", agent_image="agentserver:2.0")], | 606 | + [_tpl("tpl-1", agent_image="agentserver:2.0", session_ttl=1)], |
| 429 | [_scope(SCOPE, "tpl-1")], | 607 | [_scope(SCOPE, "tpl-1")], |
| 430 | )) | 608 | )) |
| 431 | assert result["ok"] is True | 609 | assert result["ok"] is True |
| 432 | - # 落库后闭环:扩散②软摘(已在集外)+ reconcile 转 idle + reclaim 即刻回收 | 610 | + # 落库后闭环:扩散②软摘(已在集外)+ reconcile 转 idle |
| 433 | await runtime.rm_sweeper.reconcile_once() | 611 | await runtime.rm_sweeper.reconcile_once() |
| 612 | + # 排空窗口内:不回收(优雅排空) | ||
| 613 | + await runtime.rm_sweeper.reclaim_once() | ||
| 614 | + assert pod_id in await runtime.rm_state.all_pod_ids() | ||
| 615 | + # 过窗(session_ttl=1)→ 回收 + 纪元收尾释放 surge | ||
| 616 | + await asyncio.sleep(1.2) | ||
| 434 | await runtime.rm_sweeper.reclaim_once() | 617 | await runtime.rm_sweeper.reclaim_once() |
| 435 | assert pod_id not in await runtime.rm_state.all_pod_ids() | 618 | assert pod_id not in await runtime.rm_state.all_pod_ids() |
| 619 | + assert await runtime.rm_state.drain_until(SCOPE) is None | ||
| 436 | 620 | ||
| 437 | 621 | ||
| 438 | @requires_lua | 622 | @requires_lua |
| @@ -1072,7 +1256,6 @@ async def test_new_form_row_with_missing_container_skipped(runtime): | |||
| 1072 | "ready_timeout": 300, "ready_poll_interval": 2, | 1256 | "ready_timeout": 300, "ready_poll_interval": 2, |
| 1073 | "scope_concurrency": 3, "pod_concurrency": 2, | 1257 | "scope_concurrency": 3, "pod_concurrency": 2, |
| 1074 | "pod_ttl": 300, "session_ttl": 60, "min_idle_pods": 0, | 1258 | "pod_ttl": 300, "session_ttl": 60, "min_idle_pods": 0, |
| 1075 | - "enabled": True, | ||
| 1076 | "main_container_id": "c-missing", "sidecar_container_ids": None, | 1259 | "main_container_id": "c-missing", "sidecar_container_ids": None, |
| 1077 | "volumes": None, | 1260 | "volumes": None, |
| 1078 | "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(), | 1261 | "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(), |
| @@ -31,13 +31,12 @@ def _tpl(template_id: str, **overrides) -> Template: | |||
| 31 | 31 | ||
| 32 | 32 | ||
| 33 | def _scope(scope_id: str, index: int, template_id: str = "tpl", | 33 | def _scope(scope_id: str, index: int, template_id: str = "tpl", |
| 34 | - expr: str = "", *, enabled: bool = True, | 34 | + expr: str = "", *, expires_at=None) -> RoutingScopeDef: |
| 35 | - expires_at=None) -> RoutingScopeDef: | ||
| 36 | """expr → RoutingScopeDef(与生产构造路径一致:解析串并同时保存原串)。""" | 35 | """expr → RoutingScopeDef(与生产构造路径一致:解析串并同时保存原串)。""" |
| 37 | return RoutingScopeDef( | 36 | return RoutingScopeDef( |
| 38 | scope_id=scope_id, index=index, template_id=template_id, expr=expr, | 37 | scope_id=scope_id, index=index, template_id=template_id, expr=expr, |
| 39 | rule=parse_routing_expr(expr) if expr.strip() else None, | 38 | rule=parse_routing_expr(expr) if expr.strip() else None, |
| 40 | - enabled=enabled, expires_at=expires_at, | 39 | + expires_at=expires_at, |
| 41 | ) | 40 | ) |
| 42 | 41 | ||
| 43 | 42 | ||
| @@ -218,26 +217,20 @@ def test_match_scope_first_fit_by_index_then_scope_id(): | |||
| 218 | assert match_scope(snap2, "u1", "g", "b").scope_id == "a-tie" | 217 | assert match_scope(snap2, "u1", "g", "b").scope_id == "a-tie" |
| 219 | 218 | ||
| 220 | 219 | ||
| 221 | -def test_match_scope_skips_disabled_and_missing_template(): | 220 | +def test_match_scope_skips_missing_template(): |
| 222 | - """模板缺失/禁用的 scope 不命中,继续落下一个。""" | 221 | + """模板缺失(悬挂引用)的 scope 不命中,继续落下一个。""" |
| 223 | - disabled = _scope("s-off", 0, "tpl-off") | ||
| 224 | missing = _scope("s-miss", 1, "tpl-none") | 222 | missing = _scope("s-miss", 1, "tpl-none") |
| 225 | fallback = _scope("s-ok", 2, "tpl-ok") | 223 | fallback = _scope("s-ok", 2, "tpl-ok") |
| 226 | - snap = build_snapshot( | 224 | + snap = build_snapshot([missing, fallback], [_tpl("tpl-ok")], 1) |
| 227 | - [disabled, missing, fallback], | ||
| 228 | - [_tpl("tpl-off", enabled=False), _tpl("tpl-ok")], | ||
| 229 | - 1, | ||
| 230 | - ) | ||
| 231 | hit = match_scope(snap, "u", "g", "b") | 225 | hit = match_scope(snap, "u", "g", "b") |
| 232 | assert hit is not None and hit.scope_id == "s-ok" | 226 | assert hit is not None and hit.scope_id == "s-ok" |
| 233 | 227 | ||
| 234 | 228 | ||
| 235 | -def test_match_scope_skips_disabled_and_expired_scope(): | 229 | +def test_match_scope_skips_expired_scope(): |
| 236 | - """scope 自身 enabled=False / expires_at 已过期不命中,继续落下一个。""" | 230 | + """scope 自身 expires_at 已过期不命中,继续落下一个。""" |
| 237 | from datetime import datetime, timedelta | 231 | from datetime import datetime, timedelta |
| 238 | 232 | ||
| 239 | now = datetime(2026, 9, 1, 12, 0, 0) | 233 | now = datetime(2026, 9, 1, 12, 0, 0) |
| 240 | - disabled = _scope("s-off", 0, enabled=False) | ||
| 241 | expired = _scope( | 234 | expired = _scope( |
| 242 | "s-exp", 1, expires_at=now - timedelta(seconds=1), | 235 | "s-exp", 1, expires_at=now - timedelta(seconds=1), |
| 243 | ) | 236 | ) |
| @@ -245,7 +238,7 @@ def test_match_scope_skips_disabled_and_expired_scope(): | |||
| 245 | "s-ok", 2, expires_at=now + timedelta(hours=1), | 238 | "s-ok", 2, expires_at=now + timedelta(hours=1), |
| 246 | ) | 239 | ) |
| 247 | snap = build_snapshot( | 240 | snap = build_snapshot( |
| 248 | - [disabled, expired, future], [_tpl("tpl")], 1, | 241 | + [expired, future], [_tpl("tpl")], 1, |
| 249 | ) | 242 | ) |
| 250 | hit = match_scope(snap, "u", "g", "b", now=now) | 243 | hit = match_scope(snap, "u", "g", "b", now=now) |
| 251 | assert hit is not None and hit.scope_id == "s-ok" | 244 | assert hit is not None and hit.scope_id == "s-ok" |
| @@ -265,11 +258,17 @@ def test_match_scope_no_match_returns_none(): | |||
| 265 | 258 | ||
| 266 | 259 | ||
| 267 | def test_has_wildcard_ignores_inactive_scopes(): | 260 | def test_has_wildcard_ignores_inactive_scopes(): |
| 268 | - assert not has_wildcard_scope([_scope("fb", 0, enabled=False)]) | 261 | + from datetime import datetime, timedelta |
| 262 | + | ||
| 263 | + now = datetime(2026, 9, 1, 12, 0, 0) | ||
| 264 | + expired = _scope("fb", 0, expires_at=now - timedelta(seconds=1)) | ||
| 265 | + assert not has_wildcard_scope([expired]) | ||
| 269 | assert has_wildcard_scope([_scope("fb", 0)]) | 266 | assert has_wildcard_scope([_scope("fb", 0)]) |
| 270 | 267 | ||
| 271 | 268 | ||
| 272 | -def test_parse_scope_enabled_and_expires_at(): | 269 | +def test_parse_scope_enabled_false_treated_as_absent(): |
| 270 | + """残留防御(2026-09-drop-enabled-fields):enabled=false → None(调用方 | ||
| 271 | + 剔除);expires_at 正常解析。""" | ||
| 273 | from datetime import datetime | 272 | from datetime import datetime |
| 274 | 273 | ||
| 275 | tids = {"tpl"} | 274 | tids = {"tpl"} |
| @@ -284,17 +283,14 @@ def test_parse_scope_enabled_and_expires_at(): | |||
| 284 | }, | 283 | }, |
| 285 | tids, | 284 | tids, |
| 286 | ) | 285 | ) |
| 287 | - assert scope.enabled is False | 286 | + assert scope is None |
| 288 | - assert scope.expires_at == datetime(2026, 9, 1, 12, 0, 0) | 287 | + # 残留 enabled=true → 原样忽略 |
| 289 | - assert scope.to_payload()["enabled"] is False | 288 | + kept = parse_scope( |
| 290 | - assert scope.to_payload()["expires_at"] == "2026-09-01T12:00:00" | 289 | + {"scope_id": "s", "index": 0, "template_id": "tpl", |
| 291 | - | 290 | + "routing_rules": "", "enabled": True}, |
| 292 | - with pytest.raises(InvalidParams, match="enabled"): | 291 | + tids, |
| 293 | - parse_scope( | 292 | + ) |
| 294 | - {"scope_id": "s", "index": 0, "template_id": "tpl", | 293 | + assert kept is not None and kept.to_payload()["expires_at"] is None |
| 295 | - "enabled": "yes"}, | ||
| 296 | - tids, | ||
| 297 | - ) | ||
| 298 | with pytest.raises(InvalidParams, match="expires_at"): | 294 | with pytest.raises(InvalidParams, match="expires_at"): |
| 299 | parse_scope( | 295 | parse_scope( |
| 300 | {"scope_id": "s", "index": 0, "template_id": "tpl", | 296 | {"scope_id": "s", "index": 0, "template_id": "tpl", |
| @@ -303,23 +299,21 @@ def test_parse_scope_enabled_and_expires_at(): | |||
| 303 | ) | 299 | ) |
| 304 | 300 | ||
| 305 | 301 | ||
| 306 | -def test_snapshot_roundtrips_enabled_expires_at(): | 302 | +def test_snapshot_roundtrips_expires_at(): |
| 307 | from datetime import datetime | 303 | from datetime import datetime |
| 308 | 304 | ||
| 309 | scope = _scope( | 305 | scope = _scope( |
| 310 | - "s", 0, expires_at=datetime(2026, 9, 1, 12, 0, 0), enabled=True, | 306 | + "s", 0, expires_at=datetime(2026, 9, 1, 12, 0, 0), |
| 311 | ) | 307 | ) |
| 312 | text = snapshot_to_json(build_snapshot([scope], [_tpl("tpl")], 7)) | 308 | text = snapshot_to_json(build_snapshot([scope], [_tpl("tpl")], 7)) |
| 313 | restored = snapshot_from_json(text) | 309 | restored = snapshot_from_json(text) |
| 314 | - assert restored.scopes[0].enabled is True | ||
| 315 | assert restored.scopes[0].expires_at == datetime(2026, 9, 1, 12, 0, 0) | 310 | assert restored.scopes[0].expires_at == datetime(2026, 9, 1, 12, 0, 0) |
| 316 | - # 旧快照无字段 → 默认生效 | 311 | + # 旧快照含 enabled 残键/无 expires_at → 忽略/默认生效 |
| 317 | legacy = snapshot_from_json( | 312 | legacy = snapshot_from_json( |
| 318 | '{"ver":1,"templates":{"tpl":{"template_id":"tpl"}},' | 313 | '{"ver":1,"templates":{"tpl":{"template_id":"tpl"}},' |
| 319 | '"scopes":[{"scope_id":"fb","index":0,"template_id":"tpl",' | 314 | '"scopes":[{"scope_id":"fb","index":0,"template_id":"tpl",' |
| 320 | - '"routing_rules":""}]}' | 315 | + '"routing_rules":"","enabled":true}]}' |
| 321 | ) | 316 | ) |
| 322 | - assert legacy.scopes[0].enabled is True | ||
| 323 | assert legacy.scopes[0].expires_at is None | 317 | assert legacy.scopes[0].expires_at is None |
| 324 | 318 | ||
| 325 | 319 | ||
| @@ -398,11 +392,12 @@ def test_template_json_roundtrip(): | |||
| 398 | min_idle_pods=2, data={"k": "v"}) | 392 | min_idle_pods=2, data={"k": "v"}) |
| 399 | restored = template_from_json(template_to_json(t)) | 393 | restored = template_from_json(template_to_json(t)) |
| 400 | assert restored == t | 394 | assert restored == t |
| 401 | - # int/bool 矫正 + 未知键忽略 | 395 | + # int 矫正 + 未知键忽略(enabled 已删,残留键随未知键忽略) |
| 402 | mixed = {**template_to_json(t), "ready_timeout": "90", | 396 | mixed = {**template_to_json(t), "ready_timeout": "90", |
| 403 | "enabled": 0, "unknown_key": "x"} | 397 | "enabled": 0, "unknown_key": "x"} |
| 404 | restored2 = template_from_json(mixed) | 398 | restored2 = template_from_json(mixed) |
| 405 | - assert restored2.ready_timeout == 90 and restored2.enabled is False | 399 | + assert restored2.ready_timeout == 90 |
| 400 | + assert not hasattr(restored2, "enabled") | ||
| 406 | 401 | ||
| 407 | 402 | ||
| 408 | def test_template_from_json_rejects_legacy_flat_snapshot(): | 403 | def test_template_from_json_rejects_legacy_flat_snapshot(): |