242 lines
11 KiB
Markdown
242 lines
11 KiB
Markdown
# Handover — 近期变更与待办
|
||
|
||
面向接手运维 / 二次开发的人。记录最近几次 commit 的动机、改动范围、未尽事项,
|
||
以及「线上有旧数据时怎么过渡」。详细架构见 `ARCHITECTURE.md`;本仓库的开发规约
|
||
见 `DEVELOP.md`。
|
||
|
||
---
|
||
|
||
## 1. 最近 5 个 commit(按时间倒序)
|
||
|
||
### `139f2c0` — fix: delete error(trash 路径统一 + 删除后 DB 字段更新)
|
||
|
||
**背景**:删除文件统一改为移动到 trash 桶后,存在三个连锁 bug:
|
||
|
||
1. **local 模式 trash 路径双重 `data`**:`trash_key = f"{item.bucket_name}/{item.object_key}"`。
|
||
local 模式下 `bucket_name` 是绝对路径(如 `/data/workspace`),最终落到
|
||
`data/trash/data/workspace/{ws_id}/{user_id}/...`。
|
||
2. **s3 模式桶名 / 路径名错位**:`.env` 之前是 `S3_WORKSPACE_BUCKET=workspaces`(复数),
|
||
代码 default / `PURPOSE_BUCKETS` / `USAGE_TYPE_TO_PURPOSE` 全部是单数 `workspace`。
|
||
跨环境部署产生 `KeyError: 'workspaces'`(软删时 `object_stores[item.bucket_name]` 抛错)。
|
||
3. **软删后 `storage_objects` 路径字段未更新**:只翻 `trash_key` / `object_status` /
|
||
`deleted_at` / `is_deleted`,`bucket_name` / `object_key` / `storage_uri` 仍指向源,
|
||
与物理位置不一致,`purge_trash_object` 必须读旧 `trash_key` 才能定位 trash 文件。
|
||
|
||
**改动**(5 个文件,44+/24-):
|
||
|
||
| 文件 | 改动 |
|
||
|---|---|
|
||
| `backend/src/backend/services/storage.py` | `trash_key = f"{source_purpose}/{item.object_key}"`;软删成功后更新 `bucket_name` / `object_key` / `storage_uri` 指向 trash 位置 |
|
||
| `backend/src/backend/storage_api.py` | `restore_object` 用 `item.object_key` 直接定位 trash,按 `/` 拆 `source_purpose`,写回源桶,更新 DB 字段指回 source;`purge_trash_object` 用 `(actual_bucket_name("trash"), item.object_key)` 删除,加 `bucket_name != trash` 的 409 防御 |
|
||
| `common/src/common/storage/factory.py` | 引入 `USAGE_TYPE_TO_PURPOSE` 字典(替代 `storage_api.py` 的本地定义) |
|
||
| `common/src/common/storage/__init__.py` | 导出 `USAGE_TYPE_TO_PURPOSE` |
|
||
| `.env.example` | `S3_WORKSPACE_BUCKET=workspaces` → `workspace`、`S3_VERSION_BUCKET=versions` → `version`、`S3_RUN_LOG_BUCKET=run-logs` → `run-log`(统一单数) |
|
||
|
||
**注意点**:
|
||
|
||
- `workspaces_root()` 函数名 / `WORKSPACES_ROOT` 环境变量名不动(这是 path 概念,不是 bucket 名)。
|
||
- `Workspaces` 数据库表名 / `auth.py` 的 `"workspaces"` JSON key(API 响应)不动(这些不是桶名)。
|
||
- `visibility="workspace"` 是 ACL Literal,不动。
|
||
|
||
### `2e070fa` — fix: pre check(前端 script 创建/上传前置校验)
|
||
|
||
`CreateScriptModal.tsx`(+25 行)— 文件名 / workspace_id / 类型前置校验。
|
||
`ScriptsPage.tsx`(+1)/ `scriptWorkspaceStore.ts`(+19/-10)— store action 增加
|
||
`precheck` 步骤,提前拦截无效请求。
|
||
|
||
### `5c5dec9` — fix: allows_same_name_different_parent
|
||
|
||
允许同名 script 出现在不同父目录下。`scripts.py`(+32/-?)、`services/storage.py`、
|
||
`storage_api.py` 微调;`test_scripts.py` 新增 57 行回归测试。
|
||
|
||
### `5d49ff5` — fix: file upload error(大重写)
|
||
|
||
修复 file upload 路径上一连串 schema / API / 上传流程的不一致。改动面最大
|
||
(14 个文件,872+/604-):
|
||
|
||
- 新增索引 / 调整唯一约束(去掉了 scripts 和 data_resources 的旧 unique index)
|
||
- 把多个小 migration 合并进 `e1f2a3b4c5d6_rebuild_baseline.py` 一份大 baseline
|
||
- `resources.py` / `scripts.py` 上传流程对齐
|
||
- `test_resources.py` / `test_scripts.py` 大量回归
|
||
- `API.md` 增加 3 行
|
||
|
||
### `c65d6dc` — fix: build error
|
||
|
||
`backend/Dockerfile` 单行修复(镜像构建报错)。
|
||
|
||
---
|
||
|
||
## 2. Trash 修复详解(commit `139f2c0`)
|
||
|
||
### 2.1 修复后的语义
|
||
|
||
- **trash_key**:`f"{source_purpose}/{item.object_key}"`。`source_purpose` 来自
|
||
`USAGE_TYPE_TO_PURPOSE[item.usage_type]`,与 `item.bucket_name` 解耦 —
|
||
避免 local 模式绝对路径泄漏 + 命名错位风险。
|
||
- **软删后 `storage_objects` 行的 7 个字段同步**:
|
||
|
||
| 字段 | 软删前 | 软删后 |
|
||
|---|---|---|
|
||
| `bucket_name` | 源桶 | trash 桶 |
|
||
| `object_key` | 源 key | trash key |
|
||
| `trash_key` | NULL | trash key |
|
||
| `storage_uri` | 源 URI | trash URI(`build_storage_uri` 重建) |
|
||
| `object_status` | `available` | `deleted` |
|
||
| `is_deleted` | `0` | `1` |
|
||
| `deleted_at` | NULL | `<utcnow>` |
|
||
|
||
- **物理文件落点**:
|
||
|
||
| 模式 | 源文件(被删) | trash 文件(新落) |
|
||
|---|---|---|
|
||
| S3 | `s3://<source_bucket>/<key>` | `s3://trash/<source_purpose>/<key>` |
|
||
| local | `<LOCAL_STORAGE_BASE_DIR>/<source_purpose>/<key>` | `<LOCAL_STORAGE_BASE_DIR>/trash/<source_purpose>/<key>` |
|
||
|
||
`source_purpose` ∈ `{workspace, version, run_log}`。
|
||
|
||
### 2.2 `restore_object` 反向流程
|
||
|
||
```python
|
||
data = await object_stores[trash_bucket].get(item.object_key) # 从 trash 读
|
||
source_purpose, _, source_key = item.object_key.partition("/") # 拆 source_purpose
|
||
target_bucket = actual_bucket_name(source_purpose) # 还原目标桶
|
||
await object_stores[target_bucket].put(source_key, data) # 写回源
|
||
|
||
item.bucket_name = target_bucket
|
||
item.object_key = source_key
|
||
item.storage_uri = build_storage_uri(target_bucket, source_key)
|
||
# trash_key 保留不动 → 未来 reaper 可据此清理 trash 里的 orphan 副本
|
||
item.object_status = "available"
|
||
item.deleted_at = None
|
||
```
|
||
|
||
**trash 文件不会被 restore 主动删除**(保留现有安全语义:restore 失败时
|
||
数据仍在 trash 里有兜底)。未实现的 reaper 未来可扫
|
||
`object_status='available' AND trash_key IS NOT NULL` 清理 orphan。
|
||
|
||
### 2.3 `purge_trash_object` 防御
|
||
|
||
加 409 防御:`if item.bucket_name != actual_bucket_name("trash"): raise 409`。
|
||
避免误删源数据(用 `item.object_key` 直接删 trash 文件,不再读旧 `trash_key`)。
|
||
|
||
### 2.4 USAGE_TYPE_TO_PURPOSE 提升到 common 层
|
||
|
||
`backend/storage_api.py` 与 `backend/services/storage.py` 都依赖这个映射。
|
||
提到 `common/storage/factory.py` 后两边都 `from common.storage import USAGE_TYPE_TO_PURPOSE`,
|
||
避免互相 import 循环。
|
||
|
||
---
|
||
|
||
## 3. 线上数据兼容(重要)
|
||
|
||
修复后**新行为对 DB 里已有的旧行不感知**。如果部署环境之前是
|
||
`S3_WORKSPACE_BUCKET=workspaces`(复数)且已上传过文件,
|
||
`storage_objects.bucket_name` 列里会有复数残留。新代码 default 是单数,
|
||
`object_stores` dict key 也是单数,访问旧行仍会 `KeyError: 'workspaces'`。
|
||
|
||
**部署新代码前必须先跑一次数据修复 SQL**(先 SELECT 看数量,确认是同一个 deployment):
|
||
|
||
```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';
|
||
```
|
||
|
||
> **只改 `available` 行**;`deleted` 行已经在 trash 里、bucket_name 会被新代码改写,无需处理。
|
||
> 如果同时改 `version` / `run_log` 桶残留,按相同模式跑。
|
||
|
||
---
|
||
|
||
## 4. 本地验收清单
|
||
|
||
```bash
|
||
# 1. 语法
|
||
uv run python -m compileall common/src backend/src runtime/src schedule/src
|
||
|
||
# 2. 测试
|
||
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 外,零结果
|
||
|
||
# 4. 单复数对齐
|
||
grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUCKET' \
|
||
.env.example
|
||
# 期望:全部 = 单数(workspace / version / run-log / trash)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 回归测试覆盖
|
||
|
||
| 场景 | 触发方式 | 期望 |
|
||
|---|---|---|
|
||
| 同名 script 不同父目录 | `test_soft_delete_object_sets_is_deleted` 旁边的新测试 | 两条 row 同名共存 |
|
||
| 软删 → 物理文件落到 trash 桶的 `source_purpose/{key}` | 手工:上传 → 删 → `mc ls s3/trash/` | 文件在;源桶里已删 |
|
||
| 软删后 `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 |
|
||
|
||
---
|
||
|
||
## 6. 监控项(建议加)
|
||
|
||
1. `storage_objects` 行 `bucket_name` 与 `actual_bucket_name(purpose)` 不一致的比例
|
||
(按 `usage_type` 分组 COUNT)。生产环境应长期趋近于 0。
|
||
2. `object_status='deleted'` 但 `bucket_name != actual_bucket_name('trash')` 的行数 —
|
||
应为 0(软删失败回滚残留)。
|
||
3. trash 桶目录大小 vs `object_status='deleted'` 行数(孤儿文件比例)。
|
||
没实现 reaper 前这个会单调上升。
|
||
|
||
---
|
||
|
||
## 7. 已知未实现项
|
||
|
||
- **Trash reaper**:`s3_trash_retention_days` 已声明,但代码没有扫表清理。
|
||
`restore_object` 故意保留 trash 文件作 orphan,等 reaper 兜底。
|
||
第一次实现建议在 `schedule/src/schedule/` 加一个 tick job:
|
||
```sql
|
||
SELECT storage_object_id FROM storage_objects
|
||
WHERE deleted_at < UTC_TIMESTAMP() - INTERVAL :retention_days DAY
|
||
AND object_status = 'deleted'
|
||
LIMIT 100;
|
||
```
|
||
然后调 `/internal/v1/admin/trash/purge`。
|
||
- **跨 backend trash 兼容**:当前只在 `item.storage_backend == settings.storage_backend`
|
||
的行上做物理移动。异构 backend 的旧行(少)软删时只翻 DB 状态,trash 文件实际没移动。
|
||
远程 8.153.151.51:8890 应该都是同一 backend,目前无影响。
|
||
- **历史 trash_key 兼容读**:新代码用 `item.object_key` 读 trash,**不**回退到
|
||
`item.trash_key`。如果迁移前有按旧 `trash_key = f"{bucket_name}/{object_key}"`
|
||
写入的已删行,`object_key` 还是源 key,restore 会找不到 trash 文件(404)。
|
||
处理:在 `restore_object` 加 fallback — 先按 `item.object_key` 试,失败再按 `item.trash_key`
|
||
试一次(兼容窗口期)。建议在 reaper 上线后删除 fallback。
|
||
|
||
---
|
||
|
||
## 8. 仍未合并到 develop 的本地改动
|
||
|
||
```text
|
||
M frontend/vite.config.ts (4+/2-)
|
||
?? data/ (untracked — 本地 docker volume 残留,可忽略)
|
||
```
|
||
|
||
`frontend/vite.config.ts` 的改动不在本次 trash 修复范围内,等下次 commit 一起提。 |