提交 67d87399 authored 作者: 陈泽健's avatar 陈泽健

fix(performance): MySQL 1038 整行排序爆内存修复收官 + 截图清理闭环(7 处两段式 / 真实混合场景 E2E 确认目标机曲线)

待办① 截图清理坏测试修复:
- config.py 新增 SCREENSHOT_RETENTION_DAYS(默认 3 天)定期清理配置
- main.py lifespan 启动/停止截图定期清理循环(cleanup_screenshots_loop)
- cleanup_service.py 新增定期清理循环实现
- 新增 test_screenshot_cleanup.py(10 个用例),修复原坏测试
- 5.60 已部署:截图 1.2G → 1.1M(1090 文件 / 1132MB)

待办② 真实混合场景 E2E 确认目标机监控曲线:
- 混合场景执行 perf_1c72c087(1804 快照,1797 带 targetResource)
- 报告页目标机资源 CPU/Memory/Load 曲线 + 指标卡渲染正常
- Java 进程瓶颈定位:ubains-meeting-api(CPU avg 377.4%/max 800%)
  + ubains-meeting-inner-api(424.2%/max 800%)

待办③ 克隆任务清理:无遗留,无操作

MySQL 1038 修复第三轮(报告查看 500,累计 7 处两段式):
- get_report() 模式2:最新执行记录先取 id 再回查整行
- get_report() 模式3 / _build_report_from_execution():快照先取 id 按序回查
- get_task_snapshots():快照分页两段式(保留 offset/limit)
- run_project_all() / get_project_task_ids():任务 id 两段式
- 5.60 部署验证:GET /tasks/{id}/report 原 500 → 200

问题处理/计划执行文档 4 篇归档(任务列表 500 + 报告查看 500)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 184f9c39
......@@ -3,13 +3,70 @@
> **生成时间**: 2026-09-07
> **当前分支**: `platform-auto-test`
> **最近提交**: `0aa655a5` fix(performance): 混合场景CSV强制校验与登录监控记录+目标机监控开启与监控图表X轴修复(已同步 origin/platform-auto-test)
> **会话窗口**: 性能测试 — 目标机监控开启(TARGET_MONITOR_ENABLED=true + 运行前校验)+ 监控图表 X 轴修复(dataZoom/零尺寸 resize/空态自动展开),提交推送 `0aa655a5` 并部署 5.60(容器重建
> **状态**: ✅ 已提交推送 + 部署 5.60 验证通过(env 生效 / 代码标记命中);**待真实混合场景任务端到端确认目标机曲线**
> **会话窗口(上一窗口)**: 2026-09-02 混合场景 CSV 强制校验 + 登录计入监控(该窗口改动随本窗口一并提交进 `0aa655a5`
> **会话窗口**: 性能测试 — 待办闭环(截图清理坏测试修复+部署 / MySQL 1038 三轮修复 7 处 / 真实混合场景端到端确认目标机曲线 ✅
> **状态**: ✅ 三项待办全部闭环;本地改动**未提交**(performance_service.py + config.py + cleanup_service.py + main.py + 测试 + 4 文档),待用户 /GitCommit
> **会话窗口(上一窗口)**: 2026-09-07 早 目标机监控开启 + X 轴修复(`0aa655a5`,已推送部署
---
## ⚡ 最新会话更新(2026-09-07)— 目标机监控开启 + 监控图表 X 轴修复 ✅
## ⚡ 最新会话更新(2026-09-07 续)— 待办三项闭环:坏测试修复 + 1038 三轮修复 + 目标机曲线 E2E 确认 ✅
### A. 待办① 坏测试修复 + 截图清理功能补全 ✅
- `SCREENSHOT_RETENTION_DAYS` 加入 `config.py`(默认 3 天);`cleanup_service.py``cleanup_screenshots_loop()` / `start_screenshot_cleanup_loop()``main.py` lifespan 启动/停止接线(含 5.44 截图占满磁盘事故注释);
- `tests/test_screenshot_cleanup.py` 10 个测试全过;本地全量 407 passed(+1 失败为其他窗口半成品 `test_recorder_e2e.py`,与本窗口无关);
- 5.60 部署:remote config 基础上**只加** `SCREENSHOT_RETENTION_DAYS`(保留其他窗口 RECORDER_* 字段),清理循环启动日志确认,首轮删除 1090 个过期截图(释放 1132MB,目录 1.2G→1.1M)。
### B. 待办③ 克隆任务清理 ✅(无需操作)
5.60 任务列表已无 `*_A_no_csv` 等克隆任务(上上窗口已自行清理)。
### C. 待办② 真实混合场景 E2E 确认 ✅(含意外收获:报告查看 500 修复)
**确认基础**:今晨 02:24–02:54 的混合场景任务 `perf_1c72c087`(100 VU / 30min / 12615 请求)恰在 TARGET_MONITOR_ENABLED=true 容器重建后运行,**无需重跑**,直接以其为确认对象:
| 环节 | 结果 |
|------|------|
| 快照 targetResource 覆盖 | ✅ 1793/1800(99.6%,旧执行 0 覆盖 → 开关生效) |
| 数据结构 | ✅ cpuPercent/memPercent/loadAvg1-15/memUsedMb + mysql 全套 + javaProcesses×7 |
| REST API | ✅ `/executions/{id}/snapshots` 200 且 targetResource 正常透出 |
| 前端 dist | ✅ MonitorPanel 含「目标机资源」面板 + CPU/Memory/Load 趋势图 |
| 报告页渲染(浏览器实测) | ✅ 曲线正常(CPU 均值 92.4%/峰值 99.6%,内存 79.4%,Load 21→28),X 轴 0s→1786s 正常;Java 进程表显示瓶颈服务 ubains-meeting-api(CPU 均值 377%、峰值 800% 打满 8 核)、ubains-meeting-inner-api(424%);MySQL 区块(437 采样点)完整 |
**意外收获 — 报告查看 500(1038 第三轮漏网点)**:打开报告页发现 `GET /tasks/{id}/report` 500,同为整行 ORDER BY 爆 sort buffer:
- 触发点:`get_report()` 模式2 `select(PerformanceExecution).order_by(start_time.desc()).limit(1)` —— **LIMIT 1 不豁免**(filesort 仍载入全部候选整行);`performance_executions` 行已被 `*_summary` 大 JSON 撑爆;
- 同轮排查另修 5 处同模式:`get_report()` 模式3、`_build_report_from_execution()``get_task_snapshots()`(快照表两段式)+ `run_project_all()` / `get_project_task_ids()`(只需 id 列表,直接改单列查询);
- **累计 1038 修复 7 处**(v1 list_tasks → v2 get_executions/get_execution_snapshots → v3 本轮 5 处);判断口诀更新:**「表含大 JSON/TEXT 列 + 整行 ORDER BY」即风险,与列表/详情、有无 LIMIT 无关**
- 文档:`Docs/PRD/性能测试/问题处理/_问题处理_报告查看500_同根因第三处漏网点.md` + `_执行计划_修复报告查看500.md`
- 部署 5.60(仅 performance_service.py,SHA-256 校验原子替换 + restart),report 接口 200,浏览器端到端验证通过(截图 `backend/tmp/target_monitor_curves.png`)。
**顺带澄清**:任务详情 REST 返回 `scenario_apis=null` 是 schema 序列化未含该列,DB 中存储完整(3 接口权重 30/20/40),执行不受影响。
### D. 本窗口未提交改动清单(待用户 /GitCommit)
| 文件 | 类型 | 说明 |
|------|------|------|
| `backend/app/services/performance_service.py` | 修改 | 1038 v1+v2+v3 共 7 处两段式修复 |
| `backend/app/config.py` | 修改 | +SCREENSHOT_RETENTION_DAYS(⚠️ 同文件含其他窗口 RECORDER_* 未提交字段,提交需 hunk 级筛选或协商一并提交) |
| `backend/app/services/cleanup_service.py` | 修改 | +截图清理循环函数 |
| `backend/app/main.py` | 修改 | lifespan 清理循环接线 |
| `backend/tests/test_screenshot_cleanup.py` | 新增 | 10 个测试 |
| `Docs/PRD/性能测试/问题处理/` 2 个 | 新增 | 任务列表500 + 报告查看500 问题处理/执行计划(共 4 文件) |
**勿提交**(其他窗口):projects 模块 14 文件、recorder.py、element_locator.py、frontend 多文件、`backend/tmp/``tmp/`
### E. 剩余待办
| # | 任务 | 优先级 | 说明 |
|---|------|--------|------|
| 1 | 本窗口改动提交推送 | P1 | 见上表,config.py 需 hunk 级处理 |
| 2 | 全库扫描 `select(Model).order_by` | P2 | 1038 兜底排查(含非 performance 模块) |
| 3 | performance 大 JSON 列拆表 | P3 | csv_content/summary 系列长期方案 |
---
## ⚡ 会话更新(2026-09-07 早)— 目标机监控开启 + 监控图表 X 轴修复 ✅
### A. 会话背景
......
# 执行计划:修复任务列表查询 500(大 TEXT 列排序爆内存)
> 生成时间:2026-09-07
> 关联文档:`_问题处理_任务列表查询500_MySQL排序爆内存.md`
> 状态:✅ 已完成并验证
---
## 问题概述
`GET /api/performance/tasks`(任务列表)在 5.60 MySQL 环境稳定 500:
`list_tasks()``select(PerformanceTask)` 整行查询 + `ORDER BY created_at`
分页,`csv_content` / `steps` / `*_summary` 等大 TEXT 列随整行进入 MySQL
sort buffer,报 `Out of sort memory`(1038)。
**根因**:与 08-24 outputs 表、08-25 请求详情表同源——含大列的表禁止整行
参与 ORDER BY。`list_tasks` 是同模式查询的漏网点。
---
## 执行步骤
### Step 1: `list_tasks()` 改两段式查询 ✅
**文件**`backend/app/services/performance_service.py`
**改动**:过滤条件保持不变(status / project_id),排序分页改为两段:
1. 第一段只 `select(PerformanceTask.id)` + `order_by(desc(created_at))`
+ `offset/limit`(id 单列进 sort buffer,行宽极小);
2. `ids` 为空直接返回 `([], total)`;
3. 第二段 `select(PerformanceTask).where(id.in_(ids))` 回查整行,
`by_id` 字典还原第一段分页顺序。
### Step 2: 本地回归 ✅
- `python -m pytest tests/ -q` → 398 passed(含 10 个截图清理测试,无回归);
- `python -m py_compile app/services/performance_service.py` → 通过。
### Step 3: 部署 5.60 ✅
- `performance_service.py` 上传至
`/data/third_party/plat-auto-test/backend/app/services/`
(备份 → .tmp 上传 → SHA-256 校验 → 原子 mv → 恢复 owner/mode);
- `docker restart plat-auto-test-app`
- 健康检查 `/health` = 200。
### Step 4: 5.60 验证 ✅
| 验证项 | 结果 |
|--------|------|
| `GET /api/performance/tasks` | ✅ 200(原 500),返回全部任务 |
| 页面「性能测试 → 任务列表」加载 | ✅ 正常 |
| 分页/状态过滤/项目过滤 | ✅ 行为不变(过滤条件未动) |
| 容器日志无 1038 报错 | ✅ |
---
## 影响范围
- 仅改 `list_tasks()` 的查询方式,返回结构与字段不变;
- 路由层 / 前端零改动;
- 其他模块(执行记录列表等)未发现同模式整行排序查询,暂不扩大范围。
---
## 遗留建议(P2,不在本次范围)
1. 全库扫描 `select(Model).order_by` 模式,凡表含大 TEXT/JSON 列的列表查询
统一两段式(当前已修:outputs、request_details、performance_tasks);
2. `performance_tasks` 行内大列(csv_content、summary JSON)长期考虑拆表
存储,任务行只留指针。
# 执行计划:修复报告查看 500(get_report 同根因第三处漏网点)
> 生成时间:2026-09-07
> 关联文档:`_问题处理_报告查看500_同根因第三处漏网点.md`
> 状态:✅ 已完成并验证
---
## 问题概述
`GET /api/performance/tasks/{id}/report` 在 5.60 稳定 500(1038 Out of sort memory)。
`get_report()` 模式2 用 `select(PerformanceExecution).where(task_id==).order_by(start_time.desc()).limit(1)`
整行查询最新执行记录;混合场景执行后执行行含 `target_resource_summary` / `api_summary`
等大 JSON 列,filesort 载入整行爆内存。同一排查中另发现模式3、`_build_report_from_execution`
`get_task_snapshots` 三处对快照表的同类整行排序(预防性修复)。
---
## 执行步骤
### Step 1: `performance_service.py` 4 处改为两段式 ✅
1. `get_report()` 模式2(~626):只 `select(PerformanceExecution.id)` + 同过滤 +
`order_by(start_time.desc()).limit(1)`,拿到最新执行 id 后 `db.get` 整行;
2. `get_report()` 模式3(~643):快照先 `select(PerformanceSnapshot.id)`
+ `order_by(elapsed.asc())`,再 `id.in_` 回查、按序还原;
3. `_build_report_from_execution()`(~772):同上改快照两段式;
4. `get_task_snapshots()`(~581):同上改快照两段式(含 offset/limit 保留分页)。
### Step 2: 本地回归 ✅
- `python -m pytest tests/ -q` → 通过(无回归);
- `python -m py_compile app/services/performance_service.py` → 通过。
### Step 3: 部署 5.60 ✅
- 仅上传 `performance_service.py`(备份 → .tmp 上传 → SHA-256 校验 → 原子 mv →
恢复 owner/mode);
- `docker restart plat-auto-test-app`
- 健康检查 `/health` = 200。
### Step 4: 5.60 验证 ✅
| 验证项 | 结果 |
|--------|------|
| `GET /api/performance/tasks/{id}/report` | ✅ 200(原 500),返回完整报告(摘要/指标/快照/目标机资源) |
| 报告页「目标机资源」CPU/Memory/Load 曲线 + 指标卡 | ✅ 正常渲染 |
| 报告页执行记录下拉/对比选择 | ✅ 正常 |
| 容器日志无 1038 报错 | ✅ |
---
## 影响范围
- 仅改 `get_report()` / `_build_report_from_execution()` / `get_task_snapshots()`
的查询方式,返回结构与字段不变;
- 路由层 / 前端零改动;
- 不涉及其他模块。
---
## 遗留建议(P2)
1. 全库扫描 `select(Model).order_by` 模式最终兜底(含非 performance 模块表);
2. `performance_*` 三表大 JSON 列长期拆表存储(任务/执行行只留指针)。
\ No newline at end of file
# 问题处理:性能任务列表查询 500(MySQL 排序爆内存)
> 处理时间:2026-09-07
> 现象报告人:用户(5.60 页面打开性能测试模块时发现)
> 关联文档:`_问题处理_捕获输出查询500与大JSON列排序爆内存.md`(2026-08-24 同类问题,outputs 表已修)
> 状态:✅ 已修复并部署 5.60
---
## 1. 问题现象
5.60 页面打开「性能测试 → 任务列表」报错:
```
加载任务列表失败: Request failed with status code 500
```
```
GET /api/performance/tasks → 500 Internal Server Error
```
容器日志(每次请求均复现):
```
sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError)
(1038, 'Out of sort memory, consider increasing server sort buffer size')
[SQL: SELECT performance_tasks.id, performance_tasks.name, ..., performance_tasks.csv_content, ... ORDER BY performance_tasks.created_at DESC ...]
```
> 注:该 500 在本窗口部署截图清理(重启容器)**之前就已存在**(08-31 深夜起可复现),
> 与截图清理部署无关。
---
## 2. 根因分析
`backend/app/services/performance_service.py``list_tasks()` 执行:
```python
query = select(PerformanceTask) # SELECT 整行(含全部大 TEXT 列)
query.order_by(desc(PerformanceTask.created_at)).offset(...).limit(...)
```
MySQL `ORDER BY` 走 filesort 时将**整行**载入 sort buffer。而 `performance_tasks`
表的行宽已被以下大列撑爆:
| 大列 | 内容 | 典型体积 |
|------|------|---------|
| `csv_content` | 混合场景 100 账号 CSV 全文 | 数十 KB |
| `steps` | 事务步骤 JSON | 数 KB |
| `resource_summary` / `target_resource_summary` | 执行完回写的资源汇总 JSON | 数 KB ~ 数十 KB |
| `transaction_summary` / `api_summary` | 事务/接口级统计 JSON | 数 KB ~ 数十 KB |
单行体积超过默认 `sort_buffer_size`(256KB~2MB)→ 1038 报错。
**为什么现在才爆**
1. 08-31 后混合场景任务导入 100 账号 CSV(`csv_content` 直接进任务行);
2. 任务执行完成后资源/接口/事务汇总 JSON 回写进任务行(行宽二次膨胀);
3. 任务数量累积后,即使 page_size=20,filesort 仍需把所有候选行载入 buffer 逐行比较。
**与 08-24 问题的关系**:同一根因第三次爆发。08-24 在 outputs 表(`value`
1.28MB)修过一轮,08-25 请求详情表复用时同样用两段式(`get_request_details`),
**`list_tasks` 漏网**——当时它行宽尚小未触发。
SQLite(本地开发)无此限制,本地从未复现。
---
## 3. 修复方案
**排序阶段不触碰大 TEXT 列**`list_tasks()` 改两段式查询(与
`get_request_details` 同一模式):
```python
# 1) 轻量排序:第一段只 SELECT id(大列不进 sort buffer)
ids = ... # select(PerformanceTask.id) + 同过滤 + order_by + offset/limit
# 2) 按 id 回查整行,按第一段顺序还原分页次序
rows = await self.db.execute(
select(PerformanceTask).where(PerformanceTask.id.in_(ids))
)
by_id = {t.id: t for t in rows.scalars().all()}
items = [by_id[i] for i in ids if i in by_id]
```
**v2 补充(同日发现)**:部署 v1 验证时发现 `get_executions()`(执行历史列表)
`get_execution_snapshots()`(监控回放快照)是同模式漏网点——
| 方法 | 大列 | 触发接口 |
|------|------|---------|
| `get_executions` | `resource_summary` / `target_resource_summary` / `api_summary` 等 | `GET /api/performance/executions?task_id=...` 500 |
| `get_execution_snapshots` | `resource` / `targetResource` 大 JSON | `GET /executions/{id}/snapshots`(高概率同爆,预防性修复) |
两者同样改两段式(v2 一并部署)。
**为什么不调大 `sort_buffer_size`**:治标不治本(CSV 再大仍会爆),且每连接
分配全局调大浪费内存——与 08-24 结论一致。
---
## 4. 验证结果
| 验证项 | 结果 |
|--------|------|
| 本地 `pytest tests/` 全量 | ✅ 398 passed(无回归) |
| 5.60 部署后 `GET /api/performance/tasks` | ✅ 200(原 500) |
| 5.60 部署后 `GET /api/performance/executions?task_id=...` | ✅ 200(原 500,v2) |
| 5.60 页面任务列表加载 | ✅ 正常 |
---
## 5. 经验总结
1. **`performance_tasks` 表任何列表查询禁止整行参与 ORDER BY**——新增列表
类接口时一律两段式。
2. 大 JSON/TEXT 列(`csv_content` / `*_summary` / `steps`)是行宽炸弹,
本地 SQLite 永远测不出来,必须在 MySQL 环境验证。
3. 排查同类问题的快捷路径:容器日志搜 `1038` / `Out of sort memory`
再反查对应 SQL 的 `select(Model).order_by(...)` 模式。
---
*本文档由 Claude Code 生成*
# 问题处理:报告查看 500(MySQL 排序爆内存,同根因第三处漏网点)
> 处理时间:2026-09-07
> 现象报告人:Claude Code(修复任务列表 500 后,验证目标机监控曲线时打开「报告查看」发现)
> 关联文档:`_问题处理_任务列表查询500_MySQL排序爆内存.md`(2026-09-07 同根因 v1/v2)
> 状态:✅ 已修复并部署 5.60
---
## 1. 问题现象
5.60 「性能测试 → 报告查看」打开混合场景任务(perf_1c72c087)报告页报错:
```
GET /api/performance/tasks/perf_1c72c087ba4f4854b5f9a8c9a348f565/report → 500 Internal Server Error
```
容器日志(每次刷新均复现):
```
sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError)
(1038, 'Out of sort memory, consider increasing server sort buffer size')
[SQL: SELECT performance_executions.id, ..., performance_executions.target_resource_summary,
..., performance_executions.api_summary, ... FROM performance_executions
WHERE performance_executions.task_id = %s ORDER BY performance_executions.start_time DESC LIMIT %s]
[parameters: ('perf_1c72c087ba4f4854b5f9a8c9a348f565', 1)]
```
> 与同日「任务列表查询 500」同一根因(1038),是 `get_report()` 中的漏网点。
> 该 500 一直存在(08-19 起报告查看即依赖此接口),仅因此前报告页数据少/旧任务
> 行宽小未稳定触发;本次混合场景任务执行后 `target_resource_summary`/`api_summary`
> 等大 JSON 回写进执行行,行宽爆涨后稳定复现。
---
## 2. 根因分析
`backend/app/services/performance_service.py``get_report()` 存在整行排序:
| 行号 | 查询 | 大列 | 触发条件 |
|------|------|------|---------|
| 626 | `select(PerformanceExecution).where(task_id==).order_by(start_time.desc()).limit(1)` | `resource_summary` / `target_resource_summary` / `transaction_summary` / `api_summary` | **本次实际触发**(混合场景执行行宽大 + 任务执行次数多) |
| 643 | `select(PerformanceSnapshot).where(task_id==).order_by(elapsed.asc())` | `resource` / `targetResource` | 存量数据兼容分支(executions 表无该 task 的执行记录时) |
| 772 | `select(PerformanceSnapshot).where(execution_id==).order_by(elapsed.asc())` | `resource` / `targetResource` | 单执行 1800+ 快照整行进 sort buffer;MySQL 下同样存在 1038 风险 |
| 581 | `select(PerformanceSnapshot).where(task_id==).order_by(elapsed.asc())` | `resource` / `targetResource` | 任务快照分页接口(GET /tasks/{id}/snapshots),页大时同风险 |
**为何 v1/v2 漏网**:v1 只扫了「列表类」接口(list_tasks / get_executions /
get_execution_snapshots),`get_report` 属于「详情类」,其最新执行记录查询
`order_by().limit(1)` 也会触发 filesort 载入整行——`LIMIT 1` 并不能让
filesort 少载入候选行(仍要按 created_at/start_time 排序)。
**教训**:判断是否风险只看「表含大 JSON/TEXT 列 + 整行 ORDER BY」两个条件,
与接口是列表还是详情、有没有 LIMIT 无关。
---
## 3. 修复方案
沿用两段式查询(与 v1/v2 同一模式):
1. 第一段只 select 主键 + order_by + 分页/limit(主键进 sort buffer,行宽极小);
2. 第二段 `where(id.in_(ids))` 回查整行,`by_id` 字典还原第一段顺序。
修复 4 处:
- `get_report()` 模式2:先查 `PerformanceExecution.id``db.get` 整行;
- `get_report()` 模式3、`_build_report_from_execution()``get_task_snapshots()`
快照同样先查 `PerformanceSnapshot.id` 再按序回查。
---
## 4. 验证结果
| 验证项 | 结果 |
|--------|------|
| 本地 `pytest tests/` 全量 | ✅ 通过(无回归) |
| 5.60 部署后 `GET /tasks/{id}/report` | ✅ 200(原 500) |
| 5.60 报告页「目标机资源」监控曲线 | ✅ 正常渲染(CPU / Memory / Load 曲线 + 指标卡,数据来自该次执行 1797/1804 快照带 targetResource) |
| 容器日志无 1038 报错 | ✅ |
---
## 5. 经验总结
1. **判断整行排序风险只问两个条件**:表是否含大 JSON/TEXT 列 + 查询是否
`select(Model).order_by(...)`**不要被 LIMIT/详情类接口迷惑**
2. `performance_tasks` / `performance_executions` / `performance_snapshots`
三张表全部已两段式(累计修 7 处)——后续新增任何查询前先复查这三张表。
3. 遗留 P2:仍建议全库扫描 `select(Model).order_by` 模式做最终兜底(见
`_执行计划_修复任务列表查询500_MySQL排序爆内存.md` 遗留建议)。
---
*本文档由 Claude Code 生成*
\ No newline at end of file
......@@ -61,6 +61,8 @@ class Settings:
SCREENSHOT_QUALITY: int = int(os.getenv("SCREENSHOT_QUALITY", "60"))
# SCREENSHOT_MAX_AGE_DAYS: 截图保留天数,执行启动时自动清理超期文件(0=不清理)
SCREENSHOT_MAX_AGE_DAYS: int = int(os.getenv("SCREENSHOT_MAX_AGE_DAYS", "14"))
# 截图保留天数(定期清理循环 cleanup_screenshots_loop 使用,防止截图堆积占满磁盘)
SCREENSHOT_RETENTION_DAYS: int = int(os.getenv("SCREENSHOT_RETENTION_DAYS", "3"))
# 设备模拟配置
DEVICE_SIM_MAX_INSTANCES: int = int(os.getenv("DEVICE_SIM_MAX_INSTANCES", "50"))
......
......@@ -156,6 +156,17 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"僵尸执行看门狗启动失败: {e}")
# 启动截图定期清理循环:截图文件按修改时间保留 SCREENSHOT_RETENTION_DAYS 天
# (默认 3 天),超过即删除。防止执行截图持续堆积占满磁盘
# (5.44 复现:screenshots 8.6G → /data 100% → MySQL 写 /tmp 失败 → 执行中心 500)。
screenshot_cleanup_task = None
try:
from app.services.cleanup_service import start_screenshot_cleanup_loop
screenshot_cleanup_task = start_screenshot_cleanup_loop()
logger.info("截图定期清理循环已启动")
except Exception as e:
logger.warning(f"截图定期清理循环启动失败: {e}")
yield
# 关闭时
......@@ -188,6 +199,15 @@ async def lifespan(app: FastAPI):
pass
logger.info("僵尸执行看门狗已停止")
# 停止截图定期清理循环
if screenshot_cleanup_task:
screenshot_cleanup_task.cancel()
try:
await screenshot_cleanup_task
except asyncio.CancelledError:
pass
logger.info("截图定期清理循环已停止")
# 创建 FastAPI 应用实例
app = FastAPI(
......
......@@ -15,6 +15,7 @@
创建日期:2026-07-12
"""
import asyncio
import os
import logging
import shutil
......@@ -420,3 +421,52 @@ class CleanupService:
stats["database"]["case_results"] = result_count_result.scalar() or 0
return stats
async def cleanup_screenshots_loop(
interval: int = 3600,
retention_days: int = 3,
) -> None:
"""
截图定期清理后台循环。
与调度循环独立(各自 try/except),任何一轮异常不影响后续轮次。
只清理过期截图文件,不动数据库。
Args:
interval: 扫描间隔(秒),默认 1 小时
retention_days: 保留天数,超过此天数的截图被删除,默认 3 天
"""
logger.info(
f"[截图清理] 已启动:间隔 {interval}s,保留 {retention_days} 天"
)
while True:
try:
service = CleanupService(db=None)
result = await service.cleanup_screenshots(days=retention_days, dry_run=False)
if result["count"]:
logger.info(
f"[截图清理] 本轮删除 {result['count']} 个过期截图, "
f"释放 {result['size'] / 1024 / 1024:.2f}MB"
)
except asyncio.CancelledError:
logger.info("[截图清理] 已停止")
raise
except Exception as e:
logger.error(f"[截图清理] 扫描异常: {e}")
await asyncio.sleep(interval)
def start_screenshot_cleanup_loop() -> asyncio.Task:
"""
启动截图定期清理后台任务(供 lifespan 调用)。
Returns:
asyncio.Task: 清理任务句柄(用于关闭时取消)
"""
return asyncio.create_task(
cleanup_screenshots_loop(
interval=3600,
retention_days=settings.SCREENSHOT_RETENTION_DAYS,
)
)
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_screenshot_cleanup.py
模块描述:截图定期清理循环单元测试
作者:czj
创建日期:2026-08-31
"""
import asyncio
import os
import tempfile
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.cleanup_service import (
CleanupService,
cleanup_screenshots_loop,
start_screenshot_cleanup_loop,
)
from app.config import settings
class TestCleanupServiceScreenshotCleanup:
"""CleanupService.cleanup_screenshots 单元测试"""
@pytest.mark.asyncio
async def test_cleanup_screenshots_deletes_old_files(self, tmp_path):
"""过期截图文件应被删除"""
# 创建 2 个过期文件(5 天前)
old1 = tmp_path / "old_step1.png"
old1.write_bytes(b"\x89PNG")
os.utime(old1, (time.time() - 5 * 86400, time.time() - 5 * 86400))
old2 = tmp_path / "old_step2.png"
old2.write_bytes(b"\x89PNG")
os.utime(old2, (time.time() - 4 * 86400, time.time() - 4 * 86400))
# 创建 1 个新文件(1 天前,应保留)
new = tmp_path / "new_step1.png"
new.write_bytes(b"\x89PNG")
os.utime(new, (time.time() - 1 * 86400, time.time() - 1 * 86400))
db = AsyncMock()
service = CleanupService(db, screenshot_dir=str(tmp_path))
result = await service.cleanup_screenshots(days=3, dry_run=False)
assert result["count"] == 2
assert result["size"] > 0
assert not old1.exists()
assert not old2.exists()
assert new.exists()
@pytest.mark.asyncio
async def test_cleanup_screenshots_dry_run(self, tmp_path):
"""dry_run 模式不应删除任何文件"""
old = tmp_path / "old.png"
old.write_bytes(b"\x89PNG")
os.utime(old, (time.time() - 5 * 86400, time.time() - 5 * 86400))
db = AsyncMock()
service = CleanupService(db, screenshot_dir=str(tmp_path))
result = await service.cleanup_screenshots(days=3, dry_run=True)
assert result["count"] == 1
assert old.exists() # dry_run 不删除
@pytest.mark.asyncio
async def test_cleanup_screenshots_empty_dir(self, tmp_path):
"""空目录应返回 0"""
db = AsyncMock()
service = CleanupService(db, screenshot_dir=str(tmp_path))
result = await service.cleanup_screenshots(days=3, dry_run=False)
assert result["count"] == 0
assert result["size"] == 0
@pytest.mark.asyncio
async def test_cleanup_screenshots_nonexistent_dir(self, tmp_path):
"""不存在的目录应返回 0"""
db = AsyncMock()
service = CleanupService(db, screenshot_dir=str(tmp_path / "nonexistent"))
result = await service.cleanup_screenshots(days=3, dry_run=False)
assert result["count"] == 0
assert result["size"] == 0
@pytest.mark.asyncio
async def test_cleanup_screenshots_skips_subdirs(self, tmp_path):
"""子目录不应被删除"""
subdir = tmp_path / "subdir"
subdir.mkdir()
old = tmp_path / "old.png"
old.write_bytes(b"\x89PNG")
os.utime(old, (time.time() - 5 * 86400, time.time() - 5 * 86400))
db = AsyncMock()
service = CleanupService(db, screenshot_dir=str(tmp_path))
result = await service.cleanup_screenshots(days=3, dry_run=False)
assert result["count"] == 1
assert subdir.exists() # 子目录不删除
@pytest.mark.asyncio
async def test_cleanup_screenshots_size_calculation(self, tmp_path):
"""释放空间应正确计算"""
old = tmp_path / "old.png"
content = b"\x89PNG" * 1000
old.write_bytes(content)
os.utime(old, (time.time() - 5 * 86400, time.time() - 5 * 86400))
db = AsyncMock()
service = CleanupService(db, screenshot_dir=str(tmp_path))
result = await service.cleanup_screenshots(days=3, dry_run=False)
assert result["size"] == len(content)
class TestScreenshotCleanupLoop:
"""screenshot_cleanup_loop 单元测试"""
@pytest.mark.asyncio
async def test_start_screenshot_cleanup_returns_task(self):
"""start_screenshot_cleanup_loop 应返回一个 asyncio.Task"""
with patch("app.services.cleanup_service.cleanup_screenshots_loop") as mock_loop:
mock_loop.return_value = asyncio.coroutine(lambda: None)()
task = start_screenshot_cleanup_loop()
assert isinstance(task, asyncio.Task)
task.cancel()
@pytest.mark.asyncio
async def test_cleanup_loop_calls_cleanup_once(self, tmp_path):
"""cleanup_loop 应调用一次 cleanup_screenshots"""
with patch("app.services.cleanup_service.CleanupService") as MockService:
mock_instance = MagicMock()
mock_instance.cleanup_screenshots = AsyncMock(
return_value={"count": 5, "size": 1024}
)
MockService.return_value = mock_instance
# 运行一轮(用 asyncio.wait_for 限制超时)
task = asyncio.create_task(
cleanup_screenshots_loop(interval=1, retention_days=3)
)
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# 验证 CleanupService 被调用
MockService.assert_called_once_with(db=None)
mock_instance.cleanup_screenshots.assert_called_once_with(
days=3, dry_run=False
)
@pytest.mark.asyncio
async def test_cleanup_loop_logs_when_files_deleted(self, tmp_path):
"""当有文件被删除时应记录日志"""
with patch("app.services.cleanup_service.CleanupService") as MockService:
mock_instance = MagicMock()
mock_instance.cleanup_screenshots = AsyncMock(
return_value={"count": 10, "size": 5 * 1024 * 1024}
)
MockService.return_value = mock_instance
task = asyncio.create_task(
cleanup_screenshots_loop(interval=1, retention_days=3)
)
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# 验证被调用(日志通过 logging 输出,此处验证逻辑执行)
mock_instance.cleanup_screenshots.assert_called_once()
@pytest.mark.asyncio
async def test_cleanup_loop_handles_exception(self):
"""异常不应终止循环(下一轮继续执行)"""
call_count = 0
with patch("app.services.cleanup_service.CleanupService") as MockService:
mock_instance = MagicMock()
async def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("DB error")
return {"count": 0, "size": 0}
mock_instance.cleanup_screenshots = side_effect
MockService.return_value = mock_instance
task = asyncio.create_task(
cleanup_screenshots_loop(interval=0.1, retention_days=3)
)
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# 第一轮异常,第二轮成功 → 至少调用了 2 次
assert call_count >= 2
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论