From 1079b62f0a0a44a5ce6a847a50e45f2da3a80c9e Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:08:51 +0800 Subject: [PATCH] update --- HANDOVER.md | 240 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 212 insertions(+), 28 deletions(-) diff --git a/HANDOVER.md b/HANDOVER.md index d7520f9..63a5dbf 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -6,7 +6,50 @@ --- -## 1. 最近 5 个 commit(按时间倒序) +## 1. 最近 8 个 commit(按时间倒序) + +### `85b2916` — fix: local_storage_base_dir(bucket 标识符语义统一:最终 fix) + +**背景**:见 §3。「删除文件存在报错 `failed to move object to trash: 'workspace'`」 +的真实根因 —— `StorageObjects.bucket_name` 写的是裸字符串(`"workspace"`), +`app.state.object_stores` 的 key 是 local 模式拼出来的路径(`"data/workspace"`), +两边错位导致 KeyError。 + +**改动**(1 个文件,19+/15-): + +| 文件 | 改动 | +|---|---| +| `common/src/common/storage/factory.py` | `actual_bucket_name(purpose)` 合并 local/s3 两个分支,单一返回 `settings.s3__bucket`;`build_storage_uri` local 分支从 `Path(bucket_name).absolute()` 改为 `(Path(local_storage_base_dir) / bucket_name).resolve()`;注释 / docstring 同步重写 | + +**重要语义区分**: + +- `actual_bucket_name(purpose)` → 返回 **s3 风格 bucket 标识符**(如 `"workspace"`、 + `"run-logs"`),local 和 s3 模式返回相同字符串。这是 `app.state.object_stores` + 的 dict key,也是 `StorageObjects.bucket_name` / `UploadSessions.bucket_name` + 存储的值。 +- `build_storage_config(purpose)` → 内部仍然 `Path(local_storage_base_dir) / purpose` + 拼 `base_dir`,所以 local backend 实例的磁盘根仍是 `./data//`。这层 + 与 `bucket_name` 字段解耦,是设计上正确的。 + +### `25e563d` — update: ruff check --fix + +`ruff` 自动格式化,13 个文件,0 逻辑改动。可视为「`25e563d` 之前最近一次大改 +(`5d49ff5` / `2e070fa` / `5c5dec9` / `d855912` / `85b2916`)的格式统一」。如果 +review 时看到大量 import 重排 / 引号风格变化,都是 ruff 干的。 + +### `d855912` — fix: soft delete helper(写入端 bucket_name 修复) + +**背景**:承接 `85b2916` 的语义重写。`85b2916` 之前 `scripts.py:482` 用的是 +`settings.s3_workspace_bucket`(裸字符串);修完 `actual_bucket_name` 之后 +该函数返回的是 s3 风格标识符,**逻辑等价**,但 `scripts.py` 调用方式必须跟着改 +才能保持一致性。`_resolve_bucket_for_usage` 的 fallback 路径有同一类 bug。 + +**改动**(2 个文件,4+/2-): + +| 文件 | 改动 | +|---|---| +| `backend/src/backend/scripts.py` | import 增加 `actual_bucket_name`;`bucket_name = settings.s3_workspace_bucket` → `bucket_name = actual_bucket_name("workspace")` | +| `backend/src/backend/services/storage.py` | `_resolve_bucket_for_usage` fallback 从 `BUCKET_FOR_USAGE.get(usage_type, settings.s3_workspace_bucket)` 改为 `BUCKET_FOR_USAGE.get(usage_type, actual_bucket_name(USAGE_TYPE_TO_PURPOSE.get(usage_type, "workspace")))` | ### `139f2c0` — fix: delete error(trash 路径统一 + 删除后 DB 字段更新) @@ -127,42 +170,169 @@ item.deleted_at = None --- -## 3. 线上数据兼容(重要) +## 3. Bucket 标识符语义统一(commits `d855912` + `85b2916`) -修复后**新行为对 DB 里已有的旧行不感知**。如果部署环境之前是 -`S3_WORKSPACE_BUCKET=workspaces`(复数)且已上传过文件, -`storage_objects.bucket_name` 列里会有复数残留。新代码 default 是单数, -`object_stores` dict key 也是单数,访问旧行仍会 `KeyError: 'workspaces'`。 +### 3.1 错位现象 -**部署新代码前必须先跑一次数据修复 SQL**(先 SELECT 看数量,确认是同一个 deployment): +`actual_bucket_name(purpose)` 在 `85b2916` 之前有两个分支: + +```python +def actual_bucket_name(purpose: str) -> str: + if settings.storage_backend == "local": + return str(Path(settings.local_storage_base_dir) / purpose) # → "data/workspace" + return getattr(settings, f"s3_{purpose}_bucket") # → "workspace" +``` + +这条函数被三个地方用: + +| 消费方 | 期望的值 | 实际拿到的(local 模式,修前) | +|---|---|---| +| `app.state.object_stores` dict key | 与 DB `bucket_name` 一致 | `"data/workspace"` | +| `StorageObjects.bucket_name` 写入 | 与 dict key 一致 | `"data/workspace"`(来自 `scripts.py:482` 的旧 `settings.s3_workspace_bucket`)| +| `UploadSessions.bucket_name` 写入 | 与 dict key 一致 | `settings.s3_workspace_bucket` = `"workspace"`(来自 `_resolve_bucket_for_usage` 的 fallback)| + +表里第三行就是本次出问题的根本:`UploadSessions.bucket_name` 写的是裸字符串 `"workspace"`,但 +`app.state.object_stores` 的 key 是路径 `"data/workspace"`。两者 lookup 时必然 KeyError。 +**注意**:本部署所有 DB row 的 `bucket_name` 都是裸字符串(`"workspace"`),跟 +`object_stores` dict key 错位 — 软删时 `object_stores["workspace"]` 直接抛 +`KeyError: 'workspace'`,被 `soft_delete_object` 的 try/except 包成 +`HTTPException(502, "failed to move object to trash: 'workspace'")`。 + +`scripts.py:482` 写出的 row 反而是错的 `"data/workspace"`,**但**所有现存 row 写于本会话 +之前的旧版本,所以 DB 里没有这种 row。`scripts.py` 是用户从 Jupyter 创建新脚本时触发的 +路径,本次会话没有产生新 row。 + +### 3.2 `85b2916` 的最终语义 + +`actual_bucket_name(purpose)` 合并为单一返回: + +```python +def actual_bucket_name(purpose: str) -> str: + from common.config import settings # 延迟 import 避免循环 + return getattr(settings, f"s3_{purpose}_bucket") +``` + +返回值约定(local / s3 两种模式相同): + +| purpose | 返回(与 `.env` 对齐) | +|---|---| +| `workspace` | `settings.s3_workspace_bucket` | +| `version` | `settings.s3_version_bucket` | +| `run_log` | `settings.s3_run_log_bucket` | +| `trash` | `settings.s3_trash_bucket` | + +这是**纯标识符**,不是文件系统路径。 + +`build_storage_config(purpose)` 内部仍然走 `Path(local_storage_base_dir) / purpose` 拼 +`base_dir`,所以 local backend 实例的磁盘根目录没变(仍是 `./data//`)。 +**这里有个两层抽象**: + +- `bucket_name` 字段(DB 存储 / dict key / URI 字符串)— s3 风格标识符,`actual_bucket_name` 负责 +- `base_dir`(local backend 实际写入的磁盘根)— 路径,由 `build_storage_config` 内部负责 + +两层解耦后,修改 `actual_bucket_name` 不会移动任何字节。 + +### 3.3 `build_storage_uri` 配套修复 + +旧版本 local 分支: + +```python +absolute_path = Path(bucket_name).absolute().as_posix() # 假设 bucket_name 是路径 +``` + +修后: + +```python +absolute_path = (Path(settings.local_storage_base_dir) / bucket_name).resolve().as_posix() +``` + +因为 `bucket_name` 不再是路径,必须显式拼上 `local_storage_base_dir`。 + +### 3.4 写入端对齐(commit `d855912`) + +| 文件 | 改动 | +|---|---| +| `backend/src/backend/scripts.py:482` | `bucket_name = settings.s3_workspace_bucket` → `bucket_name = actual_bucket_name("workspace")` | +| `backend/src/backend/services/storage.py:_resolve_bucket_for_usage` | fallback 从 `settings.s3_workspace_bucket` 改为 `actual_bucket_name(USAGE_TYPE_TO_PURPOSE.get(usage_type, "workspace"))` | + +**为什么 `d855912` 是必要的**:`85b2916` 改了 `actual_bucket_name` 的返回语义, +但 `scripts.py:482` 直接读 `settings.s3_workspace_bucket`、`_resolve_bucket_for_usage` +直接读 `settings.s3_workspace_bucket` —— 这两处必须改成 `actual_bucket_name(...)` , +否则它们绕过了新语义,写出的 row 又是裸字符串,新 dict key 是 s3 风格标识符, +两边重新错位。**两 commit 必须一起部署**,单独 `85b2916` 会让 dict key 是 `"workspace", +DB 仍是 `settings.s3_workspace_bucket` 写出的 `"workspace"` —— 看似 OK 但万一某天 +`.env` 改了 `S3_WORKSPACE_BUCKET=workspaces`,就立刻重新炸。 + +--- + +## 4. 线上数据兼容(重要) + +### 4.1 当前部署(local 模式 + 单数桶名) + +`d855912` + `85b2916` 之后,**新代码对 DB 里已有的旧行完全兼容**: +现有 7 个 `bucket_name='workspace'` 的 row 直接命中 dict key,无需迁移。 +重启 backend 后就能正常软删。 + +### 4.2 历史异构部署(如果你接手的环境曾跑过下面任一形态) + +#### 4.2.1 `.env` 曾是复数桶名(`S3_WORKSPACE_BUCKET=workspaces` 等) + +如果该部署曾用复数 `.env` 上传过文件,`storage_objects.bucket_name` 列里会有 +复数残留。新代码 dict key 是单数,访问旧行仍会 `KeyError: 'workspaces'`。 +**部署新代码前必须先跑一次数据修复**(先 SELECT 看数量): ```sql --- 1. 先看现状(按 backend 区分) SELECT storage_backend, bucket_name, COUNT(*) FROM storage_objects WHERE object_status = 'available' GROUP BY storage_backend, bucket_name ORDER BY storage_backend, bucket_name; --- 2a. S3 部署:把复数残留修正成单数 UPDATE storage_objects SET bucket_name = 'workspace' WHERE bucket_name = 'workspaces' AND object_status = 'available'; --- 2b. local 部署:把绝对路径里的复数残留修正成单数 -UPDATE storage_objects -SET bucket_name = '/data/workspace' -WHERE bucket_name = '/data/workspaces' - AND object_status = 'available'; +-- 同样模式处理 version / run-log / trash 残留 ``` -> **只改 `available` 行**;`deleted` 行已经在 trash 里、bucket_name 会被新代码改写,无需处理。 -> 如果同时改 `version` / `run_log` 桶残留,按相同模式跑。 +#### 4.2.2 `bucket_name` 残留 `Path` 形式(`/data/workspace` 等) + +如果历史版本曾把 `actual_bucket_name("workspace")` 的路径形式(`/data/workspace`、 +`./data/workspace`、`data/workspace`)写进 `bucket_name` 列,需要归一化成裸字符串: + +```sql +UPDATE storage_objects +SET bucket_name = 'workspace' +WHERE bucket_name LIKE '%/workspace' AND storage_backend = 'local' + AND object_status IN ('available', 'deleted'); +``` + +**只改 `available` / `deleted` 行**;正在上传中的 row(`object_status='uploading'`) +不要碰,会丢数据。 + +#### 4.2.3 异构 backend 的旧行 + +如果某个 row 的 `storage_backend` 与当前部署不一致(例如旧 row 是 `s3`、当前部署是 +`local`),物理文件位置在旧 backend 那边,**软删时只翻 DB 状态,物理文件没移动**。 +处理:人工导出后清理,或者重新上传。 + +### 4.3 监控(建议加) + +`storage_objects` 行 `bucket_name` 与 `actual_bucket_name(purpose)` 不一致的比例 +(按 `usage_type` 分组 COUNT)。生产环境应长期趋近于 0。 + +```sql +SELECT usage_type, bucket_name, COUNT(*) +FROM storage_objects +WHERE object_status = 'available' +GROUP BY usage_type, bucket_name +ORDER BY usage_type, bucket_name; +``` --- -## 4. 本地验收清单 +## 5. 本地验收清单 ```bash # 1. 语法 @@ -172,21 +342,26 @@ uv run python -m compileall common/src backend/src runtime/src schedule/src uv run --package backend pytest backend/tests -q # 期望:30 passed -# 3. 命名一致性 -grep -rn '"workspaces"\|"versions"\|"run-logs"' \ - --include='*.py' --include='*.yml' --include='*.env*' \ - backend/src common/src docker-compose.yml .env .env.example 2>/dev/null -# 期望:除 `__tablename__ = "workspaces"`(DB 表名)和 `auth.py` 的 JSON key 外,零结果 +# 3. bucket 标识符一致性(s3 风格字符串,不带路径) +grep -rn "bucket_name = settings\." \ + --include='*.py' backend/src common/src 2>/dev/null +# 期望:零结果 — bucket_name 全部走 actual_bucket_name(...) # 4. 单复数对齐 grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUCKET' \ .env.example -# 期望:全部 = 单数(workspace / version / run-log / trash) +# 期望:所有桶名与代码 default 一致(参考 .env.example 注释) + +# 5. 命名残留扫 +grep -rn '"workspaces"\|"versions"\|"run-logs"' \ + --include='*.py' --include='*.yml' --include='*.env*' \ + backend/src common/src docker-compose.yml .env .env.example 2>/dev/null +# 期望:除 `__tablename__ = "workspaces"`(DB 表名)和 `auth.py` 的 JSON key 外,零结果 ``` --- -## 5. 回归测试覆盖 +## 6. 回归测试覆盖 | 场景 | 触发方式 | 期望 | |---|---|---| @@ -195,10 +370,12 @@ grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUC | 软删后 `storage_objects` 7 字段同步 | 软删后 `SELECT * FROM storage_objects WHERE storage_object_id=...` | 见 §2.1 表格 | | `restore_object` 把 trash 文件写回源桶 | 软删 → restore → 查源桶 | 源桶恢复;trash 文件保留(orphan) | | `purge_trash_object` 拒绝误删源数据 | 手工:把 `bucket_name` 改成源桶后调 purge | 409 | +| local 模式软删走通 | local 部署下软删一个 script | 不抛 `KeyError: 'workspace'`;trash 落到 `./data/trash//` | +| `actual_bucket_name` 两种模式返回相同字符串 | 写测试或 trace | s3 模式 = local 模式(与 `.env` 对齐)| --- -## 6. 监控项(建议加) +## 7. 监控项(建议加) 1. `storage_objects` 行 `bucket_name` 与 `actual_bucket_name(purpose)` 不一致的比例 (按 `usage_type` 分组 COUNT)。生产环境应长期趋近于 0。 @@ -209,7 +386,7 @@ grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUC --- -## 7. 已知未实现项 +## 8. 已知未实现项 - **Trash reaper**:`s3_trash_retention_days` 已声明,但代码没有扫表清理。 `restore_object` 故意保留 trash 文件作 orphan,等 reaper 兜底。 @@ -229,14 +406,21 @@ grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUC 写入的已删行,`object_key` 还是源 key,restore 会找不到 trash 文件(404)。 处理:在 `restore_object` 加 fallback — 先按 `item.object_key` 试,失败再按 `item.trash_key` 试一次(兼容窗口期)。建议在 reaper 上线后删除 fallback。 +- **DB 测试种子数据**:`storage_objects` 表里有 `workspace_id='00000000000000000000000002'` + 的行(全 0 + 02,不是有效 ULID 格式),对应的 `object_key` 在磁盘 `data/workspace/` 下 + 找不到对应文件(磁盘上是真实 workspace ULID 子目录 `00000000000BM630VT9ARVFZPC`、 + `01KZR60SWWTXXM0KNZ2GJMT36A`)。这些是测试种子。修复 bucket 标识符后,软删会从 KeyError + 变成 StorageNotFoundError —— KeyError 解决后真实地暴露了「字节缺失」。处理:清掉这些 + 测试 row,或补齐磁盘文件。 --- -## 8. 仍未合并到 develop 的本地改动 +## 9. 仍未合并到 develop 的本地改动 ```text M frontend/vite.config.ts (4+/2-) ?? data/ (untracked — 本地 docker volume 残留,可忽略) ``` -`frontend/vite.config.ts` 的改动不在本次 trash 修复范围内,等下次 commit 一起提。 \ No newline at end of file +`frontend/vite.config.ts` 的改动不在本次 trash / bucket 修复范围内,等下次 commit 一起提。 +`data/` 是 `STORAGE_BACKEND=local` 的对象存储本地根,git ignore 不应跟踪。 \ No newline at end of file