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`
- blocking executor `pending` 至少回到过零;不能整个尾段持续积压。
- 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 不能继续增长。
内存判定要求“增长量”和“斜率”同时越界,避免几 MiB allocator/page-cache 噪声在短窗口被外推成很大的每小时斜率。最终报告仍保留实际增长与斜率,人工审查时不能只看 verdict。
@@ -16,7 +16,8 @@
## 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 与有界
blocking executor 指标已经过真实进程短时验证。
- 仓库 Dockerfile 构建的 Linux/cgroup v2 短时探针已证明 CPU、memory、
@@ -24,8 +25,12 @@
- PostgreSQL 16 + RLS 的 1,000 Workspace 真实启动测试,以及 5,000
Workspace 三代替换合成探针已通过。
- Core 已精确钉住 Plugin SDK 提交
`a5a96b302a5808af84bbdd28833ce050176f87e9`。最终验证必须使用包含该提交的
`c7893f8f94e83cb09ebcc98e25490fa699b0fb69`。最终验证必须使用包含该提交的
Core、Plugin Runtime 和 Box Runtime 镜像,不能混用旧 SDK。
- 独立资源复核已经移除 Cloud MCP 每会话 5 秒查询执行绑定的轮询,改由签名目录
投影提交后向一个合并回收任务发布代次变化;工具与资源调用前后仍使用数据库
execution fence。Plugin restart 冷却等待者、MCP 投影回收、消息聚合 buffer/scope
均已纳入健康快照和 soak 归零门禁。
以上结果是进入生产候选验证的前提,不是 SaaS 上线批准。
@@ -126,7 +131,13 @@
一个副本追平不能使另一个副本跳过本地 cache 刷新。
7. 同时使大量 plugin worker 因系统性故障退出,证明 restart launch 全局并发受限、
失败阈值触发 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
@@ -160,6 +171,8 @@ PostgreSQL/pgvector 和代表性 Workspace 配置分布,测量:
- 空 Workspace、活跃 Workspace、每个启用插件和每个 Pro sandbox 的边际
RSS、线程、文件描述符、连接和 PostgreSQL pool 成本;
- 启动、目录重放、批量 reconcile 和故障恢复的耗时与峰值;
- remote MCP 数量增加及目录 generation 批量切换时的数据库 QPS、回收队列和
event-loop lag,确认不存在与 session 数量成比例的空闲轮询;
- 单实例可批准的 Workspace、活跃 Bot、plugin worker 和 sandbox 上限。
容量上限必须写入生产配置与告警,不能只保留在测试报告中。
@@ -173,13 +186,15 @@ PostgreSQL/pgvector 和代表性 Workspace 配置分布,测量:
2. plugin reconcile、依赖准备、调用、崩溃与重启;
3. Dashboard/Embed/平台 WebSocket 建连、突发消息和断连;HTTP Bot 覆盖
高基数 session/idempotency、硬容量拒绝、空闲回收及 callback 堵塞;
remote MCP 覆盖大量空闲连接、批量 generation 切换和合并回收;
4. Box session、文件同步、并发 exec、输出与清理;
5. PostgreSQL pool 接近容量、事务超时和恢复;
6. Core、Plugin Runtime、Box 分别 SIGTERM 和恢复。
最后至少保留 30 分钟无测试流量冷却。任一健康失败、OOM/memory pressure、
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 都不满足条件。
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、外部连接池和插件进程的容量数据;合法活跃租户本身仍会线性占用内存。
SDK 已先行发布到分支提交 `a5a96b302a5808af84bbdd28833ce050176f87e9`,本提交集中的 LangBot
SDK 已先行发布到分支提交 `c7893f8f94e83cb09ebcc98e25490fa699b0fb69`,本提交集中的 LangBot
`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。
- SessionManager 现在维护 Workspace 二级索引和带 revision 校验的最小过期堆。新会话只扫描目标 Workspace 的有界会话集,TTL 回收只消费已过期堆前缀,全局 idle 淘汰使用最小堆;高频命中产生的旧堆项按活跃会话的有界倍数压缩。原实现会在每个攻击者可制造的新 launcher id 上扫描并排序实例全部会话。
- 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 实际拥有对应运行时资源或等待任务时才创建这些对象。
- Runtime RPC 文件同时限制单文件字节数和单连接未消费文件数量,连接关闭时清理连接拥有的临时文件。
- Box Runtime 维护实例与 Workspace 到活跃 session 的二级索引;创建、删除、过期、撤销和 shutdown 共用同一清理路径,避免每个租户 RPC 都扫描实例中的全部 session。
@@ -159,20 +171,20 @@ SDK 已先行发布到分支提交 `a5a96b302a5808af84bbdd28833ce050176f87e9`
| --- | --- |
| LangBot Ruff + `git diff --check` | 通过 |
| Plugin SDK Ruff + `git diff --check` | 通过 |
| LangBot 全量测试(使用远端精确钉住的新 SDK,含 unit/integration/Box/E2E | `2808 passed, 33 skipped` |
| Plugin SDK 全量测试 | `1312 passed` |
| LangBot 全量测试(使用远端精确钉住的新 SDK,含 unit/integration/Box/E2E | `2811 passed, 33 skipped` |
| Plugin SDK 全量测试 | `1317 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` |
| 较早 Core Dockerfile Linux 镜像构建与 `regex` 导入 | 通过,image SHA `8893a14053df`;该镜像使用旧 SDK pin,已失效,最终候选必须重建 |
| `ResourceWarning` + `PytestUnraisableExceptionWarning` 全量门禁 | Core 与 SDK 均通过,并已固化到 pytest 配置 |
| Plugin SDK Box 专项测试(含全局扫描回归保护) | `669 passed` |
| 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 |
| Cloud soak Linux 硬限制短时自检 | 通过;CPU `0.5`、memory+swap `256 MiB`、PID `128` 均从 cgroup v2 读回,冷却尾段 verdict `pass` |
| Core 双阶段历史 churn 资源探针(使用本地新 SDK | audit 通过,`11.275s` |
| Core 5,000 个 populated Workspace 三代容量探针(使用本地新 SDK) | audit 通过,最大替换耗时比 `1.378` |
| Plugin SDK 双阶段资源探针 | audit 通过,`9.037s` |
| Core 双阶段历史 churn 资源探针(使用精确钉住的新 SDK | audit 通过,`11.895s` |
| Core 5,000 个 populated Workspace 三代容量探针(使用精确钉住的新 SDK) | audit 通过,最大替换耗时比 `1.435` |
| Plugin SDK 双阶段资源探针 | audit 通过,`9.407s` |
两个仓库新增了可重复执行的历史 churn 探针,Core 另有 populated Workspace 三代替换探针:
@@ -202,24 +214,24 @@ Core audit 每个阶段执行 10,000 个空 Workspace 的真实 Model/Plugin man
- 20,000 个限流身份:rate-limit container `10,000`
- 10,000 个历史 tasktask record `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
- 三个阶段的活跃 provider/model、pipeline、bot、knowledge 和 MCP registry 均精确维持 `5,000`,不存在按历史 generation 增长。
- 到第三阶段,前两代的 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`
- 初始/第一次替换/第二次替换分别耗时 `1.707s / 2.205s / 2.351s`,最大替换耗时比为 `1.378`,未随历史代次出现 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 增量外推为生产每租户成本。
- event-loop task、线程和文件描述符保持 `1 / 1 / 6`使用远端精确钉住 SDK 的当前复跑中,第三阶段相对第二阶段 RSS 增长 `1,261,568 bytes`tracemalloc current 仅增长 `510 bytes`
- 初始/第一次替换/第二次替换分别耗时 `1.804s / 2.420s / 2.588s`,最大替换耗时比为 `1.435`,未随历史代次出现 CPU 退化。
- 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 创建/删除。第一、第二阶段的保留状态完全一致:
- RPC waiter、stream queue、action task 和活跃 installation binding 均为 `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`
- 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 加载路径:
@@ -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`
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 源码:
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"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",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
+5
View File
@@ -76,8 +76,13 @@ RUNTIME_FAILURE_COUNTER_SUFFIXES = ('.restart_coordinator.circuit_open_total',)
DRAIN_GAUGE_SUFFIXES = (
'.blocking_executor.pending',
'.restart_coordinator.active_launches',
'.restart_coordinator.gate_waiters',
'.restart_coordinator.half_open_probe_inflight',
'.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 = (
'.resources.telemetry_tasks',
@@ -259,6 +259,7 @@ class DirectoryProjectionService:
await session.flush()
await self._reconcile_entitlement_snapshot_set(snapshot)
self._publish_runtime_execution_projection(snapshot.workspaces)
self._record_success()
self._consumer_cursor = snapshot.cursor
@@ -349,10 +350,49 @@ class DirectoryProjectionService:
returned.values(),
requested_workspace_uuids=requested,
)
self._publish_runtime_execution_projection(
returned.values(),
affected_workspace_uuids=requested,
)
if projection_caught_up:
self._record_success()
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(
self,
snapshot: DirectorySnapshot,
+20
View File
@@ -231,6 +231,14 @@ class Application:
'bots': len(getattr(self.platform_mgr, '_bots_by_key', {})),
'pipelines': len(getattr(self.pipeline_mgr, '_pipelines_by_key', {})),
'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(
getattr(
self.plugin_connector,
@@ -245,6 +253,18 @@ class Application:
'mcp_sessions': len(getattr(mcp_loader, '_sessions', {})),
'mcp_host_tasks': len(getattr(mcp_loader, '_hosted_mcp_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):
self.ap = ap
self.buffers = {}
self._buffer_counts_by_scope: dict[
tuple[str, str, int],
int,
] = {}
self.lock = asyncio.Lock()
concurrency = self.ap.instance_config.data.get('concurrency', {})
self.max_buffers = max(int(concurrency.get('pending_queries', 1000)), 1)
@@ -194,12 +198,14 @@ class MessageAggregator:
async with self.lock:
buffer = self.buffers.get(aggregation_key)
if buffer is None:
workspace_buffer_count = sum(
1
for key in self.buffers
if key[0] == execution_context.instance_uuid
and key[1] == execution_context.workspace_uuid
and key[2] == execution_context.placement_generation
scope_key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
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:
bypass_aggregation = True
@@ -210,6 +216,7 @@ class MessageAggregator:
messages=[pending_msg],
)
self.buffers[aggregation_key] = buffer
self._buffer_counts_by_scope[scope_key] = workspace_buffer_count + 1
else:
if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Aggregation buffer ExecutionContext changed for the same key')
@@ -282,6 +289,12 @@ class MessageAggregator:
if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Timer ExecutionContext does not match the aggregation buffer')
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:
return
+64 -20
View File
@@ -233,8 +233,6 @@ class MCPToolCallTimeoutError(TimeoutError):
class RuntimeMCPSession:
"""运行时 MCP 会话"""
_FENCE_POLL_INTERVAL = 5.0
ap: app.Application
server_name: str
@@ -362,15 +360,6 @@ class RuntimeMCPSession:
if binding.instance_uuid != self.execution_context.instance_uuid:
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:
"""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())
shutdown_task = asyncio.create_task(self._shutdown_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(
[shutdown_task, monitor_task, reconnect_task, fence_task],
[shutdown_task, monitor_task, reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
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():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -621,16 +607,13 @@ class RuntimeMCPSession:
else:
shutdown_task = asyncio.create_task(self._shutdown_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(
[shutdown_task, reconnect_task, fence_task],
[shutdown_task, reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
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():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -1550,6 +1533,8 @@ class MCPLoader(loader.ToolLoader):
set[asyncio.Task],
] = {}
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', {})
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
@@ -1691,7 +1676,59 @@ class MCPLoader(loader.ToolLoader):
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]
await self._shutdown_sessions(sessions)
self._scope_generations.pop(scope_key[:2], None)
if self._scope_generations.get(scope_key[:2]) == scope_key[2]:
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(
self,
@@ -1713,6 +1750,13 @@ class MCPLoader(loader.ToolLoader):
async def _reset_runtime_state(self) -> None:
"""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)
self._host_dispatch_tasks.clear()
for task in dispatch_tasks:
@@ -3,6 +3,7 @@ from __future__ import annotations
import datetime
import logging
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import sqlalchemy
@@ -181,6 +182,12 @@ def _delta(
async def test_initial_snapshot_projects_core_owned_rows(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(
application,
_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.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):
@@ -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):
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(
cursor=2,
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)
await service.initialize()
reconcile_execution_projection.reset_mock()
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 provider.snapshot_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):
@@ -875,6 +875,57 @@ class TestMessageAggregatorWorkspaceIsolation:
assert {key[1] for key in agg.buffers} == {'workspace-a', 'workspace-b'}
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
async def test_same_launcher_in_two_bots_uses_separate_buffers(self):
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
async def test_mcp_idle_lifecycle_stops_without_retry_after_generation_bump():
session = _connected_session()
session.server_config.update({'mode': 'remote', 'url': 'https://example.com/mcp'})
session._FENCE_POLL_INTERVAL = 0
session._init_remote_server = AsyncMock()
session.refresh = AsyncMock()
session._assert_execution_active = AsyncMock(
side_effect=[
None,
None,
None,
WorkspaceGenerationMismatchError('generation changed while idle'),
]
async def test_directory_projection_retires_idle_mcp_scope_without_db_poll():
loader = MCPLoader(_app())
sessions = []
for index in range(100):
context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid=f'workspace-{index}',
placement_generation=1,
)
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)
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
# A second projection coalesces into the same worker instead of creating
# one timer or task per Workspace.
loader.reconcile_execution_projection('instance-a', {})
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)
await session._lifecycle_loop_with_retry()
loader.reconcile_execution_projection(
'instance-a',
{'workspace-a': 1},
affected_workspace_uuids={'workspace-a'},
)
await asyncio.sleep(0)
session._init_remote_server.assert_awaited_once_with()
assert session.status == MCPSessionStatus.ERROR
assert session.error_message == 'Workspace execution binding is stale'
assert session._shutdown_event.is_set()
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
@@ -397,6 +397,7 @@ def test_evaluate_gate_detects_restart_circuit_and_stuck_launch() -> None:
0,
**{
f'{prefix}.active_launches': 1,
f'{prefix}.gate_waiters': 2,
f'{prefix}.half_open_probe_inflight': 1,
f'{prefix}.open_remaining_seconds': 60,
f'{prefix}.circuit_open_total': 0,
@@ -406,6 +407,7 @@ def test_evaluate_gate_detects_restart_circuit_and_stuck_launch() -> None:
60,
**{
f'{prefix}.active_launches': 1,
f'{prefix}.gate_waiters': 2,
f'{prefix}.half_open_probe_inflight': 1,
f'{prefix}.open_remaining_seconds': 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('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('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:
prefix = 'body.resources.event_loop'
samples = [
Generated
+2 -2
View File
@@ -2116,7 +2116,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ 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-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2183,7 +2183,7 @@ dev = [
[[package]]
name = "langbot-plugin"
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 = [
{ name = "aiofiles" },
{ name = "aiohttp" },