fix(cloud): eliminate periodic runtime CPU spikes

This commit is contained in:
Junyan Qin
2026-07-29 12:47:53 +08:00
parent aa342d9347
commit e52d6880f5
14 changed files with 372 additions and 66 deletions
+3 -1
View File
@@ -89,7 +89,9 @@ uv run python scripts/cloud_runtime_soak.py \
- event-loop recent p95 不超过 `--max-event-loop-p95-lag-ms` - event-loop recent p95 不超过 `--max-event-loop-p95-lag-ms`
- blocking executor `pending` 至少回到过零;不能整个尾段持续积压。 - blocking executor `pending` 至少回到过零;不能整个尾段持续积压。
- Plugin Runtime restart coordinator 的 active launch、half-open probe 和 - Plugin Runtime restart coordinator 的 active launch、half-open probe 和
circuit open remaining time 必须回到零。 circuit open remaining time 必须回到零`gate_waiters` 必须至少归零一次
- Core 的 MCP projection retirement queue/worker 和 message aggregation
buffer/scope 必须至少归零一次。
- telemetry、QueryPool、MCP host/dispatch、Box creating/closing/background 等临时 gauge 不能继续增长。 - telemetry、QueryPool、MCP host/dispatch、Box creating/closing/background 等临时 gauge 不能继续增长。
内存判定要求“增长量”和“斜率”同时越界,避免几 MiB allocator/page-cache 噪声在短窗口被外推成很大的每小时斜率。最终报告仍保留实际增长与斜率,人工审查时不能只看 verdict。 内存判定要求“增长量”和“斜率”同时越界,避免几 MiB allocator/page-cache 噪声在短窗口被外推成很大的每小时斜率。最终报告仍保留实际增长与斜率,人工审查时不能只看 verdict。
@@ -16,7 +16,8 @@
## 1. 当前已形成的交付基线 ## 1. 当前已形成的交付基线
- LangBot Core Plugin SDK 全量测试、Ruff、`git diff --check` 已通过。 - LangBot Core 全量 `2811 passed, 33 skipped`Plugin SDK 全量
`1317 passed`;两仓 Ruff、`git diff --check` 已通过。
- Plugin Runtime 和 Box Runtime 的公开健康接口、event-loop lag 与有界 - Plugin Runtime 和 Box Runtime 的公开健康接口、event-loop lag 与有界
blocking executor 指标已经过真实进程短时验证。 blocking executor 指标已经过真实进程短时验证。
- 仓库 Dockerfile 构建的 Linux/cgroup v2 短时探针已证明 CPU、memory、 - 仓库 Dockerfile 构建的 Linux/cgroup v2 短时探针已证明 CPU、memory、
@@ -24,8 +25,12 @@
- PostgreSQL 16 + RLS 的 1,000 Workspace 真实启动测试,以及 5,000 - PostgreSQL 16 + RLS 的 1,000 Workspace 真实启动测试,以及 5,000
Workspace 三代替换合成探针已通过。 Workspace 三代替换合成探针已通过。
- Core 已精确钉住 Plugin SDK 提交 - Core 已精确钉住 Plugin SDK 提交
`a5a96b302a5808af84bbdd28833ce050176f87e9`。最终验证必须使用包含该提交的 `c7893f8f94e83cb09ebcc98e25490fa699b0fb69`。最终验证必须使用包含该提交的
Core、Plugin Runtime 和 Box Runtime 镜像,不能混用旧 SDK。 Core、Plugin Runtime 和 Box Runtime 镜像,不能混用旧 SDK。
- 独立资源复核已经移除 Cloud MCP 每会话 5 秒查询执行绑定的轮询,改由签名目录
投影提交后向一个合并回收任务发布代次变化;工具与资源调用前后仍使用数据库
execution fence。Plugin restart 冷却等待者、MCP 投影回收、消息聚合 buffer/scope
均已纳入健康快照和 soak 归零门禁。
以上结果是进入生产候选验证的前提,不是 SaaS 上线批准。 以上结果是进入生产候选验证的前提,不是 SaaS 上线批准。
@@ -126,7 +131,13 @@
一个副本追平不能使另一个副本跳过本地 cache 刷新。 一个副本追平不能使另一个副本跳过本地 cache 刷新。
7. 同时使大量 plugin worker 因系统性故障退出,证明 restart launch 全局并发受限、 7. 同时使大量 plugin worker 因系统性故障退出,证明 restart launch 全局并发受限、
失败阈值触发 Runtime circuit、冷却后只有一个 half-open probe,且 probe 未稳定前 失败阈值触发 Runtime circuit、冷却后只有一个 half-open probe,且 probe 未稳定前
其他 installation 不会继续重启;24 小时门禁必须把 circuit 打开判为失败。 其他 installation 不会继续重启;冷却计时器/状态等待者数量不得超过全局 restart
并发,取消 probe 不得把 circuit 永久卡在 half-open24 小时门禁必须把 circuit
打开或 `gate_waiters` 未归零判为失败。
8. 使用大量空闲 remote MCP session 做 generation 切换,证明目录投影只创建一个
合并回收任务,不产生每 session 周期数据库查询、计时器或同时唤醒;旧 session
最终关闭,`mcp_projection_retirements`
`mcp_projection_reconcile_active` 在冷却期归零。
### V-06:套餐、Box 与 stdio MCP ### V-06:套餐、Box 与 stdio MCP
@@ -160,6 +171,8 @@ PostgreSQL/pgvector 和代表性 Workspace 配置分布,测量:
- 空 Workspace、活跃 Workspace、每个启用插件和每个 Pro sandbox 的边际 - 空 Workspace、活跃 Workspace、每个启用插件和每个 Pro sandbox 的边际
RSS、线程、文件描述符、连接和 PostgreSQL pool 成本; RSS、线程、文件描述符、连接和 PostgreSQL pool 成本;
- 启动、目录重放、批量 reconcile 和故障恢复的耗时与峰值; - 启动、目录重放、批量 reconcile 和故障恢复的耗时与峰值;
- remote MCP 数量增加及目录 generation 批量切换时的数据库 QPS、回收队列和
event-loop lag,确认不存在与 session 数量成比例的空闲轮询;
- 单实例可批准的 Workspace、活跃 Bot、plugin worker 和 sandbox 上限。 - 单实例可批准的 Workspace、活跃 Bot、plugin worker 和 sandbox 上限。
容量上限必须写入生产配置与告警,不能只保留在测试报告中。 容量上限必须写入生产配置与告警,不能只保留在测试报告中。
@@ -173,13 +186,15 @@ PostgreSQL/pgvector 和代表性 Workspace 配置分布,测量:
2. plugin reconcile、依赖准备、调用、崩溃与重启; 2. plugin reconcile、依赖准备、调用、崩溃与重启;
3. Dashboard/Embed/平台 WebSocket 建连、突发消息和断连;HTTP Bot 覆盖 3. Dashboard/Embed/平台 WebSocket 建连、突发消息和断连;HTTP Bot 覆盖
高基数 session/idempotency、硬容量拒绝、空闲回收及 callback 堵塞; 高基数 session/idempotency、硬容量拒绝、空闲回收及 callback 堵塞;
remote MCP 覆盖大量空闲连接、批量 generation 切换和合并回收;
4. Box session、文件同步、并发 exec、输出与清理; 4. Box session、文件同步、并发 exec、输出与清理;
5. PostgreSQL pool 接近容量、事务超时和恢复; 5. PostgreSQL pool 接近容量、事务超时和恢复;
6. Core、Plugin Runtime、Box 分别 SIGTERM 和恢复。 6. Core、Plugin Runtime、Box 分别 SIGTERM 和恢复。
最后至少保留 30 分钟无测试流量冷却。任一健康失败、OOM/memory pressure、 最后至少保留 30 分钟无测试流量冷却。任一健康失败、OOM/memory pressure、
PID limit、blocking executor rejection、超阈值 CPU throttling/event-loop lag、 PID limit、blocking executor rejection、超阈值 CPU throttling/event-loop lag、
冷却尾段内存持续增长或临时 gauge 不回落都判为失败。 冷却尾段内存持续增长,或 Plugin restart `gate_waiters`、MCP 投影回收、
消息聚合 buffer/scope 等临时 gauge 不回落都判为失败。
必须归档: 必须归档:
@@ -17,7 +17,7 @@
1. 在最终 Cloud 部署权限和 cgroup 拓扑下重复 nsjail、namespace 和 delegated cgroup v2 的 CPU、内存、swap、PID、文件句柄验证。本轮一次性 Linux 容器已经证明代码路径可工作,但普通容器和仅 `--privileged` 的 private cgroup namespace 都不满足条件。 1. 在最终 Cloud 部署权限和 cgroup 拓扑下重复 nsjail、namespace 和 delegated cgroup v2 的 CPU、内存、swap、PID、文件句柄验证。本轮一次性 Linux 容器已经证明代码路径可工作,但普通容器和仅 `--privileged` 的 private cgroup namespace 都不满足条件。
2. 为 Cloud Box 提供并验证硬文件系统 quota provider。普通 nsjail bind mount 不能证明总字节数和 inode 硬配额,当前严格 readiness 按设计会失败关闭。 2. 为 Cloud Box 提供并验证硬文件系统 quota provider。普通 nsjail bind mount 不能证明总字节数和 inode 硬配额,当前严格 readiness 按设计会失败关闭。
3. 使用最终生产配置分布继续做容量测试,并据此确定单实例 Workspace placement 上限。本轮真实 PostgreSQL 16 + RLS 启动测试已经覆盖 1,000 个各带 Provider、三类 Model、Bot、Pipeline、KnowledgeBase、MCP 和 Plugin setting 的 Workspace,启动加载耗时和 SQL 次数保持线性;5,000 Workspace 的合成三代替换探针也证明旧运行时会释放。仓库已新增可同时采集 Core/Plugin/Box HTTP、进程树和 cgroup v2 的 24 小时门禁工具,并在受 CPU、memory、swap、PID 硬限制的 Linux 容器中完成短时自检;但最终生产候选拓扑的 24 小时运行仍未执行。测试中的 fake adapter/requester/Plugin handler 仍不能替代真实平台 SDK、外部连接池和插件进程的容量数据;合法活跃租户本身仍会线性占用内存。 3. 使用最终生产配置分布继续做容量测试,并据此确定单实例 Workspace placement 上限。本轮真实 PostgreSQL 16 + RLS 启动测试已经覆盖 1,000 个各带 Provider、三类 Model、Bot、Pipeline、KnowledgeBase、MCP 和 Plugin setting 的 Workspace,启动加载耗时和 SQL 次数保持线性;5,000 Workspace 的合成三代替换探针也证明旧运行时会释放。仓库已新增可同时采集 Core/Plugin/Box HTTP、进程树和 cgroup v2 的 24 小时门禁工具,并在受 CPU、memory、swap、PID 硬限制的 Linux 容器中完成短时自检;但最终生产候选拓扑的 24 小时运行仍未执行。测试中的 fake adapter/requester/Plugin handler 仍不能替代真实平台 SDK、外部连接池和插件进程的容量数据;合法活跃租户本身仍会线性占用内存。
SDK 已先行发布到分支提交 `a5a96b302a5808af84bbdd28833ce050176f87e9`,本提交集中的 LangBot SDK 已先行发布到分支提交 `c7893f8f94e83cb09ebcc98e25490fa699b0fb69`,本提交集中的 LangBot
`pyproject.toml``uv.lock` 已精确钉住该提交。最终镜像仍需按待验证清单记录并核对实际安装版本。 `pyproject.toml``uv.lock` 已精确钉住该提交。最终镜像仍需按待验证清单记录并核对实际安装版本。
## 覆盖范围 ## 覆盖范围
@@ -70,6 +70,18 @@ SDK 已先行发布到分支提交 `a5a96b302a5808af84bbdd28833ce050176f87e9`
- Session、Conversation、WebSocket connection/proxy/message、rate-limit identity、task record/log、telemetry task、vector handle 和 adapter 私有队列均有容量或 LRU/TTL。 - Session、Conversation、WebSocket connection/proxy/message、rate-limit identity、task record/log、telemetry task、vector handle 和 adapter 私有队列均有容量或 LRU/TTL。
- SessionManager 现在维护 Workspace 二级索引和带 revision 校验的最小过期堆。新会话只扫描目标 Workspace 的有界会话集,TTL 回收只消费已过期堆前缀,全局 idle 淘汰使用最小堆;高频命中产生的旧堆项按活跃会话的有界倍数压缩。原实现会在每个攻击者可制造的新 launcher id 上扫描并排序实例全部会话。 - SessionManager 现在维护 Workspace 二级索引和带 revision 校验的最小过期堆。新会话只扫描目标 Workspace 的有界会话集,TTL 回收只消费已过期堆前缀,全局 idle 淘汰使用最小堆;高频命中产生的旧堆项按活跃会话的有界倍数压缩。原实现会在每个攻击者可制造的新 launcher id 上扫描并排序实例全部会话。
- SDK 的 EventContext 和依赖准备锁使用 weak referencegeneration、admission、installation、capability 和 completed-process 状态有上限。 - SDK 的 EventContext 和依赖准备锁使用 weak referencegeneration、admission、installation、capability 和 completed-process 状态有上限。
- Plugin restart circuit 打开期间只有 `max_concurrent_restarts` 个 supervisor
能持有冷却计时器/状态等待,其他 installation 睡眠在同一个 semaphore FIFO
probe 状态变更在调用者取消时仍会完成,避免 half-open 永久占用。
- Box nsjail 启动时只扫描一次 `/proc`,并流式删除遗留 session 目录;不再为每个
遗留目录重复扫描全部进程或先把全部目录物化到内存。
- Box Skill discovery、目录列表和列表正文分别限制扫描 entry、package、返回 entry
与累计文本字节;BFS 使用 deque,拒绝 inode 洪泛导致的 O(N²) 或无界列表。
- Core message aggregation 使用 `(instance, workspace, generation)` O(1) scope
counter 做准入,不再为每个新 launcher 扫描全实例 buffer。
- Cloud remote MCP 的 idle execution fence 不再由每个 session 每 5 秒查询
Workspace/ExecutionState;签名目录投影事务提交后将失效 scope 合并进一个有界
cleanup worker。实际工具/资源调用前后仍保留数据库强校验。
- 空 Workspace 不再预分配 Model generation scope、Plugin installation set 或 Box generation event;只有 Workspace 实际拥有对应运行时资源或等待任务时才创建这些对象。 - 空 Workspace 不再预分配 Model generation scope、Plugin installation set 或 Box generation event;只有 Workspace 实际拥有对应运行时资源或等待任务时才创建这些对象。
- Runtime RPC 文件同时限制单文件字节数和单连接未消费文件数量,连接关闭时清理连接拥有的临时文件。 - Runtime RPC 文件同时限制单文件字节数和单连接未消费文件数量,连接关闭时清理连接拥有的临时文件。
- Box Runtime 维护实例与 Workspace 到活跃 session 的二级索引;创建、删除、过期、撤销和 shutdown 共用同一清理路径,避免每个租户 RPC 都扫描实例中的全部 session。 - Box Runtime 维护实例与 Workspace 到活跃 session 的二级索引;创建、删除、过期、撤销和 shutdown 共用同一清理路径,避免每个租户 RPC 都扫描实例中的全部 session。
@@ -159,20 +171,20 @@ SDK 已先行发布到分支提交 `a5a96b302a5808af84bbdd28833ce050176f87e9`
| --- | --- | | --- | --- |
| LangBot Ruff + `git diff --check` | 通过 | | LangBot Ruff + `git diff --check` | 通过 |
| Plugin SDK Ruff + `git diff --check` | 通过 | | Plugin SDK Ruff + `git diff --check` | 通过 |
| LangBot 全量测试(使用远端精确钉住的新 SDK,含 unit/integration/Box/E2E | `2808 passed, 33 skipped` | | LangBot 全量测试(使用远端精确钉住的新 SDK,含 unit/integration/Box/E2E | `2811 passed, 33 skipped` |
| Plugin SDK 全量测试 | `1312 passed` | | Plugin SDK 全量测试 | `1317 passed` |
| 真实 PostgreSQL 16 + pgvector 迁移/RLS/发布测试(严格资源告警) | `22 passed` | | 真实 PostgreSQL 16 + pgvector 迁移/RLS/发布测试(严格资源告警) | `22 passed` |
| 真实 PostgreSQL 16 + RLS populated Cloud 启动容量 | 500 Workspace `6.178s / CPU 3.026s`;当前 1,000 Workspace 复跑 `12.109s / CPU 5.967s` | | 真实 PostgreSQL 16 + RLS populated Cloud 启动容量 | 500 Workspace `6.178s / CPU 3.026s`;当前 1,000 Workspace 复跑 `12.109s / CPU 5.967s` |
| 较早 Core Dockerfile Linux 镜像构建与 `regex` 导入 | 通过,image SHA `8893a14053df`;该镜像使用旧 SDK pin,已失效,最终候选必须重建 | | 较早 Core Dockerfile Linux 镜像构建与 `regex` 导入 | 通过,image SHA `8893a14053df`;该镜像使用旧 SDK pin,已失效,最终候选必须重建 |
| `ResourceWarning` + `PytestUnraisableExceptionWarning` 全量门禁 | Core 与 SDK 均通过,并已固化到 pytest 配置 | | `ResourceWarning` + `PytestUnraisableExceptionWarning` 全量门禁 | Core 与 SDK 均通过,并已固化到 pytest 配置 |
| Plugin SDK Box 专项测试(含全局扫描回归保护) | `669 passed` | | Plugin SDK Box 专项测试(含全局扫描回归保护) | `669 passed` |
| Docker Compose 渲染、Compose/Kubernetes YAML 解析与 diff 检查 | 通过 | | Docker Compose 渲染、Compose/Kubernetes YAML 解析与 diff 检查 | 通过 |
| Cloud soak 门禁解析/采样/判定单元测试 | `24 passed` | | Cloud soak 门禁解析/采样/判定单元测试 | `25 passed` |
| Core/Plugin SDK event-loop monitor 专项测试 | 两仓各 `7 passed`,包含真实 50 ms scheduler stall | | Core/Plugin SDK event-loop monitor 专项测试 | 两仓各 `7 passed`,包含真实 50 ms scheduler stall |
| Cloud soak Linux 硬限制短时自检 | 通过;CPU `0.5`、memory+swap `256 MiB`、PID `128` 均从 cgroup v2 读回,冷却尾段 verdict `pass` | | Cloud soak Linux 硬限制短时自检 | 通过;CPU `0.5`、memory+swap `256 MiB`、PID `128` 均从 cgroup v2 读回,冷却尾段 verdict `pass` |
| Core 双阶段历史 churn 资源探针(使用本地新 SDK | audit 通过,`11.275s` | | Core 双阶段历史 churn 资源探针(使用精确钉住的新 SDK | audit 通过,`11.895s` |
| Core 5,000 个 populated Workspace 三代容量探针(使用本地新 SDK) | audit 通过,最大替换耗时比 `1.378` | | Core 5,000 个 populated Workspace 三代容量探针(使用精确钉住的新 SDK) | audit 通过,最大替换耗时比 `1.435` |
| Plugin SDK 双阶段资源探针 | audit 通过,`9.037s` | | Plugin SDK 双阶段资源探针 | audit 通过,`9.407s` |
两个仓库新增了可重复执行的历史 churn 探针,Core 另有 populated Workspace 三代替换探针: 两个仓库新增了可重复执行的历史 churn 探针,Core 另有 populated Workspace 三代替换探针:
@@ -202,24 +214,24 @@ Core audit 每个阶段执行 10,000 个空 Workspace 的真实 Model/Plugin man
- 20,000 个限流身份:rate-limit container `10,000` - 20,000 个限流身份:rate-limit container `10,000`
- 10,000 个历史 tasktask record `200` - 10,000 个历史 tasktask record `200`
- 5,000 次 WebSocket churnconversation 与 stream index 均为 `200` - 5,000 次 WebSocket churnconversation 与 stream index 均为 `200`
- event-loop task、线程和文件描述符保持 `1 / 1 / 6`强制加载本地最新 SDK 的当前复跑中,第二阶段相对第一阶段 RSS 增长 `1,605,632 bytes`、tracemalloc current 增长 `344,735 bytes`,总耗时 `11.275s`。Session 淘汰改为 Workspace 索引和最小堆后,同一 audit 工作量相对此前 `16.150s` 明显下降。 - event-loop task、线程和文件描述符保持 `1 / 1 / 6`使用远端精确钉住 SDK 的当前复跑中,第二阶段相对第一阶段 RSS 增长 `2,605,056 bytes`、tracemalloc current 增长 `344,622 bytes`,总耗时 `11.895s`。Session 淘汰改为 Workspace 索引和最小堆后,同一 audit 工作量相对此前 `16.150s` 明显下降。
Populated Workspace audit 为 5,000 个 Workspace 各加载一个 Provider、LLM、Embedding、Rerank、Pipeline、Bot、KnowledgeBase 和 MCP session,然后全部推进两个 generation Populated Workspace audit 为 5,000 个 Workspace 各加载一个 Provider、LLM、Embedding、Rerank、Pipeline、Bot、KnowledgeBase 和 MCP session,然后全部推进两个 generation
- 三个阶段的活跃 provider/model、pipeline、bot、knowledge 和 MCP registry 均精确维持 `5,000`,不存在按历史 generation 增长。 - 三个阶段的活跃 provider/model、pipeline、bot、knowledge 和 MCP registry 均精确维持 `5,000`,不存在按历史 generation 增长。
- 到第三阶段,前两代的 requester、Bot adapter 和 MCP session 各 `10,000` 个全部收到确定性关闭;weak reference 断言旧代对象可被回收。 - 到第三阶段,前两代的 requester、Bot adapter 和 MCP session 各 `10,000` 个全部收到确定性关闭;weak reference 断言旧代对象可被回收。
- event-loop task、线程和文件描述符保持 `1 / 1 / 6`强制加载本地最新 SDK 的当前复跑中,第三阶段相对第二阶段 RSS 增长 `1,277,952 bytes`tracemalloc current 反而减少 `148 bytes` - event-loop task、线程和文件描述符保持 `1 / 1 / 6`使用远端精确钉住 SDK 的当前复跑中,第三阶段相对第二阶段 RSS 增长 `1,261,568 bytes`tracemalloc current 仅增长 `510 bytes`
- 初始/第一次替换/第二次替换分别耗时 `1.707s / 2.205s / 2.351s`,最大替换耗时比为 `1.378`,未随历史代次出现 CPU 退化。 - 初始/第一次替换/第二次替换分别耗时 `1.804s / 2.420s / 2.588s`,最大替换耗时比为 `1.435`,未随历史代次出现 CPU 退化。
- macOS RSS sample 从初始的 `154,271,744` 增至第一阶段 `367,820,800`、第二阶段 `388,726,784` 和第三阶段 `390,004,736 bytes`;第二次替换只比第一次替换增加约 1.22 MiB,但“合法活跃租户资源的线性容量”仍必须作为 placement 容量输入。这里使用轻量 fake adapter/requester,不应把第一阶段约 204 MiB 增量外推为生产每租户成本。 - macOS RSS sample 从初始的 `154,484,736` 增至第一阶段 `368,050,176`、第二阶段 `388,939,776` 和第三阶段 `390,201,344 bytes`;第二次替换只比第一次替换增加约 1.20 MiB,但“合法活跃租户资源的线性容量”仍必须作为 placement 容量输入。这里使用轻量 fake adapter/requester,不应把第一阶段约 204 MiB 增量外推为生产每租户成本。
Plugin SDK audit 每个阶段执行 25,000 次 loopback RPC、5,000 次安装 binding 激活/撤销、10,000 个 Workspace generation 更新和 2,500 次带 Workspace 上下文的 Box session 创建/删除。第一、第二阶段的保留状态完全一致: Plugin SDK audit 每个阶段执行 25,000 次 loopback RPC、5,000 次安装 binding 激活/撤销、10,000 个 Workspace generation 更新和 2,500 次带 Workspace 上下文的 Box session 创建/删除。第一、第二阶段的保留状态完全一致:
- RPC waiter、stream queue、action task 和活跃 installation binding 均为 `0` - RPC waiter、stream queue、action task 和活跃 installation binding 均为 `0`
- installation watermark 为有界的 `5,000`Workspace generation record 为有界的 `10,000`,没有等待者时 generation event 为 `0` - installation watermark 为有界的 `5,000`Workspace generation record 为有界的 `10,000`,没有等待者时 generation event 为 `0`
- generation active task/index、Box session、Box Workspace session index、creating/closing/background task 和 session lock 均为 `0` - generation active task/index、Box session、Box Workspace session index、creating/closing/background task 和 session lock 均为 `0`
- event-loop task 和文件描述符保持 `1 / 7`;当前复跑第二阶段相对第一阶段 RSS peak 增长 `2,654,208 bytes`、tracemalloc current 增长 `289,634 bytes`,总耗时 `9.037s`。耗时增加来自本轮把大协议消息的 JSON/Pydantic、UTF-8 编码、分片和拼接移入有界线程池;25,000 RPC/阶段的合成探针仍约为 5,500 RPC/s,结构状态和第二阶段 tracemalloc 增量保持平稳。 - event-loop task 和文件描述符保持 `1 / 7`;当前复跑第二阶段相对第一阶段 RSS peak 增长 `2,670,592 bytes`、tracemalloc current 增长 `289,746 bytes`,总耗时 `9.407s`。耗时增加来自本轮把大协议消息的 JSON/Pydantic、UTF-8 编码、分片和拼接移入有界线程池;25,000 RPC/阶段的合成探针仍约为 5,300 RPC/s,结构状态和第二阶段 tracemalloc 增量保持平稳。
第二轮反向静态审查另外枚举了 Core 的 50 个显式 task 创建点和 204 个线程、阻塞调用及子进程调用点,以及 SDK 的 28 个显式 task 创建点和 62 个线程、阻塞调用及子进程调用点。显式 task 均具有持有者、完成回调或 `finally` 回收路径;所有生产入口在第一次 `asyncio.to_thread()` 前安装有界默认 executor。Core、Plugin Runtime 和 Box 的公开 `/healthz`Box `/readyz` 亦同)会输出各自的 aggregate runtime/resource counter 和 event-loop lag,供 soak 对比活跃量、pending、累计 capacity rejection 与调度延迟;不输出 debug key、控制 token、租户或插件身份。Plugin Runtime 的授权 debug info 复用同一资源快照,避免公开/私有指标语义漂移。 第二轮反向静态审查另外枚举了 Core 的 50 个显式 task 创建点和 204 个线程、阻塞调用及子进程调用点,以及 SDK 的 28 个显式 task 创建点和 62 个线程、阻塞调用及子进程调用点。第三轮独立复核继续从高基数定时器、目录遍历、准入全表扫描和取消竞态反推,新增关闭了 Plugin restart 冷却唤醒群、MCP idle 数据库轮询、nsjail orphan 的 O(session × process) 启动扫描、message aggregation 的 O(buffer) 准入及 Skill inode/文本列表边界。显式 task 均具有持有者、完成回调或 `finally` 回收路径;所有生产入口在第一次 `asyncio.to_thread()` 前安装有界默认 executor。Core、Plugin Runtime 和 Box 的公开 `/healthz`Box `/readyz` 亦同)会输出各自的 aggregate runtime/resource counter 和 event-loop lag,供 soak 对比活跃量、pending、累计 capacity rejection 与调度延迟;不输出 debug key、控制 token、租户或插件身份。Plugin Runtime 的授权 debug info 复用同一资源快照,避免公开/私有指标语义漂移。
真实 PostgreSQL populated 启动门禁会先通过 release migration 创建最新 schema,再用无 `BYPASSRLS` 的临时 Cloud Runtime 角色启动。每个 Workspace 都含九类代表性资源,测试会走实际的 instance discovery、tenant UoW、启动 binding 快照和 Model/Platform/Pipeline/RAG/MCP/Plugin 加载路径: 真实 PostgreSQL populated 启动门禁会先通过 release migration 创建最新 schema,再用无 `BYPASSRLS` 的临时 Cloud Runtime 角色启动。每个 Workspace 都含九类代表性资源,测试会走实际的 instance discovery、tenant UoW、启动 binding 快照和 Model/Platform/Pipeline/RAG/MCP/Plugin 加载路径:
@@ -230,7 +242,7 @@ Plugin SDK audit 每个阶段执行 25,000 次 loopback RPC、5,000 次安装 bi
探针要求第二阶段的结构状态与第一阶段精确相等,并对第二阶段 RSS 与 tracemalloc 增长设置失败阈值。macOS 的 RSS 来源是 `getrusage` peak,因此这里验证的是峰值增量边界而非“当前 RSS 回落”;最终 Linux 24 小时 soak 仍需采集 current RSS/PSS 和 cgroup `memory.current` 探针要求第二阶段的结构状态与第一阶段精确相等,并对第二阶段 RSS 与 tracemalloc 增长设置失败阈值。macOS 的 RSS 来源是 `getrusage` peak,因此这里验证的是峰值增量边界而非“当前 RSS 回落”;最终 Linux 24 小时 soak 仍需采集 current RSS/PSS 和 cgroup `memory.current`
LangBot 全量测试的 33 个 skip 中,22 个是默认全量运行未提供 PostgreSQL/pgvector 而跳过的集成用例,10 个是未提供 Valkey,另 1 个是可选环境的 collection skip;真实 PostgreSQL 相关路径已由上表单独运行覆盖。Plugin SDK 的 22 个 warning 为现有 Pydantic v2 deprecation 与 aiohttp AppKey 建议;没有失败、未关闭资源或资源上限降级。Core 当前全量产生 192 个既有第三方/兼容性 warning;`ResourceWarning``PytestUnraisableExceptionWarning` 仍由 pytest 配置提升为错误,本轮没有此类泄漏告警。 LangBot 全量测试的 33 个 skip 中,22 个是默认全量运行未提供 PostgreSQL/pgvector 而跳过的集成用例,10 个是未提供 Valkey,另 1 个是可选环境的 collection skip;真实 PostgreSQL 相关路径已由上表单独运行覆盖。Plugin SDK 的 26 个 warning 为现有 Pydantic v2 deprecation 与 aiohttp AppKey 建议;没有失败、未关闭资源或资源上限降级。Core 当前全量产生 193 个既有第三方/兼容性 warning;`ResourceWarning``PytestUnraisableExceptionWarning` 仍由 pytest 配置提升为错误,本轮没有此类泄漏告警。
Linux Runtime 探针使用上述镜像并只读挂载本地最新 SDK 源码: Linux Runtime 探针使用上述镜像并只读挂载本地最新 SDK 源码:
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3", "pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@a5a96b302a5808af84bbdd28833ce050176f87e9", "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@c7893f8f94e83cb09ebcc98e25490fa699b0fb69",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
+5
View File
@@ -76,8 +76,13 @@ RUNTIME_FAILURE_COUNTER_SUFFIXES = ('.restart_coordinator.circuit_open_total',)
DRAIN_GAUGE_SUFFIXES = ( DRAIN_GAUGE_SUFFIXES = (
'.blocking_executor.pending', '.blocking_executor.pending',
'.restart_coordinator.active_launches', '.restart_coordinator.active_launches',
'.restart_coordinator.gate_waiters',
'.restart_coordinator.half_open_probe_inflight', '.restart_coordinator.half_open_probe_inflight',
'.restart_coordinator.open_remaining_seconds', '.restart_coordinator.open_remaining_seconds',
'.resources.runtimes.mcp_projection_retirements',
'.resources.runtimes.mcp_projection_reconcile_active',
'.resources.runtimes.message_aggregation_buffers',
'.resources.runtimes.message_aggregation_scopes',
) )
TRANSIENT_GAUGE_SUFFIXES = ( TRANSIENT_GAUGE_SUFFIXES = (
'.resources.telemetry_tasks', '.resources.telemetry_tasks',
@@ -259,6 +259,7 @@ class DirectoryProjectionService:
await session.flush() await session.flush()
await self._reconcile_entitlement_snapshot_set(snapshot) await self._reconcile_entitlement_snapshot_set(snapshot)
self._publish_runtime_execution_projection(snapshot.workspaces)
self._record_success() self._record_success()
self._consumer_cursor = snapshot.cursor self._consumer_cursor = snapshot.cursor
@@ -349,10 +350,49 @@ class DirectoryProjectionService:
returned.values(), returned.values(),
requested_workspace_uuids=requested, requested_workspace_uuids=requested,
) )
self._publish_runtime_execution_projection(
returned.values(),
affected_workspace_uuids=requested,
)
if projection_caught_up: if projection_caught_up:
self._record_success() self._record_success()
self._consumer_cursor = batch.cursor self._consumer_cursor = batch.cursor
def _publish_runtime_execution_projection(
self,
workspaces: Iterable[DirectoryWorkspace],
*,
affected_workspace_uuids: set[str] | None = None,
) -> None:
"""Retire stale runtime scopes without per-session database polling.
The signed directory transaction is already committed when this hook
runs. Runtime calls still validate the database fence before and after
side effects; this notification only releases idle resources promptly.
"""
tool_manager = getattr(self.ap, 'tool_mgr', None)
mcp_loader = getattr(tool_manager, 'mcp_tool_loader', None)
reconcile = getattr(mcp_loader, 'reconcile_execution_projection', None)
if not callable(reconcile):
return
active_generations = {
workspace.uuid: workspace.execution_generation
for workspace in workspaces
if workspace.status == WorkspaceStatus.ACTIVE.value
}
try:
reconcile(
self.instance_uuid,
active_generations,
affected_workspace_uuids=affected_workspace_uuids,
)
except Exception:
# Runtime retirement is a resource cleanup path, not an execution
# admission boundary. Database-backed call-time fences remain
# authoritative if a local runtime hook fails.
self.ap.logger.exception('Failed to publish the Cloud execution projection to MCP runtimes')
async def _reconcile_entitlement_snapshot_set( async def _reconcile_entitlement_snapshot_set(
self, self,
snapshot: DirectorySnapshot, snapshot: DirectorySnapshot,
+20
View File
@@ -231,6 +231,14 @@ class Application:
'bots': len(getattr(self.platform_mgr, '_bots_by_key', {})), 'bots': len(getattr(self.platform_mgr, '_bots_by_key', {})),
'pipelines': len(getattr(self.pipeline_mgr, '_pipelines_by_key', {})), 'pipelines': len(getattr(self.pipeline_mgr, '_pipelines_by_key', {})),
'knowledge_bases': len(getattr(self.rag_mgr, 'knowledge_bases', {})), 'knowledge_bases': len(getattr(self.rag_mgr, 'knowledge_bases', {})),
'message_aggregation_buffers': len(getattr(self.msg_aggregator, 'buffers', {})),
'message_aggregation_scopes': len(
getattr(
self.msg_aggregator,
'_buffer_counts_by_scope',
{},
)
),
'plugin_installations': len( 'plugin_installations': len(
getattr( getattr(
self.plugin_connector, self.plugin_connector,
@@ -245,6 +253,18 @@ class Application:
'mcp_sessions': len(getattr(mcp_loader, '_sessions', {})), 'mcp_sessions': len(getattr(mcp_loader, '_sessions', {})),
'mcp_host_tasks': len(getattr(mcp_loader, '_hosted_mcp_tasks', ())), 'mcp_host_tasks': len(getattr(mcp_loader, '_hosted_mcp_tasks', ())),
'mcp_dispatch_tasks': len(getattr(mcp_loader, '_host_dispatch_tasks', ())), 'mcp_dispatch_tasks': len(getattr(mcp_loader, '_host_dispatch_tasks', ())),
'mcp_projection_retirements': len(getattr(mcp_loader, '_pending_projection_retirements', ())),
'mcp_projection_reconcile_active': int(
(
projection_task := getattr(
mcp_loader,
'_projection_reconcile_task',
None,
)
)
is not None
and not projection_task.done()
),
} }
) )
+19 -6
View File
@@ -71,6 +71,10 @@ class MessageAggregator:
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
self.ap = ap self.ap = ap
self.buffers = {} self.buffers = {}
self._buffer_counts_by_scope: dict[
tuple[str, str, int],
int,
] = {}
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
concurrency = self.ap.instance_config.data.get('concurrency', {}) concurrency = self.ap.instance_config.data.get('concurrency', {})
self.max_buffers = max(int(concurrency.get('pending_queries', 1000)), 1) self.max_buffers = max(int(concurrency.get('pending_queries', 1000)), 1)
@@ -194,12 +198,14 @@ class MessageAggregator:
async with self.lock: async with self.lock:
buffer = self.buffers.get(aggregation_key) buffer = self.buffers.get(aggregation_key)
if buffer is None: if buffer is None:
workspace_buffer_count = sum( scope_key = (
1 execution_context.instance_uuid,
for key in self.buffers execution_context.workspace_uuid,
if key[0] == execution_context.instance_uuid execution_context.placement_generation,
and key[1] == execution_context.workspace_uuid )
and key[2] == execution_context.placement_generation workspace_buffer_count = self._buffer_counts_by_scope.get(
scope_key,
0,
) )
if len(self.buffers) >= self.max_buffers or workspace_buffer_count >= self.max_buffers_per_workspace: if len(self.buffers) >= self.max_buffers or workspace_buffer_count >= self.max_buffers_per_workspace:
bypass_aggregation = True bypass_aggregation = True
@@ -210,6 +216,7 @@ class MessageAggregator:
messages=[pending_msg], messages=[pending_msg],
) )
self.buffers[aggregation_key] = buffer self.buffers[aggregation_key] = buffer
self._buffer_counts_by_scope[scope_key] = workspace_buffer_count + 1
else: else:
if buffer.execution_context != execution_context: if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Aggregation buffer ExecutionContext changed for the same key') raise ExecutionContextMismatchError('Aggregation buffer ExecutionContext changed for the same key')
@@ -282,6 +289,12 @@ class MessageAggregator:
if buffer.execution_context != execution_context: if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Timer ExecutionContext does not match the aggregation buffer') raise ExecutionContextMismatchError('Timer ExecutionContext does not match the aggregation buffer')
self.buffers.pop(aggregation_key) self.buffers.pop(aggregation_key)
scope_key = aggregation_key[:3]
scope_count = self._buffer_counts_by_scope.get(scope_key, 0)
if scope_count <= 1:
self._buffer_counts_by_scope.pop(scope_key, None)
else:
self._buffer_counts_by_scope[scope_key] = scope_count - 1
if not buffer.messages: if not buffer.messages:
return return
+63 -19
View File
@@ -233,8 +233,6 @@ class MCPToolCallTimeoutError(TimeoutError):
class RuntimeMCPSession: class RuntimeMCPSession:
"""运行时 MCP 会话""" """运行时 MCP 会话"""
_FENCE_POLL_INTERVAL = 5.0
ap: app.Application ap: app.Application
server_name: str server_name: str
@@ -362,15 +360,6 @@ class RuntimeMCPSession:
if binding.instance_uuid != self.execution_context.instance_uuid: if binding.instance_uuid != self.execution_context.instance_uuid:
raise WorkspaceInvariantError('MCP session instance does not match the active Workspace binding') raise WorkspaceInvariantError('MCP session instance does not match the active Workspace binding')
async def _monitor_execution_fence(self) -> None:
"""Poll the placement fence while an MCP transport is idle."""
while not self._shutdown_event.is_set():
await asyncio.sleep(self._FENCE_POLL_INTERVAL)
if self._shutdown_event.is_set():
return
await self._assert_execution_active()
async def _sleep_with_execution_fence(self, delay: float) -> None: async def _sleep_with_execution_fence(self, delay: float) -> None:
"""Back off without reconnecting after the captured placement expires.""" """Back off without reconnecting after the captured placement expires."""
@@ -576,16 +565,13 @@ class RuntimeMCPSession:
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health()) monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
shutdown_task = asyncio.create_task(self._shutdown_event.wait()) shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait()) reconnect_task = asyncio.create_task(self._reconnect_event.wait())
fence_task = asyncio.create_task(self._monitor_execution_fence())
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
[shutdown_task, monitor_task, reconnect_task, fence_task], [shutdown_task, monitor_task, reconnect_task],
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
for task in pending: for task in pending:
task.cancel() task.cancel()
await asyncio.gather(*pending, return_exceptions=True) await asyncio.gather(*pending, return_exceptions=True)
if fence_task in done and not self._shutdown_event.is_set():
fence_task.result()
if reconnect_task in done and not self._shutdown_event.is_set(): if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear() self._reconnect_event.clear()
self.ap.logger.info( self.ap.logger.info(
@@ -621,16 +607,13 @@ class RuntimeMCPSession:
else: else:
shutdown_task = asyncio.create_task(self._shutdown_event.wait()) shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait()) reconnect_task = asyncio.create_task(self._reconnect_event.wait())
fence_task = asyncio.create_task(self._monitor_execution_fence())
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
[shutdown_task, reconnect_task, fence_task], [shutdown_task, reconnect_task],
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
for task in pending: for task in pending:
task.cancel() task.cancel()
await asyncio.gather(*pending, return_exceptions=True) await asyncio.gather(*pending, return_exceptions=True)
if fence_task in done and not self._shutdown_event.is_set():
fence_task.result()
if reconnect_task in done and not self._shutdown_event.is_set(): if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear() self._reconnect_event.clear()
self.ap.logger.info( self.ap.logger.info(
@@ -1550,6 +1533,8 @@ class MCPLoader(loader.ToolLoader):
set[asyncio.Task], set[asyncio.Task],
] = {} ] = {}
self._host_dispatch_tasks: set[asyncio.Task] = set() self._host_dispatch_tasks: set[asyncio.Task] = set()
self._pending_projection_retirements: set[tuple[str, str, int]] = set()
self._projection_reconcile_task: asyncio.Task[None] | None = None
config = getattr(getattr(ap, 'instance_config', None), 'data', {}) config = getattr(getattr(ap, 'instance_config', None), 'data', {})
mcp_config = config.get('mcp', {}) if isinstance(config, dict) else {} mcp_config = config.get('mcp', {}) if isinstance(config, dict) else {}
raw_lifecycle_concurrency = mcp_config.get('lifecycle_concurrency', 16) if isinstance(mcp_config, dict) else 16 raw_lifecycle_concurrency = mcp_config.get('lifecycle_concurrency', 16) if isinstance(mcp_config, dict) else 16
@@ -1691,8 +1676,60 @@ class MCPLoader(loader.ToolLoader):
keys = tuple(self._session_keys_by_scope.pop(scope_key, ())) keys = tuple(self._session_keys_by_scope.pop(scope_key, ()))
sessions = [session for key in keys if (session := self._sessions.pop(key, None)) is not None] sessions = [session for key in keys if (session := self._sessions.pop(key, None)) is not None]
await self._shutdown_sessions(sessions) await self._shutdown_sessions(sessions)
if self._scope_generations.get(scope_key[:2]) == scope_key[2]:
self._scope_generations.pop(scope_key[:2], None) self._scope_generations.pop(scope_key[:2], None)
def reconcile_execution_projection(
self,
instance_uuid: str,
active_generations: typing.Mapping[str, int],
*,
affected_workspace_uuids: typing.Iterable[str] | None = None,
) -> None:
"""Queue stale MCP scopes for one coalesced, bounded cleanup worker."""
affected = None if affected_workspace_uuids is None else set(affected_workspace_uuids)
for workspace_scope, generation in tuple(self._scope_generations.items()):
scoped_instance_uuid, workspace_uuid = workspace_scope
if scoped_instance_uuid != instance_uuid:
continue
if affected is not None and workspace_uuid not in affected:
continue
if active_generations.get(workspace_uuid) == generation:
continue
self._pending_projection_retirements.add((*workspace_scope, generation))
if not self._pending_projection_retirements:
return
if self._projection_reconcile_task is not None and not self._projection_reconcile_task.done():
return
task = asyncio.create_task(
self._drain_projection_retirements(),
name='mcp-projection-reconcile',
)
self._projection_reconcile_task = task
task.add_done_callback(self._projection_reconcile_done)
async def _drain_projection_retirements(self) -> None:
while self._pending_projection_retirements:
scope_key = next(iter(self._pending_projection_retirements))
self._pending_projection_retirements.discard(scope_key)
await self._retire_runtime_scope(scope_key)
def _projection_reconcile_done(
self,
completed: asyncio.Task[None],
) -> None:
if self._projection_reconcile_task is completed:
self._projection_reconcile_task = None
if completed.cancelled():
return
exception = completed.exception()
if exception is not None:
self.ap.logger.error(
f'MCP projection reconciliation failed: {exception}',
)
async def _observe_execution_context( async def _observe_execution_context(
self, self,
context: ExecutionContext, context: ExecutionContext,
@@ -1713,6 +1750,13 @@ class MCPLoader(loader.ToolLoader):
async def _reset_runtime_state(self) -> None: async def _reset_runtime_state(self) -> None:
"""Cancel host tasks and close sessions before reload or shutdown.""" """Cancel host tasks and close sessions before reload or shutdown."""
projection_task = self._projection_reconcile_task
self._projection_reconcile_task = None
self._pending_projection_retirements.clear()
if projection_task is not None and not projection_task.done():
projection_task.cancel()
await asyncio.gather(projection_task, return_exceptions=True)
dispatch_tasks = tuple(self._host_dispatch_tasks) dispatch_tasks = tuple(self._host_dispatch_tasks)
self._host_dispatch_tasks.clear() self._host_dispatch_tasks.clear()
for task in dispatch_tasks: for task in dispatch_tasks:
@@ -3,6 +3,7 @@ from __future__ import annotations
import datetime import datetime
import logging import logging
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock
import pytest import pytest
import sqlalchemy import sqlalchemy
@@ -181,6 +182,12 @@ def _delta(
async def test_initial_snapshot_projects_core_owned_rows(projection_context): async def test_initial_snapshot_projects_core_owned_rows(projection_context):
application, session_factory = projection_context application, session_factory = projection_context
reconcile_execution_projection = Mock()
application.tool_mgr = SimpleNamespace(
mcp_tool_loader=SimpleNamespace(
reconcile_execution_projection=reconcile_execution_projection,
)
)
service = DirectoryProjectionService( service = DirectoryProjectionService(
application, application,
_Provider([_snapshot(1)]), _Provider([_snapshot(1)]),
@@ -212,6 +219,11 @@ async def test_initial_snapshot_projects_core_owned_rows(projection_context):
assert state is not None assert state is not None
assert state.cursor == 1 assert state.cursor == 1
assert state.snapshot_coverage_cursor == 1 assert state.snapshot_coverage_cursor == 1
reconcile_execution_projection.assert_called_once_with(
INSTANCE_UUID,
{WORKSPACE_UUID: 1},
affected_workspace_uuids=None,
)
async def test_same_cursor_equivocation_and_rollback_fail_closed(projection_context): async def test_same_cursor_equivocation_and_rollback_fail_closed(projection_context):
@@ -297,6 +309,12 @@ async def test_archived_and_absent_workspaces_are_execution_fenced(projection_co
async def test_event_poll_fetches_workspace_delta_and_records_receipt(projection_context): async def test_event_poll_fetches_workspace_delta_and_records_receipt(projection_context):
application, session_factory = projection_context application, session_factory = projection_context
reconcile_execution_projection = Mock()
application.tool_mgr = SimpleNamespace(
mcp_tool_loader=SimpleNamespace(
reconcile_execution_projection=reconcile_execution_projection,
)
)
event = DirectoryEvent( event = DirectoryEvent(
cursor=2, cursor=2,
uuid='40000000-0000-0000-0000-000000000001', uuid='40000000-0000-0000-0000-000000000001',
@@ -320,6 +338,7 @@ async def test_event_poll_fetches_workspace_delta_and_records_receipt(projection
) )
service = DirectoryProjectionService(application, provider, INSTANCE_UUID) service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize() await service.initialize()
reconcile_execution_projection.reset_mock()
await service.sync_once() await service.sync_once()
@@ -335,6 +354,11 @@ async def test_event_poll_fetches_workspace_delta_and_records_receipt(projection
assert inbox.applied_at is not None assert inbox.applied_at is not None
assert provider.snapshot_calls == 1 assert provider.snapshot_calls == 1
assert provider.delta_calls == 1 assert provider.delta_calls == 1
reconcile_execution_projection.assert_called_once_with(
INSTANCE_UUID,
{WORKSPACE_UUID: 1},
affected_workspace_uuids={WORKSPACE_UUID},
)
async def test_directory_delta_does_not_skip_unfetched_event_cursors(projection_context): async def test_directory_delta_does_not_skip_unfetched_event_cursors(projection_context):
@@ -875,6 +875,57 @@ class TestMessageAggregatorWorkspaceIsolation:
assert {key[1] for key in agg.buffers} == {'workspace-a', 'workspace-b'} assert {key[1] for key in agg.buffers} == {'workspace-a', 'workspace-b'}
await agg.flush_all() await agg.flush_all()
@pytest.mark.asyncio
async def test_new_buffer_uses_scope_counter_without_global_scan(self):
class NoGlobalIterationDict(dict):
def __iter__(self):
raise AssertionError('aggregation admission scanned all buffers')
def items(self):
raise AssertionError('aggregation admission scanned all buffers')
def values(self):
raise AssertionError('aggregation admission scanned all buffers')
app = make_aggregator_app()
enable_aggregation(app)
agg = get_aggregator_module().MessageAggregator(app)
agg.max_buffers = 2_000
agg.max_buffers_per_workspace = 2_000
existing = {
(
'instance-test',
f'workspace-{index}',
1,
'bot',
'pipeline',
'person',
index,
): object()
for index in range(1_000)
}
agg.buffers = NoGlobalIterationDict(existing)
agg._buffer_counts_by_scope = {key[:3]: 1 for key in existing}
context = execution_context(
'workspace-target',
pipeline_uuid='test-pipeline',
)
await agg.add_message(**scoped_message_kwargs(context))
key = aggregation_key(
context,
pipeline_uuid='test-pipeline',
)
assert key in agg.buffers
assert agg._buffer_counts_by_scope[key[:3]] == 1
timer_task = agg.buffers[key].timer_task
assert timer_task is not None
timer_task.cancel()
await asyncio.gather(timer_task, return_exceptions=True)
await agg._flush_buffer(key, context)
assert key[:3] not in agg._buffer_counts_by_scope
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_same_launcher_in_two_bots_uses_separate_buffers(self): async def test_same_launcher_in_two_bots_uses_separate_buffers(self):
app = make_aggregator_app() app = make_aggregator_app()
+63 -18
View File
@@ -558,27 +558,72 @@ async def test_mcp_resource_cache_is_not_served_to_stale_generation():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_idle_lifecycle_stops_without_retry_after_generation_bump(): async def test_directory_projection_retires_idle_mcp_scope_without_db_poll():
session = _connected_session() loader = MCPLoader(_app())
session.server_config.update({'mode': 'remote', 'url': 'https://example.com/mcp'}) sessions = []
session._FENCE_POLL_INTERVAL = 0 for index in range(100):
session._init_remote_server = AsyncMock() context = ExecutionContext(
session.refresh = AsyncMock() instance_uuid='instance-a',
session._assert_execution_active = AsyncMock( workspace_uuid=f'workspace-{index}',
side_effect=[ placement_generation=1,
None,
None,
None,
WorkspaceGenerationMismatchError('generation changed while idle'),
]
) )
session = RuntimeMCPSession(
f'server-{index}',
{'uuid': f'srv-{index}', 'mode': 'remote'},
True,
loader.ap,
context,
)
session.shutdown = AsyncMock()
loader._register_session(context, session.server_name, session)
sessions.append(session)
await session._lifecycle_loop_with_retry() loader.reconcile_execution_projection('instance-a', {})
reconcile_task = loader._projection_reconcile_task
assert reconcile_task is not None
assert len(loader._pending_projection_retirements) == 100
session._init_remote_server.assert_awaited_once_with() # A second projection coalesces into the same worker instead of creating
assert session.status == MCPSessionStatus.ERROR # one timer or task per Workspace.
assert session.error_message == 'Workspace execution binding is stale' loader.reconcile_execution_projection('instance-a', {})
assert session._shutdown_event.is_set() assert loader._projection_reconcile_task is reconcile_task
await asyncio.wait_for(reconcile_task, timeout=1)
assert loader.sessions == {}
assert loader._scope_generations == {}
assert loader._pending_projection_retirements == set()
assert sum(session.shutdown.await_count for session in sessions) == 100
loader.ap.workspace_service.get_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
async def test_directory_projection_keeps_matching_and_unaffected_mcp_scopes():
loader = MCPLoader(_app())
matching = _connected_session()
other_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-b',
placement_generation=1,
)
unaffected = _connected_session(
name='other',
uuid='srv-2',
execution_context=other_context,
)
_register_session(loader, matching)
_register_session(loader, unaffected)
loader.reconcile_execution_projection(
'instance-a',
{'workspace-a': 1},
affected_workspace_uuids={'workspace-a'},
)
await asyncio.sleep(0)
assert loader.get_session(TEST_EXECUTION_CONTEXT, 'docs') is matching
assert loader.get_session(other_context, 'other') is unaffected
assert loader._projection_reconcile_task is None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -397,6 +397,7 @@ def test_evaluate_gate_detects_restart_circuit_and_stuck_launch() -> None:
0, 0,
**{ **{
f'{prefix}.active_launches': 1, f'{prefix}.active_launches': 1,
f'{prefix}.gate_waiters': 2,
f'{prefix}.half_open_probe_inflight': 1, f'{prefix}.half_open_probe_inflight': 1,
f'{prefix}.open_remaining_seconds': 60, f'{prefix}.open_remaining_seconds': 60,
f'{prefix}.circuit_open_total': 0, f'{prefix}.circuit_open_total': 0,
@@ -406,6 +407,7 @@ def test_evaluate_gate_detects_restart_circuit_and_stuck_launch() -> None:
60, 60,
**{ **{
f'{prefix}.active_launches': 1, f'{prefix}.active_launches': 1,
f'{prefix}.gate_waiters': 2,
f'{prefix}.half_open_probe_inflight': 1, f'{prefix}.half_open_probe_inflight': 1,
f'{prefix}.open_remaining_seconds': 1, f'{prefix}.open_remaining_seconds': 1,
f'{prefix}.circuit_open_total': 1, f'{prefix}.circuit_open_total': 1,
@@ -422,10 +424,43 @@ def test_evaluate_gate_detects_restart_circuit_and_stuck_launch() -> None:
assert any('circuit_open_total by 1' in failure for failure in result.failures) assert any('circuit_open_total by 1' in failure for failure in result.failures)
assert any('active_launches above zero' in failure for failure in result.failures) assert any('active_launches above zero' in failure for failure in result.failures)
assert any('gate_waiters above zero' in failure for failure in result.failures)
assert any('half_open_probe_inflight above zero' in failure for failure in result.failures) assert any('half_open_probe_inflight above zero' in failure for failure in result.failures)
assert any('open_remaining_seconds above zero' in failure for failure in result.failures) assert any('open_remaining_seconds above zero' in failure for failure in result.failures)
def test_evaluate_gate_detects_stuck_mcp_projection_cleanup() -> None:
prefix = 'body.resources.runtimes'
state = _state(
'endpoint',
[
_sample(
0,
**{
f'{prefix}.mcp_projection_retirements': 3,
f'{prefix}.mcp_projection_reconcile_active': 1,
},
),
_sample(
60,
**{
f'{prefix}.mcp_projection_retirements': 1,
f'{prefix}.mcp_projection_reconcile_active': 1,
},
),
],
)
result = soak.evaluate_gate(
[state],
analysis_start_seconds=0,
thresholds=_thresholds(),
)
assert any('mcp_projection_retirements above zero' in failure for failure in result.failures)
assert any('mcp_projection_reconcile_active above zero' in failure for failure in result.failures)
def test_evaluate_gate_detects_event_loop_stall_and_sustained_lag() -> None: def test_evaluate_gate_detects_event_loop_stall_and_sustained_lag() -> None:
prefix = 'body.resources.event_loop' prefix = 'body.resources.event_loop'
samples = [ samples = [
Generated
+2 -2
View File
@@ -2116,7 +2116,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=a5a96b302a5808af84bbdd28833ce050176f87e9" }, { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=c7893f8f94e83cb09ebcc98e25490fa699b0fb69" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2183,7 +2183,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.4.18" version = "0.4.18"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=a5a96b302a5808af84bbdd28833ce050176f87e9#a5a96b302a5808af84bbdd28833ce050176f87e9" } source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=c7893f8f94e83cb09ebcc98e25490fa699b0fb69#c7893f8f94e83cb09ebcc98e25490fa699b0fb69" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },