Files
geMoldInsight/docs/topics/performance/OCC_THROUGHPUT.md
T
cjw 2fd1b3da21 🐛 fix(deploy): 修干净机器首次构建两处必挂——.dockerignore 排除 deploy/ + celery 并行构建依赖
部署机首次 docker compose up -d 实测暴露:

- .dockerignore 自"重写独立dockerfile"起排除整个 deploy/,而
  Dockerfile.frontend COPY deploy/nginx/frontend.conf、Dockerfile.moldinsight
  COPY deploy/requirements-*.txt → COPY not found。历史一直有旧镜像兜底未暴露;
  BuildKit 不支持重包含被排除目录的子文件,直接移除该行
- Dockerfile.celery FROM gemold-backend:latest 在 compose 并行构建下引用
  尚不存在的本地镜像必挂 → 删除 Dockerfile.celery,moldinsight-celery 改为
  与 API 服务同一 build 声明 + 同一 gemold-backend:latest tag(compose 去重
  只构建一次),celery 仅以 command: 覆盖启动 worker,参数语义不变
- build.sh/.bat 移除 gemold-celery 构建步骤;OPERATIONS / OCC_THROUGHPUT /
  TECH_DEBT / .env.example 的 Dockerfile.celery 指向同步改写;STATUS 补录

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:18:44 +08:00

87 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OCC 处理吞吐与隔离方案设计(OCC_THROUGHPUT)
> 文档定位:**OCC(PythonOCC)处理吞吐与故障隔离的专题设计文档**。
> 本文回答"OCC 串行瓶颈与超时线程泄漏的根治路线";现状事实以 [../../../STATUS.md](../../../STATUS.md) 为准,债务归属 [../../TECH_DEBT.md](../../TECH_DEBT.md) D10。
> 2026-09-17 随批次 4 产出:**方案设计先行**,短期项(方案 A)零代码可用,中期的接口演进与进程池实施留待后续批次。
---
## 1. 现状与硬约束
### 1.1 运行时事实
- 所有 OCC 操作(STEP 解析 / 布尔运算 / 三角化 / 倒扣检测 / STEP 转换等)统一经 [processing_service.py](../../../src/moldinsight/services/processing_service.py) 的 `run_occ(op_name, payload)` 投入 **常驻 OCC 工作进程池**([occ_process_pool.py](../../../src/moldinsight/services/occ_process_pool.py),默认 1 进程 = 1 串行通道)——OCC 非线程安全,通道内串行是正确性要求,不是实现偷懒。
- 操作在 [occ_worker.py](../../../src/moldinsight/core/occ_worker.py) 以注册表形式实现(parse_stp / generate_mesh / generate_cavity / analyze_mold_design / detect_undercuts / convert_component_step 等);输入输出全部是**文件路径 + 普通字典**,TopoDS_Shape 不跨进程传输(方案 B 硬约束,见 §1.2)。
- Celery worker 为 prefork 模式,`processing_service` 是模块级单例:**每个 worker 子进程各持一个常驻 OCC 工作进程**。因此 OCC 并行度 = worker 子进程数,与 web 进程数无关(web 侧 `run_occ` 仅服务于轻量同步调用,如倒扣检测)。
- 超时/崩溃 = `terminate()` 该工作进程并换新补位——进程边界干净回收,无线程滞留。
### 1.2 硬约束(决定方案边界)
| 约束 | 含义 |
|---|---|
| OCC 非线程安全 | 任何方案中,**一个进程内 OCC 操作必须串行**;并行只能靠多进程 |
| C++ 栈不可中断 | 线程级超时只能"抛弃"不能"击杀";**只有进程级 kill 是干净的故障恢复** |
| `run_occ(fn, *args)` 传闭包/绑定方法 | 函数对象不可跨进程 pickle——进程化方案必须改接口为"操作名 + 可序列化参数" |
| STEP 重载成本 | 进程间不共享 OCC 形状对象;跨进程方案每次调用需重新读文件/传 BRep(几秒级) |
---
## 2. 方案对比
### 方案 A:Celery prefork 并行伸缩(短期,零新代码)
**做法**:承认"每子进程一个串行 OCC 通道"的既有事实,把 OCC 吞吐问题转化为 worker 进程数问题:`celery -A celery_app worker --concurrency=N`,N = 期望的并行分析数(受 CPU 核数与每进程内存约束)。
- **优点**:零代码改动;进程边界天然兜住线程泄漏——泄漏线程随子进程存亡,配合 `--max-tasks-per-child=M`(子进程处理 M 个任务后重启回收)可把滞留线程的存续时间限制在一个批次内。
- **代价**:每个子进程常驻完整 Python + OCC 运行时(数百 MB),N 不能无脑调大;DB 连接按 celery 角色池(pool_size=5)随子进程倍增,PG `max_connections` 需要相应预算。
- **不解决**:单任务超时后该子进程内的线程滞留(被 max-tasks-per-child 兜底回收);单任务无加速(串行本质不变)。
**结论:立即可用的推荐做法**。部署侧调整(concurrency / max-tasks-per-child)随下次镜像与 compose 评审落地,先在 [OPERATIONS.md](../../OPERATIONS.md) 记录启动参数建议。
### 方案 B:常驻 OCC 进程池 + kill-on-timeout(中期,推荐演进方向)
**做法**:在 `run_occ` 接口之下替换执行器——不再是 `ThreadPoolExecutor`,而是**常驻的单线程 OCC 工作进程池**(每进程一个事件循环:接任务 → 执行 → 回报)。超时由主进程 `terminate()` 工作进程并更换新进程补位。
- 接口演进:`run_occ(fn, *args)` → `run_occ(op_name: str, payload: dict)`,操作名注册表映射到模块级函数(STEP 文件路径进、JSON/BRep 文件出,杜绝 pickle 大对象);各调用点(解析、型腔、倒扣、导出三角化……)逐一迁移。
- **优点**:超时 = 杀进程,**故障恢复干净彻底**(D10 残留泄漏根治);OCC 崩溃(segfault)不再波及 API/worker 主进程;进程池大小与 celery 并发解耦。
- **代价**:一次明确的接口迁移(所有 `run_occ` 调用点 + 结果序列化);进程池自管理(补位、健康检查、启动预热——spawn 下 import OCC 秒级,需常驻而非按任务拉起);跨进程只传文件路径 + JSON,现有"传形状对象"的内部调用要改为落盘中转。
- **风险**:自建进程池的运维复杂度;Windows 开发环境 spawn 语义与 Linux fork 差异需测试覆盖。
### 方案 C:OCC sidecar 服务(长期,视伸缩需求)
**做法**:OCC 能力独立成进程/容器(HTTP 或 gRPC),API 与 worker 都是客户端;STEP 按路径/对象键传入,返回 JSON 摘要 + 产物对象键。
- **优点**:隔离最彻底;OCC 可独立伸缩、独立发布、独立扩容 GPU/内存型节点;多语言可复用。
- **代价**:新增一个部署单元与序列化边界(大网格/形状数据传输设计);超出当前"单 compose 栈"的部署叙事,需与 DEPLOYMENT 文档体系一起演进。
**结论:除非出现独立伸缩/隔离性硬需求,暂不启动。**
---
## 3. 决策与路线
| 阶段 | 动作 | 状态 |
|---|---|---|
| 短期 | 方案 A:`--concurrency` 伸缩 + `--max-tasks-per-child` 兜底回收;`cancel_futures=True` 修复重建并发风险 | ✅ 部署参数 2026-09-18 落地(`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 compose + .env.example;2026-09-24 起 worker 与后端共用 `gemold-backend` 镜像、Dockerfile.celery 移除,参数以 compose `command:` 覆盖传递) |
| 中期 | 方案 B:`run_occ(op_name, payload)` 接口演进 + 常驻进程池,kill-on-timeout 根治泄漏 | ✅ 2026-09-18 实施完成(见 §5;回归测试 [tests/test_occ_process_pool.py](../../../tests/test_occ_process_pool.py)) |
| 长期 | 方案 C:sidecar,仅在出现独立伸缩需求时启动 | 暂不启动 |
## 4. 已落地的缓解(2026-09-17,批次 4)
`_reset_occ_executor` 的 `shutdown(wait=False)` 补 `cancel_futures=True`。这不只是卫生问题:旧实现下旧 executor 的**排队任务不会消失**——若挂死线程后来"慢恢复",旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争 / 崩溃风险)。补参后排队任务即被丢弃,残留问题收敛为"运行中线程滞留 1 个",由方案 A 的进程回收兜底。
## 5. 方案 B 实施记录(2026-09-18)
**接口**:`run_occ(fn, *args)` → `run_occ(op_name, payload)`;执行器由进程内线程池替换为常驻进程池。
- **新增** [occ_process_pool.py](../../../src/moldinsight/services/occ_process_pool.py):`OccProcessPool`(默认 size=1)。每个 `_OccWorker` 是一个 spawn 出的常驻子进程 + 双工管道 + 独立 `asyncio.Lock`(通道串行);阻塞收发经 `asyncio.to_thread` 不卡事件循环。操作超时或进程死亡 → `terminate()` + 换新补位;任务级整体超时(`process_file_with_storage` 外层 wait_for)→ `recover()` 整体重建。`shutdown()` 供应用退出/测试清理。
- **新增** [occ_worker.py](../../../src/moldinsight/core/occ_worker.py):操作注册表 + `worker_main` 消息循环。OCC 模块在 handler 内惰性导入(父进程 pip 环境无 OCC 也可 import),进程内单例缓存(parser/planner/analyzer/mesh_gen 等)。全部操作输入输出为**文件路径 + 普通字典**,TopoDS 不跨进程。
- **调用点迁移**(processing_service):
- `parse_stp` = 原 load_step_file + analyze_geometry 两步合一(形状在子进程内即生即用)
- `generate_mesh` / `analyze_mold_design` / `detect_undercuts` / `convert_component_step` 同名对位
- `generate_cavity` = 分模 + 方案形状持久化 STEP 导出全在子进程内;`_export_shapes`(TopoDS)不再回主进程,返回 export_manifest(与旧 `_persist_step_exports` 结构一致,主进程原样存 export_artifacts)
- 旧 `_cache_export_shapes` / `get_export_shapes` / `_persist_step_exports` / `_export_shapes_cache` 及线程 executor / `_reset_occ_executor` 全部删除(跨进程本就不存在内存形状缓存,export_router 相应移除 `export_mold_results` 内存分支)
- [shape_loader.py](../../../src/moldinsight/services/shape_loader.py) → [stp_materializer.py](../../../src/moldinsight/services/stp_materializer.py):只把 STP 原件落盘临时文件,OCC 解析交给子进程操作
- **成本确认**:每个 spawn 子进程首次操作需 import OCC(秒级);进程常驻后后续操作复用缓存实例。每个操作从 STP 原件重新加载形状(STEP 重载成本,见 §1.2)——原线程方案跨步骤共享 shape 的内存优势让位于进程隔离,符合方案 B 设计取舍。
- **测试**:[tests/test_occ_process_pool.py](../../../tests/test_occ_process_pool.py)(OCC-gated,6 例):spawn+管道往返 / 未知操作错误回传 / 子进程异常浮出 / 超时换新补位 / 真实盒体 STP 解析端到端 / generate_cavity 分模+STEP 落盘端到端。